karby/tests/e2e.spec.js
cas 35278053a7 test: e2e tests voor infinite scroll — batch loading + search reset
2 nieuwe Playwright tests:
- Sentinels triggeren meer items na scrollIntoView
- Zoekopdracht reset paginering terug naar ≤ SEARCH_PAGE_SIZE

docker/index.html synced met worker-implementatie
2026-07-24 11:13:36 +02:00

494 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

const { test, expect } = require('@playwright/test');
const BASE = 'https://eetdagboek.vantwout.dev';
test.describe('Karby Eetdagboek', () => {
test('page loads with correct title and elements', async ({ page }) => {
await page.goto(BASE);
await expect(page).toHaveTitle('Karby');
await expect(page.locator('.search-input')).toBeVisible();
await expect(page.locator('.cat-btn').first()).toBeVisible();
await expect(page.locator('#navZoeken')).toBeVisible();
await expect(page.locator('#navDagboek')).toBeVisible();
await expect(page.locator('#navMaaltijden')).toBeVisible();
});
test('search returns results and opens detail modal', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('aardappel');
await page.waitForTimeout(800);
const results = page.locator('.food-item');
await expect(results.first()).toBeVisible({ timeout: 5000 });
await results.first().click();
await expect(page.locator('#detailModal')).not.toHaveClass(/hidden/, { timeout: 5000 });
await page.locator('#detailModal .modal-close').click();
await expect(page.locator('#detailModal')).toHaveClass(/hidden/, { timeout: 3000 });
});
test('category filters work', async ({ page }) => {
await page.goto(BASE);
await page.locator('.cat-btn').filter({ hasText: 'Brood' }).click();
await page.waitForTimeout(500);
const activeBtn = page.locator('.cat-btn.active');
await expect(activeBtn).toBeVisible();
});
test('add item to diary via eetmoment flow', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
await expect(page.locator('#detailAddBtn')).toBeVisible({ timeout: 3000 });
});
test('diary tab shows date navigation', async ({ page }) => {
await page.goto(BASE);
await page.locator('#navDagboek').click();
await page.waitForTimeout(500);
await expect(page.locator('.day-navigator')).toBeVisible({ timeout: 3000 });
await expect(page.locator('.day-nav-btn').first()).toBeVisible();
await expect(page.locator('.day-nav-today')).toBeVisible();
});
test('settings modal opens', async ({ page }) => {
await page.goto(BASE);
await page.locator('#settingsBtn').click();
await page.waitForTimeout(500);
await expect(page.locator('#settingsModal')).not.toHaveClass(/hidden/, { timeout: 3000 });
await page.locator('#settingsModal .modal-close').click();
await expect(page.locator('#settingsModal')).toHaveClass(/hidden/, { timeout: 2000 });
});
test('maaltijden tab shows saved meals section', async ({ page }) => {
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(BASE);
const navBtns = page.locator('.nav-btn');
const count = await navBtns.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
const color = await navBtns.nth(i).evaluate(el => getComputedStyle(el).color);
expect(color).not.toBe('rgba(0, 0, 0, 0)');
}
});
// ========== NIEUWE TESTS — portie-steppers, animaties, maaltijden, datumkiezer, meal-groups ==========
// ---------- Stepper: basis ----------
test('portion stepper: steppers appear in addMealModal with first count=1', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
await page.locator('#detailAddBtn').click();
await page.waitForTimeout(500);
const steppers = page.locator('.portie-stepper');
await expect(steppers.first()).toBeVisible({ timeout: 3000 });
expect(await steppers.count()).toBeGreaterThan(0);
await expect(steppers.first().locator('.portie-stepper-count')).toHaveText('1');
});
test('portion stepper: plus op tweede activeert die en reset eerste naar 0', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
await page.locator('#detailAddBtn').click();
await page.waitForTimeout(500);
const steppers = page.locator('.portie-stepper');
const count = await steppers.count();
if (count < 2) { test.skip(); return; }
await steppers.nth(1).locator('.portie-stepper-plus').click();
await page.waitForTimeout(100);
await expect(steppers.first().locator('.portie-stepper-count')).toHaveText('0');
await expect(steppers.nth(1).locator('.portie-stepper-count')).toHaveText('1');
});
test('portion stepper: minus verlaagt count naar 0 en wordt dan disabled', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
await page.locator('#detailAddBtn').click();
await page.waitForTimeout(500);
const firstStepper = page.locator('.portie-stepper').first();
await expect(firstStepper.locator('.portie-stepper-count')).toHaveText('1');
await firstStepper.locator('.portie-stepper-minus').click();
await expect(firstStepper.locator('.portie-stepper-count')).toHaveText('0');
await expect(firstStepper.locator('.portie-stepper-minus')).toBeDisabled();
});
test('portion stepper: active class na plus/minus toggles', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
await page.locator('#detailAddBtn').click();
await page.waitForTimeout(500);
const firstStepper = page.locator('.portie-stepper').first();
// active class na updatePortionFromSteppers triggeren via plus
await firstStepper.locator('.portie-stepper-plus').click();
await page.waitForTimeout(100);
await expect(firstStepper).toHaveClass(/active/);
// Minus 2×: 2→1→0, dan active kwijt
await firstStepper.locator('.portie-stepper-minus').click();
await page.waitForTimeout(50);
await firstStepper.locator('.portie-stepper-minus').click();
await page.waitForTimeout(50);
await expect(firstStepper).not.toHaveClass(/active/);
});
// ---------- Modal animaties ----------
test('modal close animation: closing class applied during dismiss', async ({ page }) => {
await page.goto(BASE);
await page.locator('#settingsBtn').click();
await page.waitForTimeout(500);
await expect(page.locator('#settingsModal')).not.toHaveClass(/hidden/);
await page.locator('#settingsModal .modal-close').click();
await page.waitForTimeout(50);
await expect(page.locator('#settingsModal')).toHaveClass(/closing/);
await page.waitForTimeout(600);
await expect(page.locator('#settingsModal')).toHaveClass(/hidden/);
});
test('modal open animation: fadeIn en slideUp CSS animations gestart', async ({ page }) => {
await page.goto(BASE);
await page.locator('#settingsBtn').click();
await page.waitForTimeout(100);
const overlay = page.locator('#settingsModal');
await expect(overlay).toBeVisible();
await expect(overlay).not.toHaveClass(/closing/);
const overlayAnim = await overlay.evaluate(el => getComputedStyle(el).animationName);
expect(overlayAnim).toBe('fadeIn');
const sheet = overlay.locator('.modal-sheet');
const sheetAnim = await sheet.evaluate(el => getComputedStyle(el).animationName);
expect(sheetAnim).toBe('slideUp');
await page.locator('#settingsModal .modal-close').click();
await page.waitForTimeout(500);
});
test('tab switching: switching class verschijnt tijdens crossfade', async ({ page }) => {
await page.goto(BASE);
await page.locator('#navDagboek').click();
await page.waitForTimeout(100);
const tabEl = page.locator('#tabDagboek');
await expect(tabEl).toHaveClass(/switching/);
await page.waitForTimeout(500);
await expect(tabEl).toBeVisible();
});
// ---------- Maaltijden ----------
test('meal save: name input is empty by default', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
await page.locator('#detailAddBtn').click();
await page.waitForTimeout(500);
// Item toevoegen via addMealSubmit → sluit modal + gaat naar dagboek
await page.locator('#addMealSubmit').click();
await page.waitForTimeout(800);
const saveBtn = page.locator('.save-meal-btn');
await expect(saveBtn.first()).toBeVisible({ timeout: 5000 });
await saveBtn.first().click();
await page.waitForTimeout(500);
const nameInput = page.locator('#mealNameInput');
await expect(nameInput).toBeVisible({ timeout: 5000 });
await expect(nameInput).toHaveValue('');
await page.locator('#mealNameCancel').click();
await page.waitForTimeout(300);
});
test('meal save: empty name toont rode rand en modal blijft open', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
await page.locator('#detailAddBtn').click();
await page.waitForTimeout(500);
await page.locator('#addMealSubmit').click();
await page.waitForTimeout(800);
const saveBtn = page.locator('.save-meal-btn');
await expect(saveBtn.first()).toBeVisible({ timeout: 5000 });
await saveBtn.first().click();
await page.waitForTimeout(500);
const nameInput = page.locator('#mealNameInput');
await expect(nameInput).toBeVisible({ timeout: 5000 });
await expect(nameInput).toHaveValue('');
// Opslaan zonder naam → validatiefout
await page.locator('#mealNameSave').click();
await page.waitForTimeout(300);
await expect(nameInput).toBeVisible();
const borderColor = await nameInput.evaluate(el => getComputedStyle(el).borderColor);
const rgb = borderColor.match(/\d+/g).map(Number);
expect(rgb[0]).toBeGreaterThan(150);
expect(rgb[1]).toBeLessThan(100);
expect(rgb[2]).toBeLessThan(100);
await page.locator('#mealNameCancel').click();
await page.waitForTimeout(300);
});
// ---------- Datumkiezer ----------
test('date navigator: next/prev verandert datum en today reset', async ({ page }) => {
await page.goto(BASE);
await page.locator('#navDagboek').click();
await page.waitForTimeout(500);
const initialDay = await page.locator('#dayLabel').textContent();
const nextBtn = page.locator('.day-nav-btn').nth(1);
await nextBtn.click();
await page.waitForTimeout(300);
const nextDay = await page.locator('#dayLabel').textContent();
expect(nextDay).not.toBe(initialDay);
await page.locator('.day-nav-today').click();
await page.waitForTimeout(300);
const todayDay = await page.locator('#dayLabel').textContent();
expect(todayDay).toBe(initialDay);
});
// ---------- Zoek animaties ----------
test('zoektab: food items krijgen staggered animation indices', async ({ page }) => {
await page.goto(BASE);
await page.locator('.search-input').fill('k');
await page.waitForTimeout(800);
const items = page.locator('.food-item');
const count = await items.count();
if (count >= 2) {
const i0 = await items.first().evaluate(el => el.style.getPropertyValue('--i'));
expect(i0.trim()).toBe('0');
const i1 = await items.nth(1).evaluate(el => el.style.getPropertyValue('--i'));
expect(i1.trim()).toBe('1');
}
});
test('button press: buttons hebben transition transform bij indrukken', async ({ page }) => {
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);
expect(transition).toBeTruthy();
expect(transition).toContain('transform');
});
// ---------- Meal-group uitklappen in dagboek ----------
test('meal-group uitklappen: saved meal toggles expand/collapse in dagboek', async ({ page }) => {
await page.goto(BASE);
// Stap 1: Zoek voedingsmiddel en voeg toe aan dagboek
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
await page.locator('#detailAddBtn').click();
await page.waitForTimeout(500);
await page.locator('#addMealSubmit').click();
await page.waitForTimeout(800);
// Stap 2: Sla op als maaltijd
const saveBtn = page.locator('.save-meal-btn');
await expect(saveBtn.first()).toBeVisible({ timeout: 5000 });
await saveBtn.first().click();
await page.waitForTimeout(500);
const nameInput = page.locator('#mealNameInput');
await expect(nameInput).toBeVisible({ timeout: 5000 });
await nameInput.fill('Test ontbijt');
await page.locator('#mealNameSave').click();
await page.waitForTimeout(600);
// Stap 3: Ga naar maaltijden tab om de maaltijd toe te voegen aan dagboek
await page.locator('#navMaaltijden').click();
await page.waitForTimeout(600);
// Stap 4: Klik "Toevoegen aan dagboek" op de opgeslagen maaltijd
const mealCardAdd = page.locator('.meal-card-add').first();
await expect(mealCardAdd).toBeVisible({ timeout: 5000 });
await mealCardAdd.click();
await page.waitForTimeout(500);
// Stap 5: Klik op een eetmoment-optie in de modal (bv. Ontbijt)
const mealOption = page.locator('.meal-add-option').first();
await expect(mealOption).toBeVisible({ timeout: 3000 });
await mealOption.click();
await page.waitForTimeout(600);
// Sluit eventuele resteerde modals door op de overlay te klikken
const anyOverlay = page.locator('.modal-overlay:not(.hidden)').first();
if (await anyOverlay.isVisible().catch(() => false)) {
await anyOverlay.click({ position: { x: 10, y: 10 } });
await page.waitForTimeout(300);
}
// Stap 6: Ga naar dagboek om de meal-group te zien
await page.locator('#navDagboek').click();
await page.waitForTimeout(800);
// Stap 7: Zoek meal-group
const mealGroup = page.locator('.eetmoment-meal-group').first();
await expect(mealGroup).toBeVisible({ timeout: 5000 });
// Stap 8: Items-wrap begint collapsed
const itemsWrap = mealGroup.locator('.eetmoment-meal-items-wrap');
// Stap 8: Items-wrap start zichtbaar (geen hidden class)
await expect(itemsWrap).not.toHaveClass(/hidden/);
// Stap 9: Klik header om in te klappen → hidden aan
const header = mealGroup.locator('.eetmoment-meal-header');
await header.click();
await page.waitForTimeout(300);
await expect(itemsWrap).toHaveClass(/hidden/);
const chevronDown = header.locator('i.fa-chevron-down');
await expect(chevronDown).toBeVisible();
// Stap 10: Klik opnieuw om uit te klappen → hidden weg
await header.click();
await page.waitForTimeout(300);
await expect(itemsWrap).not.toHaveClass(/hidden/);
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(BASE);
// Add item + save as meal from search tab
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
await page.locator('#detailAddBtn').click();
await page.waitForTimeout(500);
await page.locator('#addMealSubmit').click();
await page.waitForTimeout(800);
// Navigate to diary, save as meal
await page.locator('#navDagboek').click();
await page.waitForTimeout(600);
const saveBtn = page.locator('.save-meal-btn');
if (await saveBtn.count() === 0) { test.skip(); return; }
await saveBtn.first().click();
await page.waitForTimeout(500);
await page.locator('#mealNameInput').fill('Test');
await page.locator('#mealNameSave').click();
await page.waitForTimeout(600);
// Go to maaltijden and delete
await page.locator('#navMaaltijden').click();
await page.waitForTimeout(600);
// Open first meal edit
await page.locator('.meal-card-main').first().click();
await page.waitForTimeout(500);
const deleteBtn = page.locator('#mealEditDelete');
if (await deleteBtn.isVisible().catch(() => false)) {
await deleteBtn.click();
await page.waitForTimeout(300);
await expect(page.locator('#confirmOk')).toBeVisible({ timeout: 3000 });
await expect(page.locator('#confirmCancel')).toBeVisible();
await page.locator('#confirmCancel').click();
}
});
test('portie-edit dialog: single ingredient portion change saves and closes', async ({ page }) => {
// Add a loose ingredient to the diary first
await page.goto(BASE);
await page.locator('.search-input').fill('brood');
await page.waitForTimeout(800);
await page.locator('.food-item').first().click();
await page.waitForTimeout(500);
// Click "Toevoegen aan eetmoment" → opens addMealModal
await page.locator('#detailAddBtn').click();
await page.waitForTimeout(500);
// Confirm adding to diary (default portie=100g, default eetmoment)
await page.locator('#addMealSubmit').click();
await page.waitForTimeout(800);
// Should now be on dagboek tab
// Find the portion display for a single ingredient (no data-mi attribute)
const portionSpan = page.locator('[data-action="edit-portie"]:not([data-mi])').first();
await expect(portionSpan).toBeVisible({ timeout: 5000 });
await portionSpan.click();
await page.waitForTimeout(500);
// Verify the portion edit dialog opened
const editInput = page.locator('#portieEditInput');
await expect(editInput).toBeVisible({ timeout: 3000 });
// Verify preview shows initial carbs
const preview = page.locator('#portieEditPreview');
await expect(preview).toBeVisible();
// Change the portion to 200g
await editInput.fill('200');
await editInput.dispatchEvent('input');
await page.waitForTimeout(300);
// Click save
await page.locator('#portieSaveBtn').click();
await page.waitForTimeout(500);
// Dialog should be gone (removed from DOM after closing animation)
await expect(page.locator('#portieEditInput')).not.toBeAttached({ timeout: 3000 });
});
test('infinite scroll: only first batch rendered, sentinel triggers more', async ({ page }) => {
await page.goto(BASE);
await page.waitForTimeout(1500); // wait for data load
// Clear any search query to see all items
const clearBtn = page.locator('#searchClear');
if (await clearBtn.isVisible().catch(() => false)) {
await clearBtn.click();
await page.waitForTimeout(500);
}
// Count initially rendered food items — should be ≤ SEARCH_PAGE_SIZE (50)
const initialCount = await page.locator('.food-item').count();
expect(initialCount).toBeLessThanOrEqual(50);
expect(initialCount).toBeGreaterThan(0);
// 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
await page.locator('#searchSentinel').scrollIntoViewIfNeeded();
await page.waitForTimeout(800);
// Now more items should be rendered
const afterScrollCount = await page.locator('.food-item').count();
expect(afterScrollCount).toBeGreaterThan(initialCount);
expect(afterScrollCount).toBeLessThanOrEqual(100); // 2nd page = 100 max
});
test('infinite scroll: search query resets pagination', async ({ page }) => {
await page.goto(BASE);
await page.waitForTimeout(1500);
// Scroll down a bit to load more
const sentinel = page.locator('#searchSentinel');
if (await sentinel.isVisible().catch(() => false)) {
await sentinel.scrollIntoViewIfNeeded();
await page.waitForTimeout(800);
}
const beforeSearch = await page.locator('.food-item').count();
// 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(50);
// Should be fewer than before (filtered), unless we hadn't scrolled far
if (beforeSearch > 50) {
expect(afterSearch).toBeLessThan(beforeSearch);
}
});
});