166 lines
5.8 KiB
TypeScript
166 lines
5.8 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|