karby/tests/inspiratie.spec.js
cas 85ef8ba2de UX — open tussendoortjessuggesties als bottom sheet
- Verplaatst de bestaande tabTussendoortjes-inhoud naar een nieuwe
  inspiratieSheet bottom-sheet overlay (reuseert .modal-overlay,
  .modal-sheet, .modal-handle, backdrop-close en setupSwipeDown).
- Klikken op 'Vind inspiratie' bij Tussendoor 1/2/3 opent nu de bottom
  sheet i.p.v. volledige tab-navigatie.
- Sheet heeft X-knop, backdrop-click en swipe-down om te sluiten.
- Haptics bij openen (10ms) en sluiten (6ms).
- renderTussendoortjes() werkt zowel in tab als sheet context.
- clearInspiratieContext() herstelt context ook als sheet open is.
- Playwright-tests geüpdatet: verifiëren sheet open/sluit, titel,
  context, X/backdrop close, Dagboek blijft actief, geen tab-navigatie.
- docker/index.html gesynchroniseerd.
2026-07-28 20:39:52 +02:00

469 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/);
});
});