Files
oikos/tests/e2e/oikos-meal-studio-50-use-cases.spec.mjs
T
2026-05-14 14:43:55 +02:00

297 lines
16 KiB
JavaScript

import { expect, test } from '@playwright/test';
import fs from 'node:fs';
const username = process.env.OIKOS_E2E_USERNAME;
const password = process.env.OIKOS_E2E_PASSWORD_FILE
? fs.readFileSync(process.env.OIKOS_E2E_PASSWORD_FILE, 'utf8').trim()
: process.env.OIKOS_E2E_PASSWORD;
const MODE_LABELS = {
low_effort: 'Nemt',
regular: 'Hverdag',
flexible: 'Plads til mere',
};
const MODIFIER_LABELS = {
freezer: 'Fryser',
leftovers: 'Rester',
eating_out: 'Spiser ude',
guests: 'Gæster',
no_kids: 'Børnefri',
cook_extra: 'Lav ekstra',
rugbrod: 'Rugbrød/koldt',
very_quick: 'Meget hurtigt',
};
function isoAdd(start, offset) {
const date = new Date(`${start}T12:00:00Z`);
date.setUTCDate(date.getUTCDate() + offset);
return date.toISOString().slice(0, 10);
}
function nextMonday() {
const date = new Date();
date.setUTCHours(12, 0, 0, 0);
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + (8 - day));
return date.toISOString().slice(0, 10);
}
async function dismissOnboardingIfPresent(page) {
const skip = page.getByRole('button', { name: /^(Skip|Spring over|Senere|Close|Luk)$/i }).first();
try { if (await skip.isVisible({ timeout: 1500 })) await skip.click(); } catch {}
}
async function login(page) {
if (!username || !password) throw new Error('Missing OIKOS_E2E_USERNAME and password env.');
await page.goto('/login', { waitUntil: 'domcontentloaded' });
await page.locator('#username').fill(username);
await page.locator('#password').fill(password);
const loginResponsePromise = page.waitForResponse((res) => res.url().includes('/api/v1/auth/login'));
await page.locator('#login-btn').click();
const loginResponse = await loginResponsePromise;
if (!loginResponse.ok()) throw new Error(`Login failed HTTP ${loginResponse.status()}`);
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 });
await expect(page.locator('#main-content')).toBeVisible();
await dismissOnboardingIfPresent(page);
}
function buildUseCases(start) {
const base = [
['low_effort freezer quick', 'low_effort', ['freezer', 'very_quick'], 'freezer'],
['low_effort leftovers quick', 'low_effort', ['leftovers', 'very_quick'], 'leftovers'],
['low_effort rugbrod', 'low_effort', ['rugbrod'], 'quick'],
['low_effort kids normal', 'low_effort', [], 'easy'],
['regular no modifiers', 'regular', [], 'regular'],
['regular cook extra', 'regular', ['cook_extra'], 'batch'],
['regular leftovers', 'regular', ['leftovers'], 'leftovers'],
['regular freezer', 'regular', ['freezer'], 'freezer'],
['regular very quick', 'regular', ['very_quick'], 'quick'],
['flexible guests', 'flexible', ['guests'], 'guests'],
['flexible guests cook extra', 'flexible', ['guests', 'cook_extra'], 'guests_batch'],
['flexible no kids', 'flexible', ['no_kids'], 'adult'],
['flexible no kids eating out', 'flexible', ['no_kids', 'eating_out'], 'eating_out'],
['flexible eating out', 'flexible', ['eating_out'], 'eating_out'],
['flexible freezer guests', 'flexible', ['freezer', 'guests'], 'freezer_guests'],
['low effort guests conflict', 'low_effort', ['guests'], 'conflict'],
['low effort no kids', 'low_effort', ['no_kids'], 'adult_quick'],
['regular rugbrod kids', 'regular', ['rugbrod'], 'cold'],
['regular no kids cook extra', 'regular', ['no_kids', 'cook_extra'], 'adult_batch'],
['flexible all special', 'flexible', ['guests', 'cook_extra', 'freezer'], 'complex'],
['freezer only', 'regular', ['freezer'], 'inventory'],
['leftovers only', 'regular', ['leftovers'], 'inventory'],
['eating out only', 'regular', ['eating_out'], 'non_cooking'],
['cook extra only', 'regular', ['cook_extra'], 'batch'],
['very quick only', 'regular', ['very_quick'], 'quick'],
['rugbrod very quick', 'low_effort', ['rugbrod', 'very_quick'], 'cold_quick'],
['guests no kids', 'flexible', ['guests', 'no_kids'], 'adult_guests'],
['guests leftovers', 'flexible', ['guests', 'leftovers'], 'leftover_guests'],
['no kids freezer', 'flexible', ['no_kids', 'freezer'], 'adult_inventory'],
['kids easy freezer', 'low_effort', ['freezer'], 'kid_inventory'],
['busy monday', 'low_effort', ['very_quick'], 'calendar_pressure'],
['normal tuesday', 'regular', [], 'baseline'],
['ella cooks thursday', 'regular', ['very_quick'], 'kid_cook'],
['friday guests', 'flexible', ['guests'], 'weekend_guest'],
['saturday big cook', 'flexible', ['cook_extra'], 'weekend_batch'],
['sunday family regular', 'regular', [], 'sunday'],
['rainy comfort fallback', 'low_effort', [], 'weather'],
['warm grill flexible', 'flexible', [], 'weather'],
['partial plan one day', 'regular', [], 'partial'],
['two-day low effort streak', 'low_effort', ['very_quick'], 'variety'],
['avoid duplicate category', 'regular', [], 'variety'],
['manual freezer unknown', 'low_effort', ['freezer'], 'manual_inventory'],
['planned leftovers style', 'regular', ['leftovers'], 'planned_leftovers'],
['eating out still has cards', 'flexible', ['eating_out'], 'cards'],
['new suggestion distinct', 'regular', [], 'cards'],
['rare suggestion present', 'regular', [], 'cards'],
['known suggestion present', 'regular', [], 'cards'],
['modulator labels visible', 'flexible', ['guests', 'cook_extra'], 'ui'],
['inventory options on freezer', 'low_effort', ['freezer'], 'inventory'],
['reservation-compatible day', 'low_effort', ['freezer', 'very_quick'], 'inventory_reserve'],
];
return base.map(([name, mode, modifiers, intent], idx) => ({
id: idx + 1,
name,
date: isoAdd(start, idx % 14),
mode,
modifiers,
intent,
}));
}
function analyzeSlot(useCase, slot) {
const issues = [];
const mods = slot?.context?.modulators || {};
const cards = slot?.suggestionCards || [];
if (!slot) issues.push('No slot returned for use case date.');
if (mods.dayMode !== useCase.mode) issues.push(`Expected dayMode ${useCase.mode}, got ${mods.dayMode}.`);
for (const modifier of useCase.modifiers) {
if (!(mods.manualModifiers || []).includes(modifier)) issues.push(`Missing manual modifier ${modifier}.`);
}
if (cards.length !== 3) issues.push(`Expected exactly 3 suggestion cards, got ${cards.length}.`);
const titles = cards.map((card) => card.title).filter(Boolean);
if (new Set(titles).size !== titles.length) issues.push(`Suggestion card titles are not distinct: ${titles.join(' | ')}`);
if (useCase.modifiers.includes('eating_out') && !/spiser ude|takeaway|café/i.test(`${slot.title} ${titles.join(' ')}`)) issues.push('Eating-out use case did not produce an intentional eating-out/takeaway option.');
if (useCase.modifiers.includes('freezer') && !(slot.inventoryOptions || []).length) issues.push('Freezer use case did not expose inventoryOptions.');
if (useCase.modifiers.includes('freezer') && !/fryser|freezer/i.test(`${slot.title} ${titles.join(' ')}`)) issues.push('Freezer use case did not show freezer-oriented wording.');
if (useCase.modifiers.includes('leftovers') && !/rester|leftover|fryser/i.test(`${slot.title} ${titles.join(' ')}`)) issues.push('Leftovers use case did not show leftover-oriented wording.');
if (useCase.modifiers.includes('guests') && !/gæst|guest|stor|salat|ovnret/i.test(`${slot.reason} ${titles.join(' ')}`)) issues.push('Guests use case did not explain/scaffold guest fit.');
if (useCase.modifiers.includes('no_kids') && !/børnefri|voksen|adult|chili/i.test(`${slot.reason} ${titles.join(' ')}`)) issues.push('No-kids use case did not open adult/no-kids suggestions.');
if (useCase.mode === 'low_effort' && !mods.easyDay) issues.push('Low-effort use case was not marked as easyDay.');
if (!Array.isArray(mods.labels) || mods.labels.length < 2) issues.push('Modulator labels are too sparse for user inspection.');
return issues;
}
async function attachReport(testInfo, report) {
await testInfo.attach('oikos-meal-studio-50-use-cases.json', {
body: JSON.stringify(report, null, 2),
contentType: 'application/json',
});
const markdown = [
'# Oikos Meal Plan Studio — 50-use-case E2E verification',
'',
`Run: ${report.startedAt}`,
`Base URL: ${report.baseURL}`,
`Passed: ${report.summary.passed}/${report.summary.total}`,
`Issues: ${report.summary.issueCount}`,
'',
'## Issues / defects / shortcomings',
...(report.issues.length ? report.issues.map((issue) => `- UC${String(issue.id).padStart(2, '0')} ${issue.name}: ${issue.issue}`) : ['- None recorded.']),
'',
'## Use-case results',
...report.results.map((row) => `- ${row.pass ? '✅' : '⚠️'} UC${String(row.id).padStart(2, '0')} ${row.name}${row.mode} [${row.modifiers.join(', ') || 'none'}] → ${row.slotTitle || 'no slot'}`),
].join('\n');
await testInfo.attach('oikos-meal-studio-50-use-cases.md', { body: markdown, contentType: 'text/markdown' });
}
test.describe('Oikos Meal Plan Studio comprehensive 50-use-case verification', () => {
test('50 realistic meal-planning use cases across API, UI, inventory, and mobile flow', async ({ page }, testInfo) => {
test.setTimeout(240_000);
const diagnostics = { consoleErrors: [], pageErrors: [], failedRequests: [] };
page.on('console', (msg) => { if (['error', 'warning'].includes(msg.type())) diagnostics.consoleErrors.push(`${msg.type()}: ${msg.text()}`); });
page.on('pageerror', (err) => diagnostics.pageErrors.push(err.stack || err.message));
page.on('requestfailed', (req) => diagnostics.failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText || 'failed'}`));
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1'));
await login(page);
const startedAt = new Date().toISOString();
const baseURL = testInfo.project.use.baseURL || process.env.OIKOS_E2E_BASE_URL || 'https://home.friborg.uk';
const start = nextMonday();
const useCases = buildUseCases(start);
const results = [];
const issues = [];
await test.step('health and served asset markers', async () => {
const health = await page.request.get('/ai/health');
expect(health.ok()).toBeTruthy();
const widget = await page.request.get('/ai/widget.js');
expect(widget.ok()).toBeTruthy();
const widgetText = await widget.text();
for (const marker of ['assist-day-setup', 'assist-meal-options', 'assist-leftover-inventory', 'applySuggestionCard', 'reserveInventoryForDate']) {
if (!widgetText.includes(marker)) issues.push({ id: 0, name: 'served widget marker', issue: `Missing widget marker ${marker}` });
}
const css = await page.request.get('/ai/assist.css');
const cssText = await css.text();
for (const marker of ['Three-card meal picker slice', 'Leftover/freezer inventory picker slice']) {
if (!cssText.includes(marker)) issues.push({ id: 0, name: 'served CSS marker', issue: `Missing CSS marker ${marker}` });
}
});
await test.step('inventory API add/reserve/discard smoke', async () => {
const uniqueTitle = `E2E fryser portion ${Date.now()}`;
const create = await page.request.post('/ai/api/meal-plan/leftovers', { data: { title: uniqueTitle, servings: 3, location: 'freezer', expiresAt: isoAdd(start, 30), notes: 'E2E verification item' } });
const created = await create.json();
if (!create.ok() || !created.item?.id) issues.push({ id: 0, name: 'inventory create', issue: `Could not create inventory item: HTTP ${create.status()}` });
else {
const reserve = await page.request.patch('/ai/api/meal-plan/leftovers', { data: { id: created.item.id, patch: { status: 'reserved', reservedForDate: start } } });
if (!reserve.ok()) issues.push({ id: 0, name: 'inventory reserve', issue: `Could not reserve inventory item: HTTP ${reserve.status()}` });
const discard = await page.request.patch('/ai/api/meal-plan/leftovers', { data: { id: created.item.id, patch: { status: 'discarded' } } });
if (!discard.ok()) issues.push({ id: 0, name: 'inventory cleanup', issue: `Could not discard E2E inventory item: HTTP ${discard.status()}` });
}
});
for (const useCase of useCases) {
await test.step(`UC${String(useCase.id).padStart(2, '0')} ${useCase.name}`, async () => {
const response = await page.request.post('/ai/api/meal-plan/generate', {
data: {
startDate: useCase.date,
endDate: useCase.date,
dayConfigs: [{ date: useCase.date, mode: useCase.mode, modifiers: useCase.modifiers }],
},
});
let payload = null;
if (!response.ok()) {
const issue = `Generation failed HTTP ${response.status()}`;
issues.push({ id: useCase.id, name: useCase.name, issue });
results.push({ ...useCase, pass: false, issues: [issue] });
return;
}
payload = await response.json();
const slot = payload.slots?.[0];
const rowIssues = analyzeSlot(useCase, slot);
for (const issue of rowIssues) issues.push({ id: useCase.id, name: useCase.name, issue });
results.push({
...useCase,
pass: rowIssues.length === 0,
issues: rowIssues,
slotTitle: slot?.title,
cardTitles: (slot?.suggestionCards || []).map((card) => card.title),
inventoryOptionCount: slot?.inventoryOptions?.length || 0,
modulatorLabels: slot?.context?.modulators?.labels || [],
});
});
}
await test.step('mobile UI smoke for day chips, 3 cards, and inventory panel', async () => {
await page.goto('/meals', { waitUntil: 'domcontentloaded' });
await dismissOnboardingIfPresent(page);
const host = page.locator('[data-oikos-meal-plan-studio]');
await expect(host).toBeVisible({ timeout: 20_000 });
await host.locator('[data-assist-studio-generate]').first().click();
await expect(host.locator('.assist-day-setup')).toBeVisible({ timeout: 30_000 });
await expect(host.locator('.assist-meal-options').first()).toBeVisible({ timeout: 20_000 });
await expect(host.locator('.assist-leftover-inventory')).toBeVisible({ timeout: 20_000 });
const dayModeButtons = await host.locator('[data-assist-day-mode]').count();
const modifierButtons = await host.locator('[data-assist-day-modifier]').count();
const optionCards = await host.locator('[data-assist-suggestion-card]').count();
const overflow = await page.evaluate(() => ({ innerWidth: window.innerWidth, scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth }));
if (dayModeButtons < 21) issues.push({ id: 0, name: 'mobile day mode UI', issue: `Expected day mode buttons for 7 days, got ${dayModeButtons}` });
if (modifierButtons < 56) issues.push({ id: 0, name: 'mobile modifier UI', issue: `Expected modifier buttons for 7 days, got ${modifierButtons}` });
if (optionCards < 21) issues.push({ id: 0, name: 'mobile suggestion cards', issue: `Expected 3 option cards for 7 days, got ${optionCards}` });
if (overflow.scrollWidth > overflow.innerWidth + 12) issues.push({ id: 0, name: 'mobile overflow', issue: `Horizontal overflow: ${JSON.stringify(overflow)}` });
const firstTitleBefore = await host.locator('[data-assist-studio-title="0"]').inputValue();
const secondCard = host.locator('[data-assist-suggestion-card]').nth(1);
if (await secondCard.count()) {
await secondCard.click();
const firstTitleAfter = await host.locator('[data-assist-studio-title="0"]').inputValue();
if (firstTitleBefore === firstTitleAfter) issues.push({ id: 0, name: 'suggestion card selection', issue: 'Clicking second suggestion card did not update first slot title.' });
}
});
const report = {
startedAt,
baseURL,
summary: {
total: useCases.length,
passed: results.filter((row) => row.pass).length,
failed: results.filter((row) => !row.pass).length,
issueCount: issues.length,
},
issues,
diagnostics,
results,
};
await attachReport(testInfo, report);
fs.mkdirSync('artifacts', { recursive: true });
fs.writeFileSync('artifacts/oikos-meal-studio-50-use-cases.json', JSON.stringify(report, null, 2));
expect(results).toHaveLength(50);
expect(diagnostics.pageErrors, 'No browser page errors').toEqual([]);
expect(issues, `Recorded issues:\n${issues.map((i) => `UC${i.id} ${i.name}: ${i.issue}`).join('\n')}`).toEqual([]);
});
});