- 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.
469 lines
17 KiB
JavaScript
469 lines
17 KiB
JavaScript
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/);
|
||
});
|
||
|
||
});
|