Compare commits

..

4 commits

Author SHA1 Message Date
cas
fbfe7e5121 test: regressie eetmoment-labels — zes labels zichtbaar, niet transparant/afgeknipt (t_a1d49160) 2026-08-03 19:54:12 +02:00
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
cas
3422b09214 UX — verwijder menu-item Tussendoortjes uit hoofdnavigatie
- Remove navTussendoortjes button HTML, class toggle, and click listener
- Remaining nav items (Zoeken, Dagboek, Maaltijden) auto-distribute via flex:1
- Expose window.switchTab for test access (was IIFE-scoped)
- Update e2e tests: first test verifies button is gone, rest use
  page.evaluate(() => switchTab('tussendoortjes'))
- Update inspiratie.spec.js helper to use switchTab via evaluate
- Sync docker/index.html with index.html

Resolves: t_3c830823
2026-07-28 20:10:13 +02:00
cas
11b3688779 feat: infinite scroll koppelen aan API pagination
IntersectionObserver haalt nu offset+=batch op via API ipv allItems
slicen. Bij een zoekopdracht wordt eerst de API geraadpleegd (paginated
fetchFoodPage), en bij scrollen worden volgende pagina's opgehaald.
Bij API-falen valt de code terug op de lokale client-side filtering.

Wijzigingen:
- SEARCH_PAGE_SIZE 50 → 200
- fetchFoodPage() — nieuwe async functie voor paginated API calls
- renderZoeken() — async met API try/fallback pad
- setupSearchObserver — async, API-aware via searchTotal flag
- appendSearchBatchFromApi / appendSearchBatchLocal — gesplitst
- resetSearchPagination reset nu ook searchPage en searchTotal
- isSearchLoading guard tegen race conditions
- displayCategory() met Tussendoortjes → Tussendoor mapping
- voedingsmiddelenByN Map voor click-handler lookup in API items
- data-nummer attribuut ipv data-index voor API data compatibiliteit

Alle 25 e2e tests slagen.
2026-07-25 09:38:26 +02:00
5 changed files with 3608 additions and 202 deletions

File diff suppressed because it is too large Load diff

1463
index.html

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,9 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const BASE = 'https://eetdagboek.vantwout.dev';
test.describe('Karby Eetdagboek', () => { test.describe('Karby Eetdagboek', () => {
test('page loads with correct title and elements', async ({ page }) => { test('page loads with correct title and elements', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await expect(page).toHaveTitle('Karby'); await expect(page).toHaveTitle('Karby');
await expect(page.locator('.search-input')).toBeVisible(); await expect(page.locator('.search-input')).toBeVisible();
await expect(page.locator('.cat-btn').first()).toBeVisible(); await expect(page.locator('.cat-btn').first()).toBeVisible();
@ -15,7 +13,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('search returns results and opens detail modal', async ({ page }) => { test('search returns results and opens detail modal', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('aardappel'); await page.locator('.search-input').fill('aardappel');
await page.waitForTimeout(800); await page.waitForTimeout(800);
const results = page.locator('.food-item'); const results = page.locator('.food-item');
@ -27,7 +25,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('category filters work', async ({ page }) => { test('category filters work', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.cat-btn').filter({ hasText: 'Brood' }).click(); await page.locator('.cat-btn').filter({ hasText: 'Brood' }).click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
const activeBtn = page.locator('.cat-btn.active'); const activeBtn = page.locator('.cat-btn.active');
@ -35,7 +33,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('add item to diary via eetmoment flow', async ({ page }) => { test('add item to diary via eetmoment flow', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
await page.locator('.food-item').first().click(); await page.locator('.food-item').first().click();
@ -44,7 +42,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('diary tab shows date navigation', async ({ page }) => { test('diary tab shows date navigation', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('#navDagboek').click(); await page.locator('#navDagboek').click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
await expect(page.locator('.day-navigator')).toBeVisible({ timeout: 3000 }); await expect(page.locator('.day-navigator')).toBeVisible({ timeout: 3000 });
@ -53,7 +51,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('settings modal opens', async ({ page }) => { test('settings modal opens', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('#settingsBtn').click(); await page.locator('#settingsBtn').click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
await expect(page.locator('#settingsModal')).not.toHaveClass(/hidden/, { timeout: 3000 }); await expect(page.locator('#settingsModal')).not.toHaveClass(/hidden/, { timeout: 3000 });
@ -62,14 +60,14 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('maaltijden tab shows saved meals section', async ({ page }) => { test('maaltijden tab shows saved meals section', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('#navMaaltijden').click(); await page.locator('#navMaaltijden').click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
await expect(page.locator('#tabMaaltijden')).toBeVisible(); await expect(page.locator('#tabMaaltijden')).toBeVisible();
}); });
test('contrast: key elements have visible colors', async ({ page }) => { test('contrast: key elements have visible colors', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
const navBtns = page.locator('.nav-btn'); const navBtns = page.locator('.nav-btn');
const count = await navBtns.count(); const count = await navBtns.count();
expect(count).toBeGreaterThan(0); expect(count).toBeGreaterThan(0);
@ -84,7 +82,7 @@ test.describe('Karby Eetdagboek', () => {
// ---------- Stepper: basis ---------- // ---------- Stepper: basis ----------
test('portion stepper: steppers appear in addMealModal with first count=1', async ({ page }) => { test('portion stepper: steppers appear in addMealModal with first count=1', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
await page.locator('.food-item').first().click(); await page.locator('.food-item').first().click();
@ -98,7 +96,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('portion stepper: plus op tweede activeert die en reset eerste naar 0', async ({ page }) => { test('portion stepper: plus op tweede activeert die en reset eerste naar 0', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
await page.locator('.food-item').first().click(); await page.locator('.food-item').first().click();
@ -115,7 +113,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('portion stepper: minus verlaagt count naar 0 en wordt dan disabled', async ({ page }) => { test('portion stepper: minus verlaagt count naar 0 en wordt dan disabled', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
await page.locator('.food-item').first().click(); await page.locator('.food-item').first().click();
@ -130,7 +128,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('portion stepper: active class na plus/minus toggles', async ({ page }) => { test('portion stepper: active class na plus/minus toggles', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
await page.locator('.food-item').first().click(); await page.locator('.food-item').first().click();
@ -153,7 +151,7 @@ test.describe('Karby Eetdagboek', () => {
// ---------- Modal animaties ---------- // ---------- Modal animaties ----------
test('modal close animation: closing class applied during dismiss', async ({ page }) => { test('modal close animation: closing class applied during dismiss', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('#settingsBtn').click(); await page.locator('#settingsBtn').click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
await expect(page.locator('#settingsModal')).not.toHaveClass(/hidden/); await expect(page.locator('#settingsModal')).not.toHaveClass(/hidden/);
@ -165,7 +163,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('modal open animation: fadeIn en slideUp CSS animations gestart', async ({ page }) => { test('modal open animation: fadeIn en slideUp CSS animations gestart', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('#settingsBtn').click(); await page.locator('#settingsBtn').click();
await page.waitForTimeout(100); await page.waitForTimeout(100);
const overlay = page.locator('#settingsModal'); const overlay = page.locator('#settingsModal');
@ -181,7 +179,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('tab switching: switching class verschijnt tijdens crossfade', async ({ page }) => { test('tab switching: switching class verschijnt tijdens crossfade', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('#navDagboek').click(); await page.locator('#navDagboek').click();
await page.waitForTimeout(100); await page.waitForTimeout(100);
const tabEl = page.locator('#tabDagboek'); const tabEl = page.locator('#tabDagboek');
@ -193,7 +191,7 @@ test.describe('Karby Eetdagboek', () => {
// ---------- Maaltijden ---------- // ---------- Maaltijden ----------
test('meal save: name input pre-filled with smart suggestion', async ({ page }) => { test('meal save: name input pre-filled with smart suggestion', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
await page.locator('.food-item').first().click(); await page.locator('.food-item').first().click();
@ -248,7 +246,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('meal save: empty name toont rode rand en modal blijft open', async ({ page }) => { test('meal save: empty name toont rode rand en modal blijft open', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
await page.locator('.food-item').first().click(); await page.locator('.food-item').first().click();
@ -302,7 +300,7 @@ test.describe('Karby Eetdagboek', () => {
// ---------- Datumkiezer ---------- // ---------- Datumkiezer ----------
test('date navigator: next/prev verandert datum en today reset', async ({ page }) => { test('date navigator: next/prev verandert datum en today reset', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('#navDagboek').click(); await page.locator('#navDagboek').click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
const initialDay = await page.locator('#dayLabel').textContent(); const initialDay = await page.locator('#dayLabel').textContent();
@ -320,7 +318,7 @@ test.describe('Karby Eetdagboek', () => {
// ---------- Zoek animaties ---------- // ---------- Zoek animaties ----------
test('zoektab: food items krijgen staggered animation indices', async ({ page }) => { test('zoektab: food items krijgen staggered animation indices', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('k'); await page.locator('.search-input').fill('k');
await page.waitForTimeout(800); await page.waitForTimeout(800);
const items = page.locator('.food-item'); const items = page.locator('.food-item');
@ -334,7 +332,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('button press: buttons hebben transition transform bij indrukken', async ({ page }) => { test('button press: buttons hebben transition transform bij indrukken', async ({ page }) => {
await page.goto(BASE, { waitUntil: 'networkidle' }); await page.goto('/', { waitUntil: 'networkidle' });
const btn = page.locator('button').first(); const btn = page.locator('button').first();
await expect(btn).toBeVisible({ timeout: 5000 }); await expect(btn).toBeVisible({ timeout: 5000 });
const transition = await btn.evaluate(el => getComputedStyle(el).transition); const transition = await btn.evaluate(el => getComputedStyle(el).transition);
@ -345,7 +343,7 @@ test.describe('Karby Eetdagboek', () => {
// ---------- Meal-group uitklappen in dagboek ---------- // ---------- Meal-group uitklappen in dagboek ----------
test('meal-group uitklappen: saved meal toggles expand/collapse in dagboek', async ({ page }) => { test('meal-group uitklappen: saved meal toggles expand/collapse in dagboek', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
// Stap 1: Zoek voedingsmiddel en voeg toe aan dagboek // Stap 1: Zoek voedingsmiddel en voeg toe aan dagboek
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
@ -426,25 +424,25 @@ test.describe('Karby Eetdagboek', () => {
const itemsWrap = mealGroup.locator('.eetmoment-meal-items-wrap'); const itemsWrap = mealGroup.locator('.eetmoment-meal-items-wrap');
// Stap 8: Items-wrap start zichtbaar (geen hidden class) // Stap 8: Items-wrap start zichtbaar (geen hidden class)
await expect(itemsWrap).not.toHaveClass(/hidden|collapsed/); await expect(itemsWrap).not.toHaveClass(/hidden|collapsed/);
// Stap 9: Klik header om in te klappen → collapsed class toegevoegd // Stap 9: Klik toggle om in te klappen → collapsed class toegevoegd
const header = mealGroup.locator('.eetmoment-meal-header'); const toggle = mealGroup.locator('[data-action="toggle-meal"]');
await header.click(); await toggle.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
await expect(itemsWrap).toHaveClass(/collapsed/); await expect(itemsWrap).toHaveClass(/collapsed/);
const chevronDown = header.locator('i.fa-chevron-down'); const chevronDown = toggle.locator('i.fa-chevron-down');
await expect(chevronDown).toBeVisible(); await expect(chevronDown).toBeVisible();
// Stap 10: Klik opnieuw om uit te klappen → hidden weg // Stap 10: Klik opnieuw om uit te klappen → hidden weg
await header.click(); await toggle.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
await expect(itemsWrap).not.toHaveClass(/hidden|collapsed/); await expect(itemsWrap).not.toHaveClass(/hidden|collapsed/);
const chevronUp = header.locator('i.fa-chevron-up'); const chevronUp = toggle.locator('i.fa-chevron-up');
await expect(chevronUp).toBeVisible(); await expect(chevronUp).toBeVisible();
}); });
// ---------- Confirm + delete dialogs ---------- // ---------- Confirm + delete dialogs ----------
test('confirm modal: deletion dialog shows design-conforme buttons', async ({ page }) => { test('confirm modal: deletion dialog shows design-conforme buttons', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
// Add item + save as meal from search tab // Add item + save as meal from search tab
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
@ -482,7 +480,7 @@ test.describe('Karby Eetdagboek', () => {
test('portie-edit dialog: single ingredient portion change saves and closes', async ({ page }) => { test('portie-edit dialog: single ingredient portion change saves and closes', async ({ page }) => {
// Add a loose ingredient to the diary first // Add a loose ingredient to the diary first
await page.goto(BASE); await page.goto('/');
await page.locator('.search-input').fill('brood'); await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800); await page.waitForTimeout(800);
await page.locator('.food-item').first().click(); await page.locator('.food-item').first().click();
@ -525,7 +523,7 @@ test.describe('Karby Eetdagboek', () => {
}); });
test('infinite scroll: only first batch rendered, sentinel triggers more', async ({ page }) => { test('infinite scroll: only first batch rendered, sentinel triggers more', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.waitForTimeout(1500); // wait for data load await page.waitForTimeout(1500); // wait for data load
// Clear any search query to see all items // Clear any search query to see all items
@ -535,26 +533,26 @@ test.describe('Karby Eetdagboek', () => {
await page.waitForTimeout(500); await page.waitForTimeout(500);
} }
// Count initially rendered food items — should be ≤ SEARCH_PAGE_SIZE (50) // Count initially rendered food items — should be ≤ SEARCH_PAGE_SIZE (200)
const initialCount = await page.locator('.food-item').count(); const initialCount = await page.locator('.food-item').count();
expect(initialCount).toBeLessThanOrEqual(50); expect(initialCount).toBeLessThanOrEqual(200);
expect(initialCount).toBeGreaterThan(0); expect(initialCount).toBeGreaterThan(0);
// Sentinel should exist (there are more than 50 items) // Sentinel should exist (there are more than 200 items)
await expect(page.locator('#searchSentinel')).toBeAttached({ timeout: 3000 }); await expect(page.locator('#searchSentinel')).toBeAttached({ timeout: 3000 });
// Scroll sentinel into view to trigger next batches (now loads 3 at once) // Scroll sentinel into view to trigger next batch
await page.locator('#searchSentinel').scrollIntoViewIfNeeded(); await page.locator('#searchSentinel').scrollIntoViewIfNeeded();
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
// Now more items should be rendered (up to 3 batches of 50) // Now more items should be rendered (up to 2 batches of 200)
const afterScrollCount = await page.locator('.food-item').count(); const afterScrollCount = await page.locator('.food-item').count();
expect(afterScrollCount).toBeGreaterThan(initialCount); expect(afterScrollCount).toBeGreaterThan(initialCount);
expect(afterScrollCount).toBeLessThanOrEqual(200); // max 4 batches total (50 + 3×50) expect(afterScrollCount).toBeLessThanOrEqual(400); // max 2 batches total (200 + 200)
}); });
test('infinite scroll: search query resets pagination', async ({ page }) => { test('infinite scroll: search query resets pagination', async ({ page }) => {
await page.goto(BASE); await page.goto('/');
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
// Scroll down a bit to load more // Scroll down a bit to load more
@ -566,16 +564,226 @@ test.describe('Karby Eetdagboek', () => {
const beforeSearch = await page.locator('.food-item').count(); const beforeSearch = await page.locator('.food-item').count();
// Now type a search query — should reset to ≤ 50 items // Now type a search query — should reset to first batch (≤ 200 items)
await page.locator('.search-input').fill('aardappel'); await page.locator('.search-input').fill('aardappel');
await page.waitForTimeout(800); await page.waitForTimeout(800);
const afterSearch = await page.locator('.food-item').count(); const afterSearch = await page.locator('.food-item').count();
expect(afterSearch).toBeLessThanOrEqual(50); expect(afterSearch).toBeLessThanOrEqual(200);
// Should be fewer than before (filtered), unless we hadn't scrolled far // Should be fewer than before (filtered), unless we hadn't scrolled far
if (beforeSearch > 50) { if (beforeSearch > 200) {
expect(afterSearch).toBeLessThan(beforeSearch); expect(afterSearch).toBeLessThan(beforeSearch);
} }
}); });
// ========== TUSSENDOORTJES (via inspiratie-flow, nav-knop verwijderd) ==========
test('tussendoortjes inspiratie: bottom sheet opent via Vind inspiratie vanuit elk Tussendoor-moment', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1000);
// Verify the bottom-nav button is NOT present
await expect(page.locator('#navTussendoortjes')).not.toBeAttached();
// Open dagboek
await page.click('#navDagboek');
await page.waitForTimeout(600);
// Click first Vind inspiratie button
const btn = page.locator('.vind-inspiratie-btn').first();
await expect(btn).toBeVisible();
await btn.click();
await page.waitForTimeout(600);
// Sheet should open
await expect(page.locator('#inspiratieSheet')).toBeVisible();
await expect(page.locator('#inspiratieSheet')).not.toHaveClass(/hidden/);
// Sheet title shows inspiration context
await expect(page.locator('#inspiratieSheetTitle')).toContainText('Inspiratie voor');
// Range controls are in the sheet
await expect(page.locator('#inspiratieSheet #rangeMin')).toBeVisible();
await expect(page.locator('#inspiratieSheet #rangeMax')).toBeVisible();
// Dagboek stays active (no tab switch)
await expect(page.locator('#navDagboek')).toHaveClass(/active/);
});
test('tussendoortjes inspiratie: default range shows suggestie cards in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.click('#navDagboek');
await page.waitForTimeout(600);
// Click first Vind inspiratie to open sheet
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Default range is 15-20g — should show at least one suggestie card
const cards = page.locator('#inspiratieSheet .suggestie-card');
const count = await cards.count();
expect(count).toBeGreaterThanOrEqual(1);
// Each card should have a name, total, ingredients, and preparation
const firstCard = cards.first();
await expect(firstCard.locator('.suggestie-card-naam')).toBeVisible();
await expect(firstCard.locator('.suggestie-card-totaal')).toBeVisible();
await expect(firstCard.locator('.suggestie-ingredient').first()).toBeVisible();
await expect(firstCard.locator('.suggestie-bereiding')).toBeVisible();
});
test('tussendoortjes inspiratie: filter by range in sheet shows only matching totals', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Set range to 10-15g — should show suggestions within this range
await page.locator('#inspiratieSheet #rangeMax').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMax').fill('15');
await page.locator('#inspiratieSheet #rangeMin').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMin').fill('10');
await page.waitForTimeout(500);
// Check that all visible totals are within 10-15g
const totals = page.locator('#inspiratieSheet .suggestie-card-totaal');
const totalCount = await totals.count();
for (let i = 0; i < totalCount; i++) {
const text = await totals.nth(i).textContent();
const val = parseFloat(text);
expect(val).toBeGreaterThanOrEqual(10);
expect(val).toBeLessThanOrEqual(15);
}
});
test('tussendoortjes inspiratie: narrow range shows empty state in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Set range to 0-1g — should show no suggestions
await page.locator('#inspiratieSheet #rangeMax').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMax').fill('1');
await page.locator('#inspiratieSheet #rangeMin').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMin').fill('0');
await page.waitForTimeout(500);
// Should show empty state
const cards = page.locator('#inspiratieSheet .suggestie-card');
const cardCount = await cards.count();
expect(cardCount).toBe(0);
// Empty state text should be visible in the sheet
await expect(page.locator('#inspiratieSheet .inspiratie-empty')).toBeVisible();
});
test('tussendoortjes inspiratie: warning label is present in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1000);
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(600);
const warning = page.locator('#inspiratieSheet .suggestie-warning');
await expect(warning).toBeVisible();
const text = await warning.textContent();
expect(text).toContain('NEVO');
expect(text).toContain('medisch');
});
test('tussendoortjes inspiratie: each ingredient shows name, grams, and kh in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(1000);
const ingredients = page.locator('#inspiratieSheet .suggestie-ingredient');
const count = await ingredients.count();
expect(count).toBeGreaterThanOrEqual(3); // at least 3 total ingredient rows across all cards
// Each ingredient row should have name, grams, and kh value
const firstIng = ingredients.first();
await expect(firstIng.locator('.suggestie-ingredient-naam')).toBeVisible();
await expect(firstIng.locator('.suggestie-ingredient-gram')).toBeVisible();
const gramText = await firstIng.locator('.suggestie-ingredient-gram').textContent();
expect(gramText).toMatch(/\d+g/); // e.g. "175g"
await expect(firstIng.locator('.suggestie-ingredient-kh')).toBeVisible();
const khText = await firstIng.locator('.suggestie-ingredient-kh').textContent();
expect(khText).toMatch(/\d+[.,]?\d* g kh/); // e.g. "6.7 g kh"
});
test('tussendoortjes inspiratie: range value 0 is not defaulted to 15 in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Set min to 0 — previously this would silently become 15 due to parseInt() || 15
await page.locator('#inspiratieSheet #rangeMin').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMin').fill('0');
await page.waitForTimeout(300);
// Verify the input shows 0
await expect(page.locator('#inspiratieSheet #rangeMin')).toHaveValue('0');
// Verify that the app correctly interprets 0 (not falling back to 15)
const parseResult = await page.evaluate(() => {
const el = document.getElementById('rangeMin');
const v = el.value;
const raw = parseInt(v, 10);
const oldFallback = raw || 15; // old broken approach
const newFallback = isNaN(raw) ? 15 : raw; // corrected approach
return { value: v, raw, oldFallback, newFallback };
});
expect(parseResult.raw).toBe(0); // parseInt('0') → 0
expect(parseResult.oldFallback).toBe(15); // 0 || 15 → 15 (the bug)
expect(parseResult.newFallback).toBe(0); // isNaN(0) ? 15 : 0 → 0 (fixed)
});
test('tussendoortjes inspiratie: range 01 is interpreted correctly and shows empty state in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1500);
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(800);
// Set range to 01
await page.locator('#inspiratieSheet #rangeMax').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMax').fill('1');
await page.locator('#inspiratieSheet #rangeMin').click({ clickCount: 3 });
await page.locator('#inspiratieSheet #rangeMin').fill('0');
await page.waitForTimeout(500);
// Verify the actual parsed values via berekenReceptTotalen — min should be 0, not 15
const rangeValues = await page.evaluate(() => {
const rawMin = parseInt(document.getElementById('rangeMin').value, 10);
const rawMax = parseInt(document.getElementById('rangeMax').value, 10);
return {
rangeMin: isNaN(rawMin) ? 15 : rawMin,
rangeMax: isNaN(rawMax) ? 20 : rawMax,
};
});
expect(rangeValues.rangeMin).toBe(0);
expect(rangeValues.rangeMax).toBe(1);
// Should show empty state
const cards = page.locator('#inspiratieSheet .suggestie-card');
const cardCount = await cards.count();
expect(cardCount).toBe(0);
await expect(page.locator('#inspiratieSheet .inspiratie-empty')).toBeVisible();
});
test('tussendoortjes inspiratie: range inputs have haptic metadata in sheet', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(1000);
await page.click('#navDagboek');
await page.waitForTimeout(600);
await page.locator('.vind-inspiratie-btn').first().click();
await page.waitForTimeout(500);
// Both range inputs should have data-haptic attribute
await expect(page.locator('#inspiratieSheet #rangeMin')).toHaveAttribute('data-haptic', 'normal');
await expect(page.locator('#inspiratieSheet #rangeMax')).toHaveAttribute('data-haptic', 'normal');
});
}); });

View file

@ -0,0 +1,123 @@
const { test, expect } = require('@playwright/test');
/**
* Regressietest: eetmoment-labels (icon + naam) zichtbaar in dagboek.
*
* Achtergrond (kaart t_a1d49160): "Eetmoment-labels niet zichtbaar in dagboek
* icon+naam tekst valt weg". Niet reproduceerbaar tegen lokale app noch live;
* template/CSS zijn sinds de initiële commit correct. Deze test borgt dat alle
* zes eetmomentkaarten hun icon + naam tonen, niet transparant en niet
* afgeknipt voor lege én gevulde kaarten, op mobiel én desktop.
*
* Determinisme: de dagboek-data wordt per scenario geseed via localStorage
* (addInitScript) en er wordt gewacht tot de fadeIn-stagger-animatie klaar is
* (opacity === 1) voordat er geassert wordt. Computed styles, geen screenshots.
*/
const MOMENTS = [
{ id: 'Ontbijt', name: 'Ontbijt', icon: '🥐' },
{ id: 'Tussendoor 1', name: 'Tussendoor 1', icon: '☕' },
{ id: 'Lunch', name: 'Lunch', icon: '🥪' },
{ id: 'Tussendoor 2', name: 'Tussendoor 2', icon: '🍎' },
{ id: 'Avondeten', name: 'Avondeten', icon: '🍲' },
{ id: 'Tussendoor 3', name: 'Tussendoor 3', icon: '🍪' },
];
function todayStr() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
function buildDiaryJson(filled) {
const day = {};
for (const m of MOMENTS) {
day[m.id] = filled
? [{ item: { naam: 'Volkoren brood', kh: '50.0', n: '1' }, portie: 30 }]
: [];
}
return JSON.stringify({ [todayStr()]: day });
}
/** Seed dagboek-data in een verse context en open het dagboek-tabblad. */
async function openDiary(page, filled) {
await page.addInitScript((json) => {
localStorage.setItem('koolhydraat-dagboek', json);
}, buildDiaryJson(filled));
await page.goto('/');
await page.locator('#navDagboek').click();
// Wacht tot alle 6 kaarten gerenderd zijn én de fadeIn-stagger (0.35s +
// maximaal 5 * 50ms delay) volledig is afgelopen — anders is opacity nog 0.
await expect(page.locator('.eetmoment-card')).toHaveCount(6, { timeout: 5000 });
await expect
.poll(() =>
page.locator('.eetmoment-card').last().evaluate((el) => getComputedStyle(el).opacity)
, { timeout: 5000 })
.toBe('1');
}
/** Assert per kaart: icon+naam aanwezig, zichtbaar, niet transparant, niet afgeknipt. */
async function expectAllLabelsVisible(page) {
const cards = page.locator('.eetmoment-card');
expect(await cards.count()).toBe(6);
for (let i = 0; i < MOMENTS.length; i++) {
const m = MOMENTS[i];
const card = cards.nth(i);
const header = card.locator('.eetmoment-header');
const icon = card.locator('.eetmoment-icon');
const name = card.locator('.eetmoment-name');
// Template: juiste moment-id, icon en naam aanwezig
await expect(header).toHaveAttribute('data-moment', m.id);
await expect(icon).toHaveText(m.icon, { timeout: 3000 });
await expect(name).toHaveText(m.name, { timeout: 3000 });
// Computed style: zichtbaar + niet transparant + niet afgeknipt
for (const el of [icon, name]) {
const cs = await el.evaluate((node) => {
const style = getComputedStyle(node);
const r = node.getBoundingClientRect();
const cardR = node.closest('.eetmoment-card').getBoundingClientRect();
const alpha = (style.color.match(/rgba?\(([^)]+)\)/) || [])[1];
const a = alpha ? parseFloat(alpha.split(',')[3] ?? '1') : 1;
return {
display: style.display,
visibility: style.visibility,
opacity: parseFloat(style.opacity),
colorAlpha: Number.isNaN(a) ? 1 : a,
w: r.width,
h: r.height,
insideCard: r.bottom <= cardR.bottom + 1 && r.top >= cardR.top - 1,
};
});
expect(cs.display, `${m.id}: ${el === icon ? 'icon' : 'naam'} display`).not.toBe('none');
expect(cs.visibility, `${m.id}: ${el === icon ? 'icon' : 'naam'} visibility`).toBe('visible');
expect(cs.opacity, `${m.id}: ${el === icon ? 'icon' : 'naam'} opacity`).toBeGreaterThan(0.99);
expect(cs.colorAlpha, `${m.id}: ${el === icon ? 'icon' : 'naam'} transparant`).toBeGreaterThan(0);
expect(cs.w, `${m.id}: ${el === icon ? 'icon' : 'naam'} breedte 0`).toBeGreaterThan(0);
expect(cs.h, `${m.id}: ${el === icon ? 'icon' : 'naam'} hoogte 0`).toBeGreaterThan(0);
expect(cs.insideCard, `${m.id}: ${el === icon ? 'icon' : 'naam'} afgeknipt`).toBe(true);
}
}
}
test.describe('Eetmoment-labels — icon+naam zichtbaar (regressie t_a1d49160)', () => {
for (const filled of [false, true]) {
const label = filled ? 'gevuld' : 'leeg';
test(`${label}: alle zes labels zichtbaar, niet transparant/afgeknipt (mobiel)`, async ({ page }) => {
await openDiary(page, filled);
await expectAllLabelsVisible(page);
});
test.describe(`desktop viewport (${label})`, () => {
test.use({ viewport: { width: 1280, height: 800 } });
test('alle zes labels zichtbaar, niet transparant/afgeknipt', async ({ page }) => {
await openDiary(page, filled);
await expectAllLabelsVisible(page);
});
});
}
});

469
tests/inspiratie.spec.js Normal file
View file

@ -0,0 +1,469 @@
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/);
});
});