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);
|
|
|
|
|
});
|
2026-05-19 10:13:40 +09:00
|
|
|
|
|
|
|
|
it('triggers reasoning branches for partial coverage and urgent items', async () => {
|
|
|
|
|
const recipeC = {
|
|
|
|
|
_id: 'recipeC',
|
|
|
|
|
name: 'Recipe C',
|
|
|
|
|
ingredients: [
|
|
|
|
|
{ productId: 'prod1', quantity: 10, isOptional: false },
|
|
|
|
|
{ productId: 'prod2', quantity: 10, isOptional: false },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeC] });
|
|
|
|
|
|
|
|
|
|
// 1. Coverage: (10/10 + 5/10)/2 = 0.75 (hits >0.5)
|
|
|
|
|
// 2. Urgency: both set to urgent = 1.0 (hits >0.7)
|
|
|
|
|
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
|
|
|
|
{ productId: 'prod1', quantity: 10, freshnessEstimate: { urgency: 'urgent' } },
|
|
|
|
|
{ productId: 'prod2', quantity: 5, freshnessEstimate: { urgency: 'urgent' } },
|
|
|
|
|
]);
|
|
|
|
|
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
|
|
|
|
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
|
|
|
|
|
|
|
|
|
|
const suggestions = await service.getSuggestions('hh1', 'user1');
|
|
|
|
|
expect(suggestions[0]!.scores.coverage).toBe(0.75);
|
|
|
|
|
expect(suggestions[0]!.scores.urgency).toBe(1);
|
|
|
|
|
expect(suggestions[0]!.reasoning).toContain('Uses several ingredients already stocked in your pantry.');
|
|
|
|
|
expect(suggestions[0]!.reasoning).toContain('High priority: Saves expiring pantry items from going to waste!');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('triggers reasoning for moderately soon-to-expire items', async () => {
|
|
|
|
|
const recipeD = {
|
|
|
|
|
_id: 'recipeD',
|
|
|
|
|
name: 'Recipe D',
|
|
|
|
|
ingredients: [{ productId: 'prod1', quantity: 5, isOptional: false }],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeD] });
|
|
|
|
|
|
|
|
|
|
// Urgency soon/expiringSoon has weight 0.7 (hits >0.4 branch)
|
|
|
|
|
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
|
|
|
|
{ productId: 'prod1', quantity: 5, freshnessEstimate: { urgency: 'expiringSoon' } },
|
|
|
|
|
]);
|
|
|
|
|
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
|
|
|
|
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
|
|
|
|
|
|
|
|
|
|
const suggestions = await service.getSuggestions('hh1', 'user1');
|
|
|
|
|
expect(suggestions[0]!.scores.urgency).toBe(0.7);
|
|
|
|
|
expect(suggestions[0]!.reasoning).toContain('Helps use up items that should be consumed soon.');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('aggregates duplicate pantry items and handles normal/default urgencies', async () => {
|
|
|
|
|
const recipeE = {
|
|
|
|
|
_id: 'recipeE',
|
|
|
|
|
name: 'Recipe E',
|
|
|
|
|
ingredients: [
|
|
|
|
|
{ productId: 'prod1', quantity: 5, isOptional: false },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeE] });
|
|
|
|
|
|
|
|
|
|
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
|
|
|
|
{ productId: 'prod1', quantity: 2, freshnessEstimate: { daysRemaining: 5, urgency: 'normal' } },
|
|
|
|
|
{ productId: 'prod1', quantity: 3, freshnessEstimate: { daysRemaining: 10, urgency: 'unknown-type' } },
|
|
|
|
|
]);
|
|
|
|
|
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
|
|
|
|
mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] });
|
|
|
|
|
|
|
|
|
|
const suggestions = await service.getSuggestions('hh1', 'user1');
|
|
|
|
|
expect(suggestions[0]!.scores.coverage).toBe(1);
|
|
|
|
|
expect(suggestions[0]!.scores.urgency).toBe(0.3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('covers boundary logic for nameless recipes, custom meals, default targets and private weights', async () => {
|
|
|
|
|
// 1. Nameless recipe and recipe without ingredients
|
|
|
|
|
const rawRecipe = { _id: 'recipeMissingProps' };
|
|
|
|
|
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [rawRecipe] });
|
|
|
|
|
|
|
|
|
|
// 2. Active target with partial/falsy info
|
|
|
|
|
mockNutritionRepo.findByUser.mockResolvedValue({ dailyCalories: 0, proteinG: 0 });
|
|
|
|
|
|
|
|
|
|
// 3. Last eaten containing a custom meal without recipeId (should continue)
|
|
|
|
|
mockMealPlanRepo.findByHousehold.mockResolvedValue({
|
|
|
|
|
data: [
|
|
|
|
|
{
|
|
|
|
|
days: [
|
|
|
|
|
{
|
|
|
|
|
date: '2026-05-19',
|
|
|
|
|
meals: [
|
|
|
|
|
{ customName: 'Snack' }, // no recipeId!
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
|
|
|
|
|
|
|
|
|
const suggestions = await service.getSuggestions('hh1', 'user1');
|
|
|
|
|
|
|
|
|
|
expect(suggestions).toHaveLength(1);
|
|
|
|
|
|
|
|
|
|
// 4. Direct call to getUrgencyWeight default branch
|
|
|
|
|
const defaultWeight = (service as any).getUrgencyWeight('mystery-status');
|
|
|
|
|
expect(defaultWeight).toBe(0);
|
|
|
|
|
});
|
2026-05-14 18:38:50 +09:00
|
|
|
});
|
|
|
|
|
});
|