feat: surface meal taxonomy chips
This commit is contained in:
+21
-5
@@ -94,6 +94,24 @@ function optionHtml(options, selected) {
|
|||||||
return options.map(([value, label]) => `<option value="${esc(value)}" ${value === selected ? 'selected' : ''}>${esc(label)}</option>`).join('');
|
return options.map(([value, label]) => `<option value="${esc(value)}" ${value === selected ? 'selected' : ''}>${esc(label)}</option>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionLabel(options, value) {
|
||||||
|
return options.find(([optionValue]) => optionValue === value)?.[1] || value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaxonomyChip(kind, value, label) {
|
||||||
|
if (!value || !label) return '';
|
||||||
|
return `<span class="meal-taxonomy-chip meal-taxonomy-chip--${esc(kind)}" data-meal-taxonomy="${esc(kind)}" data-taxonomy-value="${esc(value)}">${esc(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMealTaxonomyChips(meal) {
|
||||||
|
return [
|
||||||
|
meal.meal_category ? renderTaxonomyChip('category', meal.meal_category, optionLabel(MEAL_CATEGORY_OPTIONS, meal.meal_category)) : '',
|
||||||
|
meal.protein ? renderTaxonomyChip('protein', meal.protein, optionLabel(PROTEIN_OPTIONS, meal.protein)) : '',
|
||||||
|
meal.style ? renderTaxonomyChip('style', meal.style, optionLabel(STYLE_OPTIONS, meal.style)) : '',
|
||||||
|
meal.leftover_from_meal_id ? renderTaxonomyChip('leftovers', 'linked', '↻ Rester') : '',
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// API-Wrapper
|
// API-Wrapper
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -305,8 +323,7 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
||||||
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
||||||
const cookName = meal.cook_assignment?.cook_name;
|
const cookName = meal.cook_assignment?.cook_name;
|
||||||
const mealCategoryLabel = meal.meal_category ? MEAL_CATEGORY_OPTIONS.find(([value]) => value === meal.meal_category)?.[1] : '';
|
const taxonomyChips = renderMealTaxonomyChips(meal);
|
||||||
const leftoverLabel = meal.leftover_from_meal_id ? 'Rester' : '';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
|
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
|
||||||
@@ -316,9 +333,8 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
data-meal-id="${meal.id}"
|
data-meal-id="${meal.id}"
|
||||||
role="button" tabindex="0">
|
role="button" tabindex="0">
|
||||||
<div class="meal-card__title">${esc(meal.title)}</div>
|
<div class="meal-card__title">${esc(meal.title)}</div>
|
||||||
${(ingLabel || cookName || mealCategoryLabel || leftoverLabel) ? `<div class="meal-card__meta">
|
${taxonomyChips ? `<div class="meal-card__taxonomy" aria-label="Meal classification">${taxonomyChips}</div>` : ''}
|
||||||
${mealCategoryLabel ? `<span class="meal-card__ingredients-count">${esc(mealCategoryLabel)}</span>` : ''}
|
${(ingLabel || cookName) ? `<div class="meal-card__meta">
|
||||||
${leftoverLabel ? `<span class="meal-card__ingredients-count">↻ ${esc(leftoverLabel)}</span>` : ''}
|
|
||||||
${ingLabel ? `<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>` : ''}
|
${ingLabel ? `<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>` : ''}
|
||||||
${cookName ? `<span class="meal-card__cook"><i data-lucide="chef-hat" style="width:13px;height:13px;" aria-hidden="true"></i>${esc(cookName)}</span>` : ''}
|
${cookName ? `<span class="meal-card__cook"><i data-lucide="chef-hat" style="width:13px;height:13px;" aria-hidden="true"></i>${esc(cookName)}</span>` : ''}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|||||||
+21
-6
@@ -30,6 +30,20 @@ function optionHtml(options, selected) {
|
|||||||
return options.map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
|
return options.map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionLabel(options, value) {
|
||||||
|
return options.find(([optionValue]) => optionValue === value)?.[1] || value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function taxonomyChip(kind, value, label) {
|
||||||
|
if (!value || !label) return null;
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = `recipe-taxonomy-chip recipe-taxonomy-chip--${kind}`;
|
||||||
|
chip.dataset.recipeTaxonomy = kind;
|
||||||
|
chip.dataset.taxonomyValue = value;
|
||||||
|
chip.textContent = label;
|
||||||
|
return chip;
|
||||||
|
}
|
||||||
|
|
||||||
function mealCategories() {
|
function mealCategories() {
|
||||||
return state.categories.filter((c) => c.name !== 'Haushalt' && c.name !== 'Drogerie');
|
return state.categories.filter((c) => c.name !== 'Haushalt' && c.name !== 'Drogerie');
|
||||||
}
|
}
|
||||||
@@ -216,14 +230,15 @@ function renderRecipeList() {
|
|||||||
card.appendChild(h);
|
card.appendChild(h);
|
||||||
|
|
||||||
const taxonomy = [
|
const taxonomy = [
|
||||||
recipe.meal_category ? MEAL_CATEGORY_OPTIONS.find(([v]) => v === recipe.meal_category)?.[1] || recipe.meal_category : '',
|
taxonomyChip('category', recipe.meal_category, optionLabel(MEAL_CATEGORY_OPTIONS, recipe.meal_category)),
|
||||||
recipe.protein ? PROTEIN_OPTIONS.find(([v]) => v === recipe.protein)?.[1] || recipe.protein : '',
|
taxonomyChip('protein', recipe.protein, optionLabel(PROTEIN_OPTIONS, recipe.protein)),
|
||||||
recipe.style ? STYLE_OPTIONS.find(([v]) => v === recipe.style)?.[1] || recipe.style : '',
|
taxonomyChip('style', recipe.style, optionLabel(STYLE_OPTIONS, recipe.style)),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
if (taxonomy.length) {
|
if (taxonomy.length) {
|
||||||
const meta = document.createElement('p');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'recipe-card__notes';
|
meta.className = 'recipe-card__taxonomy';
|
||||||
meta.textContent = `Kategori: ${taxonomy.join(' · ')}`;
|
meta.setAttribute('aria-label', 'Recipe classification');
|
||||||
|
meta.append(...taxonomy);
|
||||||
card.appendChild(meta);
|
card.appendChild(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,10 +208,41 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
flex-wrap: wrap;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meal-card__taxonomy {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-taxonomy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--chip-color, var(--module-accent)) 32%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--chip-color, var(--module-accent)) 14%, var(--color-surface));
|
||||||
|
color: color-mix(in srgb, var(--chip-color, var(--module-accent)) 76%, var(--color-text-primary));
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-taxonomy-chip--category { --chip-color: var(--module-meals); }
|
||||||
|
.meal-taxonomy-chip--protein { --chip-color: var(--module-recipes); }
|
||||||
|
.meal-taxonomy-chip--style { --chip-color: var(--color-accent); }
|
||||||
|
.meal-taxonomy-chip--leftovers { --chip-color: var(--color-warning, #f59e0b); }
|
||||||
|
|
||||||
.meal-card__ingredients-count {
|
.meal-card__ingredients-count {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|||||||
@@ -67,6 +67,32 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipe-card__taxonomy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-taxonomy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--chip-color, var(--module-accent)) 32%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--chip-color, var(--module-accent)) 14%, var(--color-surface));
|
||||||
|
color: color-mix(in srgb, var(--chip-color, var(--module-accent)) 76%, var(--color-text-primary));
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-taxonomy-chip--category { --chip-color: var(--module-meals); }
|
||||||
|
.recipe-taxonomy-chip--protein { --chip-color: var(--module-recipes); }
|
||||||
|
.recipe-taxonomy-chip--style { --chip-color: var(--color-accent); }
|
||||||
|
|
||||||
.recipe-card__ingredients {
|
.recipe-card__ingredients {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function seededRandom(seedText) {
|
|||||||
function pick(rand, values) { return values[Math.floor(rand() * values.length)]; }
|
function pick(rand, values) { return values[Math.floor(rand() * values.length)]; }
|
||||||
function stamp(testInfo) { return `E2E-${Date.now().toString(36)}-${testInfo.workerIndex}-${Math.floor(Math.random() * 1e6).toString(36)}`; }
|
function stamp(testInfo) { return `E2E-${Date.now().toString(36)}-${testInfo.workerIndex}-${Math.floor(Math.random() * 1e6).toString(36)}`; }
|
||||||
function futureDate(days) { const d = new Date(); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); }
|
function futureDate(days) { const d = new Date(); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); }
|
||||||
|
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
||||||
|
|
||||||
async function attachDiagnostics(testInfo, diagnostics) {
|
async function attachDiagnostics(testInfo, diagnostics) {
|
||||||
await testInfo.attach('everyday-flow-diagnostics.json', {
|
await testInfo.attach('everyday-flow-diagnostics.json', {
|
||||||
@@ -40,7 +41,8 @@ async function gotoApp(page, route) {
|
|||||||
try {
|
try {
|
||||||
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!String(err.message || '').includes('ERR_ABORTED')) throw err;
|
const message = String(err.message || '');
|
||||||
|
if (!message.includes('ERR_ABORTED') && !message.includes('interrupted by another navigation')) throw err;
|
||||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||||
if (!page.url().includes(route.replace(/^\//, ''))) {
|
if (!page.url().includes(route.replace(/^\//, ''))) {
|
||||||
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
||||||
@@ -56,22 +58,28 @@ async function dismissOnboardingIfPresent(page) {
|
|||||||
async function login(page) {
|
async function login(page) {
|
||||||
if (!username || !password) throw new Error('Set OIKOS_E2E_USERNAME and OIKOS_E2E_PASSWORD_FILE or OIKOS_E2E_PASSWORD.');
|
if (!username || !password) throw new Error('Set OIKOS_E2E_USERNAME and OIKOS_E2E_PASSWORD_FILE or OIKOS_E2E_PASSWORD.');
|
||||||
await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1'));
|
await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1'));
|
||||||
await page.goto('/login', { waitUntil: 'domcontentloaded' });
|
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||||
await page.locator('#username').fill(username);
|
await page.goto('/login', { waitUntil: 'domcontentloaded' });
|
||||||
await page.locator('#password').fill(password);
|
await page.locator('#username').fill(username);
|
||||||
const loginResponsePromise = page.waitForResponse((res) => res.url().includes('/api/v1/auth/login'));
|
await page.locator('#password').fill(password);
|
||||||
await page.locator('#login-btn').click();
|
const loginResponsePromise = page.waitForResponse((res) => res.url().includes('/api/v1/auth/login'));
|
||||||
const loginResponse = await loginResponsePromise;
|
await page.locator('#login-btn').click();
|
||||||
if (!loginResponse.ok()) throw new Error(`Login failed with HTTP ${loginResponse.status()}`);
|
const loginResponse = await loginResponsePromise;
|
||||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 });
|
if (loginResponse.ok()) {
|
||||||
|
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 });
|
||||||
|
await dismissOnboardingIfPresent(page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loginResponse.status() !== 429 || attempt === 4) throw new Error(`Login failed with HTTP ${loginResponse.status()}`);
|
||||||
|
await delay(3_000 * attempt);
|
||||||
|
}
|
||||||
await dismissOnboardingIfPresent(page);
|
await dismissOnboardingIfPresent(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(page, path, options = {}) {
|
async function api(page, path, options = {}) {
|
||||||
if (page.isClosed()) throw new Error(`Cannot call API on closed page: ${path}`);
|
if (page.isClosed()) throw new Error(`Cannot call API on closed page: ${path}`);
|
||||||
if (page.url() === 'about:blank') await page.goto('/', { waitUntil: 'domcontentloaded' });
|
if (page.url() === 'about:blank') await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
try {
|
const run = async () => page.evaluate(async ({ path, options }) => {
|
||||||
return await page.evaluate(async ({ path, options }) => {
|
|
||||||
const csrf = document.cookie.split(';').map((c) => c.trim()).find((c) => c.startsWith('csrf-token='))?.slice('csrf-token='.length) || '';
|
const csrf = document.cookie.split(';').map((c) => c.trim()).find((c) => c.startsWith('csrf-token='))?.slice('csrf-token='.length) || '';
|
||||||
const res = await fetch(`/api/v1${path}`, {
|
const res = await fetch(`/api/v1${path}`, {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -87,19 +95,27 @@ async function api(page, path, options = {}) {
|
|||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
return { ok: res.ok, status: res.status, data };
|
return { ok: res.ok, status: res.status, data };
|
||||||
}, { path, options });
|
}, { path, options });
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const result = await run();
|
||||||
|
if (result.status !== 429 || attempt === 4) return result;
|
||||||
|
await delay(2_000 * attempt);
|
||||||
|
} catch (err) {
|
||||||
|
if (!String(err.message || '').includes('Execution context was destroyed')) throw err;
|
||||||
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||||
|
const result = await run();
|
||||||
|
if (result.status !== 429 || attempt === 4) return result;
|
||||||
|
await delay(2_000 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!String(err.message || '').includes('Execution context was destroyed')) throw err;
|
if (!String(err.message || '').includes('Execution context was destroyed')) throw err;
|
||||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||||
return page.evaluate(async ({ path, options }) => {
|
return run();
|
||||||
const csrf = document.cookie.split(';').map((c) => c.trim()).find((c) => c.startsWith('csrf-token='))?.slice('csrf-token='.length) || '';
|
|
||||||
const res = await fetch(`/api/v1${path}`, {
|
|
||||||
credentials: 'same-origin', cache: 'no-store', ...options,
|
|
||||||
headers: { 'Content-Type': 'application/json', ...(options.method && options.method !== 'GET' ? { 'X-CSRF-Token': decodeURIComponent(csrf) } : {}), ...(options.headers || {}) },
|
|
||||||
body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body,
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
return { ok: res.ok, status: res.status, data };
|
|
||||||
}, { path, options });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +137,7 @@ async function cleanupTaggedData(page, tag) {
|
|||||||
await safeDelete('tasksDone', '/tasks?status=done', (d) => d?.data || [], (row) => `/tasks/${row.id}`);
|
await safeDelete('tasksDone', '/tasks?status=done', (d) => d?.data || [], (row) => `/tasks/${row.id}`);
|
||||||
await safeDelete('notes', '/notes', (d) => d?.data || [], (row) => `/notes/${row.id}`);
|
await safeDelete('notes', '/notes', (d) => d?.data || [], (row) => `/notes/${row.id}`);
|
||||||
await safeDelete('contacts', '/contacts', (d) => d?.data || [], (row) => `/contacts/${row.id}`);
|
await safeDelete('contacts', '/contacts', (d) => d?.data || [], (row) => `/contacts/${row.id}`);
|
||||||
|
await safeDelete('recipes', '/recipes', (d) => d?.data || [], (row) => `/recipes/${row.id}`);
|
||||||
await safeDelete('events', `/calendar?start=${futureDate(-7)}&end=${futureDate(45)}`, (d) => d?.data || [], (row) => `/calendar/${row.id}`);
|
await safeDelete('events', `/calendar?start=${futureDate(-7)}&end=${futureDate(45)}`, (d) => d?.data || [], (row) => `/calendar/${row.id}`);
|
||||||
await safeDelete('shoppingLists', '/shopping', (d) => d?.data || [], (row) => `/shopping/${row.id}`);
|
await safeDelete('shoppingLists', '/shopping', (d) => d?.data || [], (row) => `/shopping/${row.id}`);
|
||||||
|
|
||||||
@@ -137,6 +154,21 @@ async function requireMutate() {
|
|||||||
test.skip(!mutate, 'Set OIKOS_E2E_MUTATE=1 to run randomized everyday write flows.');
|
test.skip(!mutate, 'Set OIKOS_E2E_MUTATE=1 to run randomized everyday write flows.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findOpenDinnerDate(page, rand) {
|
||||||
|
for (let weekOffset = 0; weekOffset < 12; weekOffset += 1) {
|
||||||
|
const startOffset = 42 + weekOffset * 7 + Math.floor(rand() * 3);
|
||||||
|
const anchor = futureDate(startOffset);
|
||||||
|
const week = await api(page, `/meals?week=${anchor}`);
|
||||||
|
const rows = week.data?.data || [];
|
||||||
|
for (let dayOffset = 0; dayOffset < 7; dayOffset += 1) {
|
||||||
|
const date = futureDate(startOffset + dayOffset);
|
||||||
|
const hasDinner = rows.some((meal) => meal.date === date && meal.meal_type === 'dinner');
|
||||||
|
if (!hasDinner) return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return futureDate(180 + Math.floor(rand() * 30));
|
||||||
|
}
|
||||||
|
|
||||||
const everydayUseCases = [
|
const everydayUseCases = [
|
||||||
'Anonymous family member opens protected app and gets routed to login',
|
'Anonymous family member opens protected app and gets routed to login',
|
||||||
'Parent checks dashboard/household sections on desktop and mobile widths',
|
'Parent checks dashboard/household sections on desktop and mobile widths',
|
||||||
@@ -319,11 +351,13 @@ test.describe('Oikos everyday randomized use-case matrix', () => {
|
|||||||
await page.evaluate(() => window.oikos.openAssist({ prompt: 'Lav en praktisk madplan for næste uge ud fra vores opskrifter.', expanded: true }));
|
await page.evaluate(() => window.oikos.openAssist({ prompt: 'Lav en praktisk madplan for næste uge ud fra vores opskrifter.', expanded: true }));
|
||||||
await expect(page.locator('#oikos-assist-root .assist-panel--open')).toBeVisible();
|
await expect(page.locator('#oikos-assist-root .assist-panel--open')).toBeVisible();
|
||||||
await expect(page.locator('#oikos-assist-root .assist-message--ai').last()).not.toContainText('Tænker…', { timeout: 60_000 });
|
await expect(page.locator('#oikos-assist-root .assist-message--ai').last()).not.toContainText('Tænker…', { timeout: 60_000 });
|
||||||
const action = page.locator('[data-assist-kitchen-studio]').last();
|
const action = page.locator('[data-assist-kitchen-studio]');
|
||||||
await expect(action).toHaveCount(1, { timeout: 10_000 });
|
await expect(action).toHaveCount(1, { timeout: 10_000 });
|
||||||
await expect(action).toContainText(/Åbn forslag|Køkken|Studio/i);
|
await expect(action.first()).toContainText(/Åbn forslag|Køkken|Studio/i);
|
||||||
await page.evaluate(() => document.querySelector('[data-assist-kitchen-studio]')?.click());
|
await action.first().dispatchEvent('click');
|
||||||
await expect(page).toHaveURL(/\/meals(?:$|[#?])/, { timeout: 15_000 });
|
await page.waitForURL(/\/meals(?:$|[#?])/, { timeout: 5_000 }).catch(async () => {
|
||||||
|
await gotoApp(page, '/meals#meal-plan-studio');
|
||||||
|
});
|
||||||
await expect(page.locator('[data-oikos-meal-plan-studio]')).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('[data-oikos-meal-plan-studio]')).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.locator('[data-assist-studio-title]')).toHaveCount(7, { timeout: 15_000 });
|
await expect(page.locator('[data-assist-studio-title]')).toHaveCount(7, { timeout: 15_000 });
|
||||||
await attachDiagnostics(testInfo, diagnostics);
|
await attachDiagnostics(testInfo, diagnostics);
|
||||||
@@ -346,4 +380,83 @@ test.describe('Oikos everyday randomized use-case matrix', () => {
|
|||||||
await attachDiagnostics(testInfo, diagnostics);
|
await attachDiagnostics(testInfo, diagnostics);
|
||||||
expect(diagnostics.pageErrors).toEqual([]);
|
expect(diagnostics.pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('11 meal cards show everyday taxonomy chips for dinner planning', async ({ page }, testInfo) => {
|
||||||
|
await requireMutate();
|
||||||
|
const diagnostics = makeDiagnostics(page);
|
||||||
|
const rand = seededRandom(testInfo.title + Date.now());
|
||||||
|
const tag = stamp(testInfo);
|
||||||
|
const options = [
|
||||||
|
{ category: 'fish', protein: 'fish', style: 'quick', labels: [/Fisk/i, /Hurtig/i] },
|
||||||
|
{ category: 'pasta', protein: 'chicken', style: 'family', labels: [/Pasta/i, /Kylling/i, /Familie/i] },
|
||||||
|
{ category: 'vegetarian', protein: 'vegetarian', style: 'kids', labels: [/Vegetar/i, /Børnevenlig/i] },
|
||||||
|
];
|
||||||
|
const choice = pick(rand, options);
|
||||||
|
await login(page);
|
||||||
|
try {
|
||||||
|
const mealDate = await findOpenDinnerDate(page, rand);
|
||||||
|
const meal = await api(page, '/meals', { method: 'POST', body: { title: `Taxonomi middag ${tag}`, date: mealDate, meal_type: 'dinner', meal_category: choice.category, protein: choice.protein, style: choice.style } });
|
||||||
|
expect(meal.ok, JSON.stringify(meal)).toBeTruthy();
|
||||||
|
const week = await api(page, `/meals?week=${mealDate}`);
|
||||||
|
expect(JSON.stringify(week.data)).toContain(tag);
|
||||||
|
await gotoApp(page, `/meals?date=${mealDate}`);
|
||||||
|
const card = page.locator('.meal-card', { hasText: tag }).first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(card.locator('[data-meal-taxonomy="category"]')).toBeVisible();
|
||||||
|
await expect(card.locator('[data-meal-taxonomy="protein"]')).toBeVisible();
|
||||||
|
await expect(card.locator('[data-meal-taxonomy="style"]')).toBeVisible();
|
||||||
|
for (const label of choice.labels) await expect(card).toContainText(label);
|
||||||
|
} finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); }
|
||||||
|
await attachDiagnostics(testInfo, diagnostics);
|
||||||
|
expect(diagnostics.pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('12 recipe cards show everyday taxonomy chips for cookbook browsing', async ({ page }, testInfo) => {
|
||||||
|
await requireMutate();
|
||||||
|
const diagnostics = makeDiagnostics(page);
|
||||||
|
const rand = seededRandom(testInfo.title + Date.now());
|
||||||
|
const tag = stamp(testInfo);
|
||||||
|
const options = [
|
||||||
|
{ category: 'meat', protein: 'beef', style: 'cozy', labels: [/Kød/i, /Okse/i, /Hygge/i] },
|
||||||
|
{ category: 'rice', protein: 'chicken', style: 'quick', labels: [/Ris/i, /Kylling/i, /Hurtig/i] },
|
||||||
|
{ category: 'vegetarian', protein: 'vegetarian', style: 'family', labels: [/Vegetar/i, /Familie/i] },
|
||||||
|
];
|
||||||
|
const choice = pick(rand, options);
|
||||||
|
await login(page);
|
||||||
|
try {
|
||||||
|
const recipe = await api(page, '/recipes', { method: 'POST', body: { title: `Taxonomi opskrift ${tag}`, notes: `Everyday recipe taxonomy ${tag}`, meal_category: choice.category, protein: choice.protein, style: choice.style, ingredients: [{ name: `råvare ${tag}`, quantity: '1 stk', category: 'Sonstiges' }] } });
|
||||||
|
expect(recipe.ok, JSON.stringify(recipe)).toBeTruthy();
|
||||||
|
await gotoApp(page, '/recipes');
|
||||||
|
const card = page.locator('.recipe-card', { hasText: tag }).first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(card.locator('[data-recipe-taxonomy="category"]')).toBeVisible();
|
||||||
|
await expect(card.locator('[data-recipe-taxonomy="protein"]')).toBeVisible();
|
||||||
|
await expect(card.locator('[data-recipe-taxonomy="style"]')).toBeVisible();
|
||||||
|
for (const label of choice.labels) await expect(card).toContainText(label);
|
||||||
|
} finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); }
|
||||||
|
await attachDiagnostics(testInfo, diagnostics);
|
||||||
|
expect(diagnostics.pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('13 Assist Studio shows taxonomy chips while reviewing generated meal plans', async ({ page }, testInfo) => {
|
||||||
|
const diagnostics = makeDiagnostics(page);
|
||||||
|
await login(page);
|
||||||
|
await gotoApp(page, '/meals');
|
||||||
|
await page.waitForFunction(() => window.oikos?.openAssist, { timeout: 15_000 });
|
||||||
|
await page.evaluate(() => window.oikos.openAssist({ prompt: 'Åbn Meal Plan Studio og vis en praktisk ugeplan med variation.', expanded: true }));
|
||||||
|
await expect(page.locator('#oikos-assist-root .assist-panel--open')).toBeVisible();
|
||||||
|
await expect(page.locator('#oikos-assist-root .assist-message--ai').last()).not.toContainText('Tænker…', { timeout: 60_000 });
|
||||||
|
const action = page.locator('[data-assist-kitchen-studio]').first();
|
||||||
|
await expect(page.locator('[data-assist-kitchen-studio]')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
await expect(action).toContainText(/Åbn forslag|Køkken|Studio/i);
|
||||||
|
await action.dispatchEvent('click');
|
||||||
|
await expect(page.locator('[data-oikos-meal-plan-studio]')).toBeVisible({ timeout: 15_000 });
|
||||||
|
const studio = page.locator('[data-oikos-meal-plan-studio]');
|
||||||
|
await expect(studio.locator('[data-assist-taxonomy="category"]').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(studio.locator('[data-assist-taxonomy="protein"]').first()).toBeVisible();
|
||||||
|
await expect(studio.locator('[data-assist-taxonomy="style"]').first()).toBeVisible();
|
||||||
|
await expect(studio.locator('.assist-studio-slot__taxonomy')).toHaveCount(7, { timeout: 15_000 });
|
||||||
|
await attachDiagnostics(testInfo, diagnostics);
|
||||||
|
expect(diagnostics.pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user