Files
oikos/test-meal-fit.js
T
2026-05-23 12:45:25 +02:00

156 lines
6.0 KiB
JavaScript

/**
* 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);