MeshiTrack/packages/api/src/modules/meal-plans/suggestion-engine.service.test.ts

166 lines
5.8 KiB
TypeScript
Raw Normal View History

2026-05-14 18:38:50 +09:00
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SuggestionEngineService } from './suggestion-engine.service.js';
import type { RecipesRepository } from '../recipes/recipes.repository.js';
import type { PantryRepository } from '../pantry/pantry.repository.js';
import type { MealPlanRepository } from './meal-plans.repository.js';
import type { NutritionTargetRepository } from '../nutrition-targets/nutrition-target.repository.js';
describe(SuggestionEngineService.name, () => {
let service: SuggestionEngineService;
let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType<typeof vi.fn> };
let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType<typeof vi.fn> };
let mockMealPlanRepo: { [K in keyof MealPlanRepository]: ReturnType<typeof vi.fn> };
let mockNutritionRepo: { [K in keyof NutritionTargetRepository]: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-20T00:00:00Z'));
mockRecipesRepo = {
findByHousehold: vi.fn(),
} as never;
mockPantryRepo = {
findActiveByHousehold: vi.fn(),
} as never;
mockMealPlanRepo = {
findByHousehold: vi.fn(),
} as never;
mockNutritionRepo = {
findByUser: vi.fn(),
} as never;
service = new SuggestionEngineService({
recipesRepository: mockRecipesRepo as unknown as RecipesRepository,
pantryRepository: mockPantryRepo as unknown as PantryRepository,
mealPlanRepository: mockMealPlanRepo as unknown as MealPlanRepository,
nutritionTargetRepository: mockNutritionRepo as unknown as NutritionTargetRepository,
});
});
afterEach(() => {
vi.useRealTimers();
});
describe('getSuggestions', () => {
it('correctly ranks recipes based on inventory coverage and freshness', async () => {
// 1. Set up recipes:
// - Recipe A: Needs Product 1 (2 units) and Product 2 (1 unit)
// - Recipe B: Needs Product 3 (1 unit)
const recipeA = {
_id: 'recipeA',
name: 'Recipe A',
ingredients: [
{ productId: 'prod1', quantity: 2, isOptional: false },
{ productId: 'prod2', quantity: 1, isOptional: false },
],
perServingNutrition: { calories: 400, protein: 30, carbs: 40, fat: 10 }, // balanced
};
const recipeB = {
_id: 'recipeB',
name: 'Recipe B',
ingredients: [
{ productId: 'prod3', quantity: 1, isOptional: false },
],
perServingNutrition: { calories: 600, protein: 10, carbs: 100, fat: 15 }, // high carb
};
mockRecipesRepo.findByHousehold.mockResolvedValue({
data: [recipeA, recipeB],
pagination: { hasMore: false },
});
// 2. Set up Pantry inventory:
// We have Product 1 in abundance (expiringSoon).
// We have Product 2 (fresh).
// Product 3 is NOT in pantry.
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
{
productId: 'prod1',
quantity: 10,
freshnessEstimate: { daysRemaining: 2, urgency: 'expiringSoon' },
},
{
productId: 'prod2',
quantity: 5,
freshnessEstimate: { daysRemaining: 15, urgency: 'fresh' },
},
]);
// 3. Set up standard nutrition target (Maintenance: 30p/40c/30f split)
// Macro split match logic:
// Recipe A: 400cals, 30g Protein(120cals=30%), 40g Carbs(160cals=40%), 10g Fat(90cals=22.5%) -> highly aligned!
mockNutritionRepo.findByUser.mockResolvedValue({
dailyCalories: 2000,
proteinG: 150, // (150 * 4) = 600cals (30%)
carbsG: 200, // (200 * 4) = 800cals (40%)
fatG: 67, // (67 * 9) = 603cals (30%)
});
// 4. Set up recent meal plans (empty history -> 100% Variety for all)
mockMealPlanRepo.findByHousehold.mockResolvedValue({
data: [],
});
// Run suggestion fetch
const suggestions = await service.getSuggestions('hh1', 'user1');
// Assertions:
expect(suggestions.length).toBe(2);
// Recipe A should clearly rank #1 (100% Coverage, using urgent items, highly nutritious match)
const top = suggestions[0]!;
expect(top.recipeId).toBe('recipeA');
expect(top.scores.coverage).toBe(1); // full coverage
// Urgency: (expiringSoon[0.7] + fresh[0.1]) / 2 = 0.4
expect(top.scores.urgency).toBeGreaterThan(0.3);
expect(top.scores.variety).toBe(1); // never eaten
// Recipe B should have 0 coverage and thus lower totalScore
const bottom = suggestions[1]!;
expect(bottom.recipeId).toBe('recipeB');
expect(bottom.scores.coverage).toBe(0);
expect(bottom.totalScore).toBeLessThan(top.totalScore);
});
it('penalizes recipes eaten recently (Variety score)', async () => {
const recipeX = {
_id: 'recipeX',
name: 'Recipe X',
ingredients: [],
perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 2 },
};
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeX] });
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
mockNutritionRepo.findByUser.mockResolvedValue(null);
// Fake history: Recipe X was eaten 7 days ago
const date7DaysAgo = new Date();
date7DaysAgo.setDate(date7DaysAgo.getDate() - 7);
const dateStr = date7DaysAgo.toISOString().split('T')[0];
mockMealPlanRepo.findByHousehold.mockResolvedValue({
data: [
{
days: [
{
date: dateStr,
meals: [{ recipeId: 'recipeX' }],
},
],
},
],
});
const suggestions = await service.getSuggestions('hh1', 'user1');
// Variety calculation: 7 days ago / 14 days = 0.5
expect(suggestions[0]!.scores.variety).toBeCloseTo(0.5, 1);
});
});
});