156 lines
6.0 KiB
JavaScript
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);
|