From 3ac82e65fe798e64fa8dbbf01f71ff49ee1cc2f8 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sat, 23 May 2026 12:45:25 +0200 Subject: [PATCH] feat: add context-aware meal fit engine --- docs/MEAL_PLANNING_ROADMAP.md | 6 + package.json | 1 + server/routes/meal-planning.js | 23 +++ server/services/meal-fit.js | 296 +++++++++++++++++++++++++++++++++ test-meal-fit.js | 155 +++++++++++++++++ 5 files changed, 481 insertions(+) create mode 100644 server/services/meal-fit.js create mode 100644 test-meal-fit.js diff --git a/docs/MEAL_PLANNING_ROADMAP.md b/docs/MEAL_PLANNING_ROADMAP.md index cf53a2b..ec1fddb 100644 --- a/docs/MEAL_PLANNING_ROADMAP.md +++ b/docs/MEAL_PLANNING_ROADMAP.md @@ -65,6 +65,12 @@ Meal planning should be a native Oikos kitchen workflow, not a chatbot shortcut. - [x] Structural E2E asserts Studio plans expose modulator labels and active signal types, so planning context is not hidden in opaque AI text. - [x] Playwright artifacts are opt-in to reduce credential leak risk. +### Context-aware family logistics engine + +- [x] Added deterministic meal-fit service for the next planning slice: day context, energy, guests, cleanup, interruption tolerance, concrete inventory/leftover use, preferences/allergies, recency, and grocery delta all affect ranking before AI gets involved. +- [x] Added `/api/v1/meal-planning/suggestions` as a thin native API boundary returning ranked suggestions plus optional generated grocery output. +- [x] Added `test:meal-fit` acceptance coverage proving busy/low-energy days, guest days, allergy blocks, dislike/repetition, concrete inventory matching, and grocery dedupe change outputs materially. + ## Remaining Combined Plan ### P0 — Upstream/Core polish before PR diff --git a/package.json b/package.json index 01b4897..8ba70bd 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "test:carddav": "node --experimental-sqlite test-carddav.js", "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav", "test:meal-planning": "node --experimental-sqlite test-meal-planning.js", + "test:meal-fit": "node test-meal-fit.js", "test:e2e:oikos-flow": "playwright test tests/e2e/oikos-kitchen-assist-flow.spec.mjs" }, "dependencies": { diff --git a/server/routes/meal-planning.js b/server/routes/meal-planning.js index a5912c0..dd36168 100644 --- a/server/routes/meal-planning.js +++ b/server/routes/meal-planning.js @@ -9,6 +9,7 @@ import crypto from 'node:crypto'; import * as db from '../db.js'; import { createLogger } from '../logger.js'; import { str, num, date, oneOf, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../middleware/validate.js'; +import { generateGroceryList, scoreMealSuggestions } from '../services/meal-fit.js'; const log = createLogger('MealPlanning'); const router = express.Router(); @@ -254,6 +255,28 @@ router.put('/cook-assignments/:mealId', (req, res) => { } catch (err) { handleError(res, err, 'PUT /cook-assignments/:mealId'); } }); +router.post('/suggestions', (req, res) => { + try { + const meals = Array.isArray(req.body?.meals) ? req.body.meals : []; + const selectedMeals = Array.isArray(req.body?.selectedMeals) ? req.body.selectedMeals : []; + const context = { + meals, + dayContext: req.body?.dayContext || req.body?.day_context || {}, + preferences: Array.isArray(req.body?.preferences) ? req.body.preferences : [], + inventory: Array.isArray(req.body?.inventory) ? req.body.inventory : [], + recentMeals: Array.isArray(req.body?.recentMeals) ? req.body.recentMeals : [], + pantryStaples: Array.isArray(req.body?.pantryStaples) ? req.body.pantryStaples : [], + today: req.body?.today, + }; + if (!meals.length) return res.status(400).json({ error: 'At least one meal is required.', code: 400 }); + const suggestions = scoreMealSuggestions(context); + const groceryList = selectedMeals.length + ? generateGroceryList(selectedMeals, { inventory: context.inventory, pantryStaples: context.pantryStaples }) + : null; + res.json({ data: { suggestions, groceryList } }); + } catch (err) { handleError(res, err, 'POST /suggestions'); } +}); + router.get('/feedback', (req, res) => { try { const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50))); diff --git a/server/services/meal-fit.js b/server/services/meal-fit.js new file mode 100644 index 0000000..35badaf --- /dev/null +++ b/server/services/meal-fit.js @@ -0,0 +1,296 @@ +/** + * Modul: Meal Fit Service + * Zweck: Deterministic context-aware meal ranking and grocery deltas for family logistics. + * Dependencies: none + */ + +const STOP_WORDS = new Set([ + 'fresh', 'frozen', 'canned', 'can', 'jar', 'large', 'small', 'medium', 'chopped', 'diced', + 'sliced', 'grated', 'shredded', 'minced', 'whole', 'organic', 'low', 'fat', 'lean', 'extra', + 'of', 'with', 'and', 'the', 'a', 'an', 'to', 'for', 'optional', 'some', 'little', +]); + +const INGREDIENT_ALIASES = new Map([ + ['cheddar cheese', 'cheddar'], + ['grated cheddar', 'cheddar'], + ['shredded cheddar', 'cheddar'], + ['chicken breast', 'chicken'], + ['chicken breasts', 'chicken'], + ['leftover chicken', 'chicken'], + ['minced beef', 'beef'], + ['ground beef', 'beef'], + ['beef mince', 'beef'], + ['bell pepper', 'pepper'], + ['bell peppers', 'pepper'], + ['tortilla wraps', 'tortillas'], + ['wraps', 'tortillas'], +]); + +const EFFORT_SCORE = { survival: 4, easy: 3, normal: 1, project: -4 }; +const CLEANUP_SCORE = { low: 3, medium: 0, high: -4 }; +const INTERRUPTION_SCORE = { high: 3, medium: 0, low: -4 }; +const KID_SCORE = { safe: 3, mixed: 0, risky: -4 }; + +function toArray(value) { + return Array.isArray(value) ? value : []; +} + +export function normalizeIngredientName(value) { + const raw = String(value || '').toLowerCase().replace(/[^a-z0-9æøåäöüéèàçñ\s-]/gi, ' ').replace(/\s+/g, ' ').trim(); + if (!raw) return ''; + if (INGREDIENT_ALIASES.has(raw)) return INGREDIENT_ALIASES.get(raw); + const singularish = raw.replace(/ies$/, 'y').replace(/oes$/, 'o').replace(/s$/, ''); + if (INGREDIENT_ALIASES.has(singularish)) return INGREDIENT_ALIASES.get(singularish); + return singularish.split(' ').filter((part) => part && !STOP_WORDS.has(part)).join(' ') || singularish; +} + +function ingredientName(ingredient) { + return normalizeIngredientName(typeof ingredient === 'string' ? ingredient : ingredient?.name); +} + +function inventoryMap(inventory) { + const map = new Map(); + for (const item of toArray(inventory)) { + const normalized = normalizeIngredientName(item.normalizedName || item.normalized_name || item.name); + if (!normalized) continue; + const existing = map.get(normalized) || { name: item.name || normalized, portions: 0, expiresOn: null, items: [] }; + existing.portions += Number(item.portions || 0); + existing.expiresOn = earlierDate(existing.expiresOn, item.expiresOn || item.expires_on || null); + existing.items.push(item); + map.set(normalized, existing); + } + return map; +} + +function earlierDate(a, b) { + if (!a) return b; + if (!b) return a; + return String(a) < String(b) ? a : b; +} + +function daysUntil(dateStr, today = new Date().toISOString().slice(0, 10)) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dateStr || ''))) return null; + const ms = Date.parse(`${dateStr}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`); + return Math.round(ms / 86400000); +} + +function tagsFor(meal) { + const parsed = typeof meal.tags_json === 'string' ? safeJson(meal.tags_json, []) : []; + return new Set([...toArray(meal.tags), ...parsed, meal.meal_category, meal.protein, meal.style] + .map((tag) => String(tag || '').toLowerCase().trim()).filter(Boolean)); +} + +function safeJson(value, fallback) { + try { return JSON.parse(value); } catch { return fallback; } +} + +function mealIngredients(meal) { + return toArray(meal.ingredients).map((ing) => ({ + ...((typeof ing === 'string') ? { name: ing } : ing), + normalizedName: ingredientName(ing), + })).filter((ing) => ing.normalizedName); +} + +function groceryDeltaFor(meal, inventory, pantryStaples = []) { + const inv = inventoryMap(inventory); + const pantry = new Set(toArray(pantryStaples).map(normalizeIngredientName).filter(Boolean)); + const usesInventory = []; + const pantryCovered = []; + const missing = []; + for (const ingredient of mealIngredients(meal)) { + if (inv.has(ingredient.normalizedName)) { + usesInventory.push(ingredient.normalizedName); + } else if (pantry.has(ingredient.normalizedName)) { + pantryCovered.push(ingredient.normalizedName); + } else { + missing.push(ingredient.normalizedName); + } + } + return { + newItems: new Set(missing).size, + usesInventory: [...new Set(usesInventory)], + pantryCovered: [...new Set(pantryCovered)], + missingItems: [...new Set(missing)], + }; +} + +function hardBlocked(meal, preferences) { + const allTargets = new Set([ + String(meal.name || meal.title || '').toLowerCase(), + ...mealIngredients(meal).map((i) => i.normalizedName), + ...tagsFor(meal), + ]); + for (const pref of toArray(preferences)) { + if ((pref.type || pref.preference) !== 'allergy') continue; + const target = normalizeIngredientName(pref.target || pref.name || pref.ingredient || pref.value); + if (target && allTargets.has(target)) return `Blocked: allergy/diet conflict with ${target}.`; + } + return null; +} + +function preferenceEffect(meal, preferences) { + let score = 0; + const reasons = []; + const warnings = []; + const allTargets = new Set([ + String(meal.name || meal.title || '').toLowerCase(), + ...mealIngredients(meal).map((i) => i.normalizedName), + ...tagsFor(meal), + ]); + for (const pref of toArray(preferences)) { + const type = pref.type || pref.preference; + const rawTarget = pref.target || pref.name || pref.ingredient || pref.value; + const target = normalizeIngredientName(rawTarget) || String(rawTarget || '').toLowerCase(); + if (!target || !allTargets.has(target)) continue; + const strength = pref.strength === 'high' ? 3 : pref.strength === 'low' ? 1 : 2; + if (type === 'like' || type === 'favorite') { score += strength * 2; reasons.push(`Family signal likes ${target}.`); } + if (type === 'dislike') { score -= strength * 3; warnings.push(`Preference risk: ${target} is disliked.`); } + } + return { score, reasons, warnings }; +} + +function recencyPenalty(meal, recentMeals) { + const title = String(meal.name || meal.title || '').toLowerCase().trim(); + const category = String(meal.meal_category || '').toLowerCase(); + let penalty = 0; + for (const recent of toArray(recentMeals)) { + const recentTitle = String(recent.name || recent.title || '').toLowerCase().trim(); + if (title && recentTitle === title) penalty -= 8; + if (category && category === String(recent.meal_category || '').toLowerCase()) penalty -= 2; + } + return penalty; +} + +function dayLabels(dayContext = {}) { + return new Set([ + ...toArray(dayContext.labels), + dayContext.busyness === 'high' ? 'busy_evening' : null, + dayContext.energy === 'low' ? 'low_energy' : null, + dayContext.guests ? 'guests' : null, + dayContext.soloParent ? 'solo_parent' : null, + ].filter(Boolean)); +} + +export function scoreMealSuggestions({ meals = [], dayContext = {}, preferences = [], inventory = [], recentMeals = [], pantryStaples = [], today } = {}) { + const labels = dayLabels(dayContext); + const inv = inventoryMap(inventory); + return toArray(meals).map((meal) => { + const blocked = hardBlocked(meal, preferences); + const mealName = meal.name || meal.title || 'Untitled meal'; + const tags = tagsFor(meal); + const ingredients = mealIngredients(meal); + const groceryDelta = groceryDeltaFor(meal, inventory, pantryStaples); + const reasons = []; + const warnings = []; + let score = 50; + + if (blocked) { + return { mealId: meal.id, mealName, score: -9999, fitLabels: ['blocked'], reasons: [], warnings: [blocked], groceryDelta }; + } + + const effort = meal.effort || (tags.has('quick') ? 'easy' : 'normal'); + const cleanup = meal.cleanup || 'medium'; + const interruptionTolerance = meal.interruptionTolerance || meal.interruption_tolerance || (tags.has('leftovers') ? 'high' : 'medium'); + const activeMinutes = Number(meal.activeMinutes ?? meal.active_minutes ?? meal.active_time_minutes ?? 30); + const totalMinutes = Number(meal.totalMinutes ?? meal.total_minutes ?? 45); + + score += EFFORT_SCORE[effort] ?? 0; + score += CLEANUP_SCORE[cleanup] ?? 0; + score += INTERRUPTION_SCORE[interruptionTolerance] ?? 0; + score += KID_SCORE[meal.kidFit || meal.kid_fit] ?? 0; + + if (labels.has('busy_evening') || labels.has('late_activity') || dayContext.dinnerWindowMinutes <= 30) { + if (activeMinutes <= 15) { score += 10; reasons.push(`Fits a tight day: ${activeMinutes} min active time.`); } + else if (activeMinutes > 30) { score -= 12; warnings.push(`Busy-day mismatch: ${activeMinutes} min active time.`); } + if (cleanup === 'low') { score += 5; reasons.push('Low cleanup helps on a busy evening.'); } + if (cleanup === 'high') { score -= 8; warnings.push('High cleanup on a busy evening.'); } + if (interruptionTolerance === 'high') { score += 5; reasons.push('Can survive interruptions.'); } + if (interruptionTolerance === 'low') { score -= 8; warnings.push('Needs continuous attention on an interruption-heavy day.'); } + } + + if (labels.has('low_energy') || labels.has('solo_parent')) { + if (effort === 'survival' || effort === 'easy') { score += 8; reasons.push(`Low-energy fit: ${effort} effort.`); } + if (effort === 'project') { score -= 18; warnings.push('Project meal is a bad fit for low parent energy.'); } + } + + if (labels.has('guests')) { + if (meal.guestFit || meal.guest_fit || meal.batchFriendly || meal.batch_friendly) { score += 35; reasons.push('Guest-capable / batch-friendly.'); } + else { score -= 15; warnings.push('Not marked as guest-capable.'); } + } + + if (dayContext.weather === 'hot' && tags.has('oven')) { score -= 4; warnings.push('Oven-heavy meal on a hot day.'); } + if ((dayContext.weather === 'cold' || dayContext.weather === 'rainy') && (tags.has('soup') || tags.has('cozy'))) { score += 3; reasons.push('Season/weather tie-breaker fits.'); } + + for (const ingredient of ingredients) { + const available = inv.get(ingredient.normalizedName); + if (!available) continue; + score += 3; + const due = daysUntil(available.expiresOn, today); + if (due !== null && due <= 1) { + score += 8; + reasons.push(`Uses ${ingredient.normalizedName} before it expires.`); + } else { + reasons.push(`Uses available ${ingredient.normalizedName}.`); + } + } + + const pref = preferenceEffect(meal, preferences); + score += pref.score; + reasons.push(...pref.reasons); + warnings.push(...pref.warnings); + + const repetition = recencyPenalty(meal, recentMeals); + if (repetition < 0) { + score += repetition; + warnings.push('Recently eaten / similar category, so it is demoted for variety.'); + } + + if (groceryDelta.newItems <= 2) { score += 7; reasons.push(`Small grocery delta: ${groceryDelta.newItems} new items.`); } + else if (groceryDelta.newItems >= 8) { score -= 8; warnings.push(`Heavy grocery burden: ${groceryDelta.newItems} new items.`); } + + const fitLabels = []; + if (activeMinutes <= 15) fitLabels.push('quick-active-time'); + if (cleanup === 'low') fitLabels.push('low-cleanup'); + if (interruptionTolerance === 'high') fitLabels.push('interruption-friendly'); + if (groceryDelta.usesInventory.length) fitLabels.push('uses-inventory'); + if (groceryDelta.newItems <= 2) fitLabels.push('low-grocery-delta'); + if (meal.guestFit || meal.guest_fit) fitLabels.push('guest-capable'); + + return { + mealId: meal.id, + mealName, + score: Math.round(score), + fitLabels, + reasons: [...new Set(reasons)].slice(0, 5), + warnings: [...new Set(warnings)].slice(0, 5), + groceryDelta, + totalMinutes, + activeMinutes, + }; + }).sort((a, b) => b.score - a.score || String(a.mealName).localeCompare(String(b.mealName))); +} + +export function generateGroceryList(selectedMeals = [], { inventory = [], pantryStaples = [] } = {}) { + const inv = inventoryMap(inventory); + const pantry = new Set(toArray(pantryStaples).map(normalizeIngredientName).filter(Boolean)); + const items = new Map(); + const coveredByInventory = []; + for (const meal of toArray(selectedMeals)) { + for (const ingredient of mealIngredients(meal)) { + const normalized = ingredient.normalizedName; + if (!normalized) continue; + if (inv.has(normalized)) { + coveredByInventory.push({ name: normalized, sourceMealId: meal.id, sourceMealName: meal.name || meal.title }); + continue; + } + if (pantry.has(normalized)) continue; + const existing = items.get(normalized) || { name: normalized, quantity: ingredient.quantity || null, category: ingredient.category || null, sourceMeals: [] }; + existing.sourceMeals.push({ id: meal.id, name: meal.name || meal.title }); + items.set(normalized, existing); + } + } + return { + items: [...items.values()].sort((a, b) => a.name.localeCompare(b.name)), + coveredByInventory, + }; +} diff --git a/test-meal-fit.js b/test-meal-fit.js new file mode 100644 index 0000000..747b0ec --- /dev/null +++ b/test-meal-fit.js @@ -0,0 +1,155 @@ +/** + * 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);