- 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
348 lines
13 KiB
JavaScript
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`);
|
|
}
|
|
});
|
|
});
|