Compare commits
No commits in common. "3422b09214d6fdcdd08fa3c4a89f68f3959e8263" and "0ab09c6f1bfb8d8eefad3bca1600651263442d1a" have entirely different histories.
3422b09214
...
0ab09c6f1b
4 changed files with 194 additions and 3182 deletions
1343
docker/index.html
1343
docker/index.html
File diff suppressed because it is too large
Load diff
1343
index.html
1343
index.html
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,11 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const BASE = 'https://eetdagboek.vantwout.dev';
|
||||
|
||||
test.describe('Karby Eetdagboek', () => {
|
||||
|
||||
test('page loads with correct title and elements', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await expect(page).toHaveTitle('Karby');
|
||||
await expect(page.locator('.search-input')).toBeVisible();
|
||||
await expect(page.locator('.cat-btn').first()).toBeVisible();
|
||||
|
|
@ -13,7 +15,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('search returns results and opens detail modal', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('aardappel');
|
||||
await page.waitForTimeout(800);
|
||||
const results = page.locator('.food-item');
|
||||
|
|
@ -25,7 +27,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('category filters work', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.cat-btn').filter({ hasText: 'Brood' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
const activeBtn = page.locator('.cat-btn.active');
|
||||
|
|
@ -33,7 +35,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('add item to diary via eetmoment flow', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('.food-item').first().click();
|
||||
|
|
@ -42,7 +44,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('diary tab shows date navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('#navDagboek').click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('.day-navigator')).toBeVisible({ timeout: 3000 });
|
||||
|
|
@ -51,7 +53,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('settings modal opens', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('#settingsBtn').click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('#settingsModal')).not.toHaveClass(/hidden/, { timeout: 3000 });
|
||||
|
|
@ -60,14 +62,14 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('maaltijden tab shows saved meals section', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('#navMaaltijden').click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('#tabMaaltijden')).toBeVisible();
|
||||
});
|
||||
|
||||
test('contrast: key elements have visible colors', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
const navBtns = page.locator('.nav-btn');
|
||||
const count = await navBtns.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
|
@ -82,7 +84,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
// ---------- Stepper: basis ----------
|
||||
|
||||
test('portion stepper: steppers appear in addMealModal with first count=1', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('.food-item').first().click();
|
||||
|
|
@ -96,7 +98,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('portion stepper: plus op tweede activeert die en reset eerste naar 0', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('.food-item').first().click();
|
||||
|
|
@ -113,7 +115,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('portion stepper: minus verlaagt count naar 0 en wordt dan disabled', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('.food-item').first().click();
|
||||
|
|
@ -128,7 +130,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('portion stepper: active class na plus/minus toggles', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('.food-item').first().click();
|
||||
|
|
@ -151,7 +153,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
// ---------- Modal animaties ----------
|
||||
|
||||
test('modal close animation: closing class applied during dismiss', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('#settingsBtn').click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('#settingsModal')).not.toHaveClass(/hidden/);
|
||||
|
|
@ -163,7 +165,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('modal open animation: fadeIn en slideUp CSS animations gestart', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('#settingsBtn').click();
|
||||
await page.waitForTimeout(100);
|
||||
const overlay = page.locator('#settingsModal');
|
||||
|
|
@ -179,7 +181,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('tab switching: switching class verschijnt tijdens crossfade', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('#navDagboek').click();
|
||||
await page.waitForTimeout(100);
|
||||
const tabEl = page.locator('#tabDagboek');
|
||||
|
|
@ -191,7 +193,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
// ---------- Maaltijden ----------
|
||||
|
||||
test('meal save: name input pre-filled with smart suggestion', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('.food-item').first().click();
|
||||
|
|
@ -246,7 +248,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('meal save: empty name toont rode rand en modal blijft open', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('.food-item').first().click();
|
||||
|
|
@ -300,7 +302,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
// ---------- Datumkiezer ----------
|
||||
|
||||
test('date navigator: next/prev verandert datum en today reset', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('#navDagboek').click();
|
||||
await page.waitForTimeout(500);
|
||||
const initialDay = await page.locator('#dayLabel').textContent();
|
||||
|
|
@ -318,7 +320,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
// ---------- Zoek animaties ----------
|
||||
|
||||
test('zoektab: food items krijgen staggered animation indices', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('k');
|
||||
await page.waitForTimeout(800);
|
||||
const items = page.locator('.food-item');
|
||||
|
|
@ -332,7 +334,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('button press: buttons hebben transition transform bij indrukken', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' });
|
||||
const btn = page.locator('button').first();
|
||||
await expect(btn).toBeVisible({ timeout: 5000 });
|
||||
const transition = await btn.evaluate(el => getComputedStyle(el).transition);
|
||||
|
|
@ -343,7 +345,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
// ---------- Meal-group uitklappen in dagboek ----------
|
||||
|
||||
test('meal-group uitklappen: saved meal toggles expand/collapse in dagboek', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
// Stap 1: Zoek voedingsmiddel en voeg toe aan dagboek
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
|
|
@ -424,25 +426,25 @@ test.describe('Karby Eetdagboek', () => {
|
|||
const itemsWrap = mealGroup.locator('.eetmoment-meal-items-wrap');
|
||||
// Stap 8: Items-wrap start zichtbaar (geen hidden class)
|
||||
await expect(itemsWrap).not.toHaveClass(/hidden|collapsed/);
|
||||
// Stap 9: Klik toggle om in te klappen → collapsed class toegevoegd
|
||||
const toggle = mealGroup.locator('[data-action="toggle-meal"]');
|
||||
await toggle.click();
|
||||
// Stap 9: Klik header om in te klappen → collapsed class toegevoegd
|
||||
const header = mealGroup.locator('.eetmoment-meal-header');
|
||||
await header.click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(itemsWrap).toHaveClass(/collapsed/);
|
||||
const chevronDown = toggle.locator('i.fa-chevron-down');
|
||||
const chevronDown = header.locator('i.fa-chevron-down');
|
||||
await expect(chevronDown).toBeVisible();
|
||||
// Stap 10: Klik opnieuw om uit te klappen → hidden weg
|
||||
await toggle.click();
|
||||
await header.click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(itemsWrap).not.toHaveClass(/hidden|collapsed/);
|
||||
const chevronUp = toggle.locator('i.fa-chevron-up');
|
||||
const chevronUp = header.locator('i.fa-chevron-up');
|
||||
await expect(chevronUp).toBeVisible();
|
||||
});
|
||||
|
||||
// ---------- Confirm + delete dialogs ----------
|
||||
|
||||
test('confirm modal: deletion dialog shows design-conforme buttons', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
// Add item + save as meal from search tab
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
|
|
@ -480,7 +482,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
|
||||
test('portie-edit dialog: single ingredient portion change saves and closes', async ({ page }) => {
|
||||
// Add a loose ingredient to the diary first
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.locator('.search-input').fill('brood');
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('.food-item').first().click();
|
||||
|
|
@ -523,7 +525,7 @@ test.describe('Karby Eetdagboek', () => {
|
|||
});
|
||||
|
||||
test('infinite scroll: only first batch rendered, sentinel triggers more', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.waitForTimeout(1500); // wait for data load
|
||||
|
||||
// Clear any search query to see all items
|
||||
|
|
@ -533,26 +535,26 @@ test.describe('Karby Eetdagboek', () => {
|
|||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Count initially rendered food items — should be ≤ SEARCH_PAGE_SIZE (200)
|
||||
// Count initially rendered food items — should be ≤ SEARCH_PAGE_SIZE (50)
|
||||
const initialCount = await page.locator('.food-item').count();
|
||||
expect(initialCount).toBeLessThanOrEqual(200);
|
||||
expect(initialCount).toBeLessThanOrEqual(50);
|
||||
expect(initialCount).toBeGreaterThan(0);
|
||||
|
||||
// Sentinel should exist (there are more than 200 items)
|
||||
// Sentinel should exist (there are more than 50 items)
|
||||
await expect(page.locator('#searchSentinel')).toBeAttached({ timeout: 3000 });
|
||||
|
||||
// Scroll sentinel into view to trigger next batch
|
||||
// Scroll sentinel into view to trigger next batches (now loads 3 at once)
|
||||
await page.locator('#searchSentinel').scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Now more items should be rendered (up to 2 batches of 200)
|
||||
// Now more items should be rendered (up to 3 batches of 50)
|
||||
const afterScrollCount = await page.locator('.food-item').count();
|
||||
expect(afterScrollCount).toBeGreaterThan(initialCount);
|
||||
expect(afterScrollCount).toBeLessThanOrEqual(400); // max 2 batches total (200 + 200)
|
||||
expect(afterScrollCount).toBeLessThanOrEqual(200); // max 4 batches total (50 + 3×50)
|
||||
});
|
||||
|
||||
test('infinite scroll: search query resets pagination', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto(BASE);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Scroll down a bit to load more
|
||||
|
|
@ -564,200 +566,16 @@ test.describe('Karby Eetdagboek', () => {
|
|||
|
||||
const beforeSearch = await page.locator('.food-item').count();
|
||||
|
||||
// Now type a search query — should reset to first batch (≤ 200 items)
|
||||
// Now type a search query — should reset to ≤ 50 items
|
||||
await page.locator('.search-input').fill('aardappel');
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const afterSearch = await page.locator('.food-item').count();
|
||||
expect(afterSearch).toBeLessThanOrEqual(200);
|
||||
expect(afterSearch).toBeLessThanOrEqual(50);
|
||||
// Should be fewer than before (filtered), unless we hadn't scrolled far
|
||||
if (beforeSearch > 200) {
|
||||
if (beforeSearch > 50) {
|
||||
expect(afterSearch).toBeLessThan(beforeSearch);
|
||||
}
|
||||
});
|
||||
|
||||
// ========== TUSSENDOORTJES (via inspiratie-flow, nav-knop verwijderd) ==========
|
||||
|
||||
test('tussendoortjes tab: bottom-nav button is removed, tab accessible via switchTab', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1000);
|
||||
// Verify the bottom-nav button is NOT present
|
||||
await expect(page.locator('#navTussendoortjes')).not.toBeAttached();
|
||||
// Verify the tab content still exists
|
||||
await expect(page.locator('#tabTussendoortjes')).toBeAttached();
|
||||
// Navigate via page.evaluate (simulating Vind inspiratie flow)
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('#tabTussendoortjes')).toBeVisible();
|
||||
await expect(page.locator('.tussendoortjes-header')).toHaveText('Tussendoortjes');
|
||||
await expect(page.locator('#rangeMin')).toBeVisible();
|
||||
await expect(page.locator('#rangeMax')).toBeVisible();
|
||||
});
|
||||
|
||||
test('tussendoortjes tab: default range shows suggestie cards', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(800);
|
||||
// Default range is 15-20g — should show at least one suggestie card
|
||||
const cards = page.locator('.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 tab: filter by range shows only matching totals', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Set range to 10-15g — should show suggestions within this range
|
||||
await page.locator('#rangeMax').click({ clickCount: 3 });
|
||||
await page.locator('#rangeMax').fill('15');
|
||||
await page.locator('#rangeMin').click({ clickCount: 3 });
|
||||
await page.locator('#rangeMin').fill('10');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that all visible totals are within 10-15g
|
||||
const totals = page.locator('.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 tab: narrow range shows empty state', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Set range to 0-1g — should show no suggestions
|
||||
await page.locator('#rangeMax').click({ clickCount: 3 });
|
||||
await page.locator('#rangeMax').fill('1');
|
||||
await page.locator('#rangeMin').click({ clickCount: 3 });
|
||||
await page.locator('#rangeMin').fill('0');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show empty state
|
||||
const cards = page.locator('.suggestie-card');
|
||||
const cardCount = await cards.count();
|
||||
expect(cardCount).toBe(0);
|
||||
// Empty state text should be visible
|
||||
await expect(page.locator('#tabTussendoortjes .empty-state-text')).toBeVisible();
|
||||
});
|
||||
|
||||
test('tussendoortjes tab: warning label is present', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(500);
|
||||
const warning = page.locator('.suggestie-warning');
|
||||
await expect(warning).toBeVisible();
|
||||
const text = await warning.textContent();
|
||||
expect(text).toContain('NEVO');
|
||||
expect(text).toContain('medisch');
|
||||
});
|
||||
|
||||
test('tussendoortjes tab: each ingredient shows name, grams, and kh', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const ingredients = page.locator('.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 tab: range value 0 is not defaulted to 15', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Set min to 0 — previously this would silently become 15 due to parseInt() || 15
|
||||
await page.locator('#rangeMin').click({ clickCount: 3 });
|
||||
await page.locator('#rangeMin').fill('0');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify the input shows 0
|
||||
await expect(page.locator('#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 tab: range 0–1 is interpreted correctly and shows empty state', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Set range to 0–1
|
||||
await page.locator('#rangeMax').click({ clickCount: 3 });
|
||||
await page.locator('#rangeMax').fill('1');
|
||||
await page.locator('#rangeMin').click({ clickCount: 3 });
|
||||
await page.locator('#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('.suggestie-card');
|
||||
const cardCount = await cards.count();
|
||||
expect(cardCount).toBe(0);
|
||||
await expect(page.locator('#tabTussendoortjes .empty-state-text')).toBeVisible();
|
||||
});
|
||||
|
||||
test('tussendoortjes tab: range inputs have haptic metadata', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Both range inputs should have data-haptic attribute
|
||||
await expect(page.locator('#rangeMin')).toHaveAttribute('data-haptic', 'normal');
|
||||
await expect(page.locator('#rangeMax')).toHaveAttribute('data-haptic', 'normal');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,348 +0,0 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* E2E tests for the "Vind inspiratie" feature on Tussendoor 1/2/3
|
||||
*
|
||||
* 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: switch to tussendoortjes tab (via switchTab, nav-knop is verwijderd)
|
||||
async function goToTussendoortjes(page) {
|
||||
await page.evaluate(() => switchTab('tussendoortjes'));
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
test.describe('Vind inspiratie feature', () => {
|
||||
|
||||
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 tussendoortjes 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 be on tussendoortjes tab
|
||||
const title = page.locator('#tussendoortjesTitle');
|
||||
await expect(title).toBeVisible();
|
||||
|
||||
// Title should start with "Inspiratie voor Tussendoor"
|
||||
const titleText = await title.textContent();
|
||||
expect(titleText).toContain('Inspiratie voor Tussendoor');
|
||||
});
|
||||
|
||||
test('Vind inspiratie shows context header with date and moment name', 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
|
||||
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 and shows normal title', 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 close
|
||||
await page.click('#inspiratieContextClose');
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// Title should now be "Tussendoortjes"
|
||||
const titleText = await page.locator('#tussendoortjesTitle').textContent();
|
||||
expect(titleText).toBe('Tussendoortjes');
|
||||
|
||||
// Context should be hidden
|
||||
await expect(page.locator('#inspiratieContext')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Nieuwe suggesties button exists and generates suggestions', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForApp(page);
|
||||
await goToTussendoortjes(page);
|
||||
|
||||
// Button should be visible
|
||||
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 goToTussendoortjes(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 goToTussendoortjes(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();
|
||||
}
|
||||
|
||||
await goToTussendoortjes(page);
|
||||
|
||||
// Check Nieuwe suggesties button has haptic
|
||||
// (data-haptic is set globally via the event delegation on buttons,
|
||||
// but the button itself should trigger haptic via the global setup)
|
||||
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 goToTussendoortjes(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 goToTussendoortjes(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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue