const { test, expect } = require('@playwright/test'); /** * E2E tests for the "Vind inspiratie" feature on Tussendoor 1/2/3 * * The feature opens a bottom sheet (inspiratieSheet) instead of a full tab/page. * * Prerequisites: * - The app must be served at the configured baseURL * - Run `npx playwright test` from the project root * - Or `LOCAL_TEST=http://localhost:8080 npx playwright test` for local file serving */ // Helper: wait for DOM to be fully interactive after navigation async function waitForApp(page) { // Wait for the app shell to be ready await page.waitForSelector('#app', { timeout: 15000 }); // Ensure the IIFE has initialized by checking for bottom nav await page.waitForSelector('#navDagboek', { timeout: 10000 }); // Small delay for async data loading (voedingsmiddelen.json) await page.waitForTimeout(500); } // Helper: switch to dagboek tab async function goToDagboek(page) { await page.click('#navDagboek'); await page.waitForTimeout(400); } // Helper: open inspiratieSheet via Vind inspiratie button on the first Tussendoor card async function openInspiratieSheet(page) { await goToDagboek(page); const inspiratieBtn = page.locator('.vind-inspiratie-btn').first(); await expect(inspiratieBtn).toBeVisible(); await inspiratieBtn.click(); await page.waitForTimeout(500); // Verify sheet is open await expect(page.locator('#inspiratieSheet')).toBeVisible(); } test.describe('Vind inspiratie feature (bottom sheet)', () => { test('Vind inspiratie button only appears on Tussendoor 1, 2, 3 cards in dagboek', async ({ page }) => { await page.goto('/'); await waitForApp(page); await goToDagboek(page); // Find all eetmoment cards const cards = await page.locator('.eetmoment-card').all(); expect(cards.length).toBeGreaterThanOrEqual(6); // 6 eetmomenten for (const card of cards) { const nameEl = card.locator('.eetmoment-name'); const name = await nameEl.textContent(); const hasInspiratieBtn = await card.locator('.vind-inspiratie-btn').count(); if (name === 'Tussendoor 1' || name === 'Tussendoor 2' || name === 'Tussendoor 3') { expect(hasInspiratieBtn).toBe(1); } else { expect(hasInspiratieBtn).toBe(0); } } }); test('Clicking Vind inspiratie opens bottom sheet with correct context title', async ({ page }) => { await page.goto('/'); await waitForApp(page); await goToDagboek(page); // Click the first visible "Vind inspiratie" button (Tussendoor 1) const inspiratieBtn = page.locator('.vind-inspiratie-btn').first(); await expect(inspiratieBtn).toBeVisible(); await inspiratieBtn.click(); await page.waitForTimeout(500); // Should now have the inspiratieSheet open (bottom sheet) const sheet = page.locator('#inspiratieSheet'); await expect(sheet).toBeVisible(); await expect(sheet).not.toHaveClass(/hidden/); // Sheet title should start with "Inspiratie voor Tussendoor" const title = page.locator('#inspiratieSheetTitle'); const titleText = await title.textContent(); expect(titleText).toContain('Inspiratie voor Tussendoor'); }); test('Afsluiten X-knop sluit bottom sheet en keert terug naar Dagboek', async ({ page }) => { await page.goto('/'); await waitForApp(page); await goToDagboek(page); // Open inspiratie sheet const inspiratieBtn = page.locator('.vind-inspiratie-btn').first(); await inspiratieBtn.click(); await page.waitForTimeout(500); // Sheet should be open await expect(page.locator('#inspiratieSheet')).toBeVisible(); // Click X close button await page.click('#inspiratieSheetClose'); await page.waitForTimeout(500); // Sheet should be hidden await expect(page.locator('#inspiratieSheet')).toHaveClass(/hidden/); // Dagboek should still be active await expect(page.locator('#navDagboek')).toHaveClass(/active/); }); test('Afsluiten via backdrop-click sluit bottom sheet en keert terug naar Dagboek', async ({ page }) => { await page.goto('/'); await waitForApp(page); await goToDagboek(page); // Open inspiratie sheet const inspiratieBtn = page.locator('.vind-inspiratie-btn').first(); await inspiratieBtn.click(); await page.waitForTimeout(500); // Sheet should be open await expect(page.locator('#inspiratieSheet')).toBeVisible(); // Click the overlay backdrop (not the sheet content) const overlay = page.locator('#inspiratieSheet'); // Click outside the modal-sheet area (the overlay itself) await overlay.click({ position: { x: 10, y: 10 } }); await page.waitForTimeout(500); // Sheet should be hidden await expect(page.locator('#inspiratieSheet')).toHaveClass(/hidden/); // Dagboek should still be active await expect(page.locator('#navDagboek')).toHaveClass(/active/); }); test('Vind inspiratie shows context header with date and moment name in sheet', async ({ page }) => { await page.goto('/'); await waitForApp(page); await goToDagboek(page); // Click first Vind inspiratie button const inspiratieBtn = page.locator('.vind-inspiratie-btn').first(); await inspiratieBtn.click(); await page.waitForTimeout(500); // Context header should be visible in the sheet const contextEl = page.locator('#inspiratieContext'); await expect(contextEl).toBeVisible(); // Context label should contain the moment name const contextLabel = page.locator('#inspiratieContextLabel'); const labelText = await contextLabel.textContent(); expect(labelText).toContain('Tussendoor'); expect(labelText).toContain('202'); // any year prefix in the date }); test('Context header close button clears inspiration mode but keeps sheet open', async ({ page }) => { await page.goto('/'); await waitForApp(page); await goToDagboek(page); // Click Vind inspiratie const inspiratieBtn = page.locator('.vind-inspiratie-btn').first(); await inspiratieBtn.click(); await page.waitForTimeout(500); // Context is visible await expect(page.locator('#inspiratieContext')).toBeVisible(); // Click context close (× in the context banner, not the sheet X) await page.click('#inspiratieContextClose'); await page.waitForTimeout(400); // Title should now show "Tussendoortjes" (inspiratie context cleared) const titleText = await page.locator('#inspiratieSheetTitle').textContent(); expect(titleText).toBe('Tussendoortjes'); // Context should be hidden await expect(page.locator('#inspiratieContext')).toBeHidden(); // Sheet should still be open (not closed) await expect(page.locator('#inspiratieSheet')).toBeVisible(); await expect(page.locator('#inspiratieSheet')).not.toHaveClass(/hidden/); }); test('Nieuwe suggesties button exists in sheet and generates suggestions', async ({ page }) => { await page.goto('/'); await waitForApp(page); // Open sheet via first Vind inspiratie button to get suggestions context await openInspiratieSheet(page); // Button should be visible in the sheet const nieuweBtn = page.locator('#nieuweSuggestiesBtn'); await expect(nieuweBtn).toBeVisible(); expect(await nieuweBtn.textContent()).toContain('Nieuwe suggesties'); // Click it - should generate suggestions await nieuweBtn.click(); await page.waitForTimeout(500); // Should render suggestions (cards or empty state) const suggestieCards = page.locator('.suggestie-card'); const emptyState = page.locator('.inspiratie-empty'); const hasCards = (await suggestieCards.count()) > 0; const isEmpty = await emptyState.isVisible(); // Either we have cards or an empty state (if no suggestions fit the range) expect(hasCards || isEmpty).toBeTruthy(); }); test('Nieuwe suggesties changes the set on consecutive presses', async ({ page }) => { await page.goto('/'); await waitForApp(page); await openInspiratieSheet(page); const nieuweBtn = page.locator('#nieuweSuggestiesBtn'); // First press await nieuweBtn.click(); await page.waitForTimeout(500); const firstCardNames = await page.locator('.suggestie-card-naam').allTextContents(); // Second press (only if there are enough ingredients to generate a different set) await nieuweBtn.click(); await page.waitForTimeout(500); const secondCardNames = await page.locator('.suggestie-card-naam').allTextContents(); // If both sets have cards, at least one name should differ if (firstCardNames.length > 0 && secondCardNames.length > 0) { const firstJoined = firstCardNames.join(','); const secondJoined = secondCardNames.join(','); // They should not be exactly identical // (There's a small chance of getting the same set, but it should be rare) try { expect(firstJoined).not.toBe(secondJoined); } catch (e) { // If they're the same, it's possible with a small pool — log a warning console.warn('WARNING: Both presses returned the same set (possible but unlikely)'); } } }); test('All shown suggestion totals are within the carb range (default 15-20g)', async ({ page }) => { await page.goto('/'); await waitForApp(page); await openInspiratieSheet(page); // Click Nieuwe suggesties a few times to increase coverage const nieuweBtn = page.locator('#nieuweSuggestiesBtn'); for (let i = 0; i < 3; i++) { await nieuweBtn.click(); await page.waitForTimeout(500); const totals = await page.locator('.suggestie-card-totaal').allTextContents(); for (const t of totals) { // Format: "X.X g kh" const match = t.match(/([\d.]+)/); if (match) { const khValue = parseFloat(match[1]); expect(khValue).toBeGreaterThanOrEqual(15); expect(khValue).toBeLessThanOrEqual(20); } } } }); test('All interactive elements have haptic metadata', async ({ page }) => { await page.goto('/'); await waitForApp(page); await goToDagboek(page); // Check that Vind inspiratie buttons have data-haptic attribute const inspiratieBtns = page.locator('.vind-inspiratie-btn'); const count = await inspiratieBtns.count(); expect(count).toBeGreaterThanOrEqual(1); for (let i = 0; i < count; i++) { const haptic = await inspiratieBtns.nth(i).getAttribute('data-haptic'); expect(haptic).toBeTruthy(); } // Open sheet and check Nieuwe suggesties button exists inside await openInspiratieSheet(page); const nieuweBtn = page.locator('#nieuweSuggestiesBtn'); await expect(nieuweBtn).toBeVisible(); }); test('All shown ingredients have valid NEVO-n in database; kaneel uses actual DB value (56g/100g, not 0)', async ({ page }) => { await page.goto('/'); await waitForApp(page); await openInspiratieSheet(page); const nieuweBtn = page.locator('#nieuweSuggestiesBtn'); // Collect data across multiple generations to increase coverage let allIngredients = []; let kaneelFound = false; let kaneelKhPer100 = 0; for (let i = 0; i < 5; i++) { await nieuweBtn.click(); await page.waitForTimeout(500); // Get ingredient data-n and kh from DOM const ingredientData = await page.evaluate(() => { const items = document.querySelectorAll('.suggestie-ingredient'); return Array.from(items).map(el => ({ n: el.getAttribute('data-n'), khPer100: el.getAttribute('data-kh-per-100'), khText: el.querySelector('.suggestie-ingredient-kh')?.textContent || '', portieText: el.querySelector('.suggestie-ingredient-gram')?.textContent || '', })); }); allIngredients = allIngredients.concat(ingredientData); // Check if kaneel is in this batch — read khPer100 directly from data attribute const kaneelItems = ingredientData.filter(d => d.n === '826'); if (kaneelItems.length > 0) { kaneelFound = true; for (const ki of kaneelItems) { const khp = parseFloat(ki.khPer100); if (!isNaN(khp)) { kaneelKhPer100 = khp; } } } } // Verify every ingredient has a valid NEVO-n in the database // Load DB from Node.js side (data is not globally exposed due to IIFE) const fs = require('fs'); const dbPath = require('path').join(__dirname, '..', 'data', 'voedingsmiddelen.json'); let dbNs = new Set(); try { const dbData = JSON.parse(fs.readFileSync(dbPath, 'utf-8')); dbNs = new Set(dbData.map(item => String(item.n))); } catch (e) { console.warn('WARNING: Could not load voedingsmiddelen.json, skipping NEVO-n validation:', e.message); } if (dbNs.size > 0) { const invalidIngredients = allIngredients.filter(d => d.n && !dbNs.has(d.n)); if (invalidIngredients.length > 0) { // Some ingredients couldn't be resolved — fail with details const errMsg = 'All shown ingredients must have a NEVO-n that exists in voedingsmiddelen.json. ' + 'Invalid n values: ' + JSON.stringify(invalidIngredients); expect(invalidIngredients, errMsg).toEqual([]); } } // If kaneel appeared, verify its kh_per100 rounds to ~56, not 0 if (kaneelFound) { const errMsg = 'Kaneel (n=826) should use DB kh value of 56g/100g, not the hardcoded 0 from INSPIRATIE_POOL. ' + 'Got ' + kaneelKhPer100 + 'g/100g instead of 56g/100g.'; expect(kaneelKhPer100, errMsg).toBe(56); } else { // If kaneel didn't appear by chance, log a warning console.warn('WARNING: Kaneel did not appear in 5 generations — could not verify its DB value'); } }); test('Generated recipe titles use friendly names, not raw NEVO display names', async ({ page }) => { await page.goto('/'); await waitForApp(page); await openInspiratieSheet(page); // Known NEVO display-name fragments that should NEVER appear in a recipe title. // These are raw NEVO aliases that get stripped by displayNaam() — NOT the friendly // names from INSPIRATIE_POOL. "Blauwe bessen" is a valid friendly name, for example. const NEVO_ALIAS_FRAGMENTS = [ 'z schil gem', 'bessen bos-', 'Bessen bos-', 'z schil', ]; const nieuweBtn = page.locator('#nieuweSuggestiesBtn'); let titlesChecked = 0; // Generate multiple batches to increase coverage for (let batch = 0; batch < 5; batch++) { await nieuweBtn.click(); await page.waitForTimeout(500); const titles = await page.locator('.suggestie-card-naam').allTextContents(); titlesChecked += titles.length; for (const title of titles) { const titleLower = title.toLowerCase(); for (const fragment of NEVO_ALIAS_FRAGMENTS) { const fragLower = fragment.toLowerCase(); expect( titleLower, `Recipe title "${title}" should not contain NEVO alias fragment "${fragment}"` ).not.toContain(fragLower); } } } // Ensure we actually checked some titles (at least one batch had suggestions) if (titlesChecked === 0) { console.warn('WARNING: No suggestion titles were generated across 5 batches — cannot verify friendly names'); } else { console.log(`OK: Checked ${titlesChecked} generated titles — no NEVO alias fragments found`); } }); test('Dagboek blijft actief na openen en sluiten van inspiratie-sheet', async ({ page }) => { await page.goto('/'); await waitForApp(page); await goToDagboek(page); // Verify Dagboek is active initially await expect(page.locator('#navDagboek')).toHaveClass(/active/); // Open sheet const inspiratieBtn = page.locator('.vind-inspiratie-btn').first(); await inspiratieBtn.click(); await page.waitForTimeout(500); // Sheet is open await expect(page.locator('#inspiratieSheet')).toBeVisible(); // Dagboek should still be active (no tab switch) await expect(page.locator('#navDagboek')).toHaveClass(/active/); // Close sheet via X await page.click('#inspiratieSheetClose'); await page.waitForTimeout(500); // Sheet is hidden await expect(page.locator('#inspiratieSheet')).toHaveClass(/hidden/); // Dagboek should still be active await expect(page.locator('#navDagboek')).toHaveClass(/active/); }); test('Geen navigatie naar tussendoortjes tab (tabTussendoortjes blijft leeg/verborgen)', async ({ page }) => { await page.goto('/'); await waitForApp(page); await goToDagboek(page); // Open inspiratie sheet const inspiratieBtn = page.locator('.vind-inspiratie-btn').first(); await inspiratieBtn.click(); await page.waitForTimeout(500); // Sheet is visible await expect(page.locator('#inspiratieSheet')).toBeVisible(); // tabTussendoortjes should either be hidden or empty const tussendoortjesTab = page.locator('#tabTussendoortjes'); const classAttr = await tussendoortjesTab.getAttribute('class'); const isHidden = classAttr && classAttr.includes('hidden'); if (!isHidden) { // If visible, it should be empty (no suggestieList in it, no content) const suggestieCount = await tussendoortjesTab.locator('.suggestie-card').count(); expect(suggestieCount).toBe(0); } // Dagboek stays active await expect(page.locator('#navDagboek')).toHaveClass(/active/); }); });