/** * Modul: Meal-Fit-Test * Zweck: Validiert deterministic context-aware meal suggestions and grocery dedupe. * Ausführen: node test-meal-fit.js */ import { generateGroceryList, normalizeIngredientName, scoreMealSuggestions } from './server/services/meal-fit.js'; let passed = 0; let failed = 0; function test(name, fn) { try { fn(); console.log(` ✓ ${name}`); passed++; } catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; } } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); } function topName(result) { return result[0]?.mealName; } const meals = [ { id: 'wraps', name: 'Chicken wraps', ingredients: ['tortilla wraps', 'leftover chicken', 'cucumber', 'cheddar cheese'], activeMinutes: 12, totalMinutes: 15, effort: 'easy', cleanup: 'low', interruptionTolerance: 'high', kidFit: 'safe', meal_category: 'chicken', style: 'quick', tags: ['portable'], }, { id: 'lasagna', name: 'Sunday lasagna', ingredients: ['beef mince', 'tomatoes', 'pasta sheets', 'cheddar cheese'], activeMinutes: 50, totalMinutes: 110, effort: 'project', cleanup: 'high', interruptionTolerance: 'low', kidFit: 'safe', guestFit: true, batchFriendly: true, meal_category: 'pasta', tags: ['oven', 'cozy'], }, { id: 'fish', name: 'Fish curry', ingredients: ['fish', 'rice', 'curry paste', 'coconut milk'], activeMinutes: 35, totalMinutes: 40, effort: 'normal', cleanup: 'medium', interruptionTolerance: 'low', kidFit: 'risky', meal_category: 'fish', style: 'family', }, { id: 'pancakes', name: 'Pancakes', ingredients: ['flour', 'milk', 'eggs'], activeMinutes: 25, totalMinutes: 30, effort: 'easy', cleanup: 'medium', interruptionTolerance: 'medium', kidFit: 'safe', meal_category: 'breakfast', }, ]; console.log('\n[Meal-Fit-Test] Context-aware dinner planner\n'); test('ingredient normalization dedupes obvious variants', () => { assert(normalizeIngredientName('Grated cheddar') === 'cheddar'); assert(normalizeIngredientName('cheddar cheese') === 'cheddar'); assert(normalizeIngredientName('Tortilla wraps') === 'tortillas'); }); test('busy low-energy day promotes quick low-cleanup interruption-friendly leftover meal', () => { const suggestions = scoreMealSuggestions({ meals, dayContext: { busyness: 'high', energy: 'low', dinnerWindowMinutes: 25, labels: ['late_activity'] }, inventory: [{ name: 'leftover chicken', portions: 2, expiresOn: '2026-05-24' }], today: '2026-05-23', }); assert(topName(suggestions) === 'Chicken wraps', `Expected Chicken wraps, got ${topName(suggestions)}`); const top = suggestions[0]; assert(top.fitLabels.includes('quick-active-time')); assert(top.fitLabels.includes('low-cleanup')); assert(top.fitLabels.includes('uses-inventory')); assert(top.reasons.some((reason) => reason.includes('tight day') || reason.includes('Low-energy'))); }); test('normal guest day promotes guest-capable batch meal over emergency wrap default', () => { const suggestions = scoreMealSuggestions({ meals, dayContext: { busyness: 'low', energy: 'high', guests: true, dinnerWindowMinutes: 150 }, pantryStaples: ['pasta sheets'], }); assert(topName(suggestions) === 'Sunday lasagna', `Expected Sunday lasagna, got ${topName(suggestions)}`); assert(suggestions[0].fitLabels.includes('guest-capable')); }); test('allergy blocks instead of merely warning', () => { const suggestions = scoreMealSuggestions({ meals, preferences: [{ type: 'allergy', target: 'fish', strength: 'high' }], }); const fish = suggestions.find((suggestion) => suggestion.mealId === 'fish'); assert(fish.score < -1000); assert(fish.warnings.some((warning) => warning.includes('allergy'))); }); test('dislike and recent repetition demote risky meals with grounded warnings', () => { const suggestions = scoreMealSuggestions({ meals, preferences: [{ type: 'dislike', target: 'fish', strength: 'high' }], recentMeals: [{ title: 'Fish curry', meal_category: 'fish' }], }); const fish = suggestions.find((suggestion) => suggestion.mealId === 'fish'); assert(fish.warnings.some((warning) => warning.includes('disliked'))); assert(fish.warnings.some((warning) => warning.includes('Recently eaten'))); assert(suggestions.indexOf(fish) > 0, 'Fish should not rank first after dislike + repetition'); }); test('inventory matching is concrete, not broad tag magic', () => { const suggestions = scoreMealSuggestions({ meals: [{ id: 'taggy', name: 'Mystery pasta', ingredients: ['tomatoes'], meal_category: 'pasta', tags: ['pasta'] }], inventory: [{ name: 'pasta', expiresOn: '2026-05-24' }], today: '2026-05-23', }); assert(!suggestions[0].fitLabels.includes('uses-inventory'), 'Broad pasta tag must not count as concrete inventory use'); assert(!suggestions[0].reasons.some((reason) => reason.includes('Uses pasta'))); }); test('grocery list dedupes normalized ingredient names and tracks source meals', () => { const grocery = generateGroceryList([ meals[0], { id: 'quesadillas', name: 'Quesadillas', ingredients: ['tortillas', 'grated cheddar', 'pepper'] }, ], { inventory: [{ name: 'leftover chicken' }], pantryStaples: ['pepper'] }); const names = grocery.items.map((item) => item.name); assert(names.filter((name) => name === 'cheddar').length === 1, `Expected one cheddar, got ${names.join(', ')}`); assert(names.filter((name) => name === 'tortillas').length === 1, `Expected one tortillas, got ${names.join(', ')}`); const cheddar = grocery.items.find((item) => item.name === 'cheddar'); assert(cheddar.sourceMeals.length === 2, 'Cheddar should link both source meals'); assert(grocery.coveredByInventory.some((item) => item.name === 'chicken'), 'Leftover chicken should be inventory-covered'); }); console.log(`\n[Meal-Fit-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`); if (failed > 0) process.exit(1);