karby/tests/inspiratie.spec.js
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

348 lines
13 KiB
JavaScript

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