import { describe, it, expect } from 'vitest'; import { NutritionCalculatorService } from '../../../src/modules/recipes/nutrition-calculator.service.js'; import { NutritionWarning } from '@meshitrack/shared'; const service = new NutritionCalculatorService(); function makeProduct( overrides: Partial<{ servingSize: number; servingUnit: string; nutrition: Record; }> = {}, ) { return { servingSize: overrides.servingSize ?? 100, servingUnit: overrides.servingUnit ?? 'g', nutrition: { calories: 200, protein: 20, carbs: 10, fat: 8, ...(overrides.nutrition ?? {}), }, }; } describe(NutritionCalculatorService.name, () => { describe('calculateRecipeNutrition', () => { it('calculates total and per-serving nutrition from one ingredient', () => { const product = makeProduct(); // 200 kcal per 100g const productMap = new Map([['p1', product]]); const result = service.calculateRecipeNutrition( [{ productId: 'p1', quantity: 200 }], // 200g = 2 servings worth productMap, 2, // 2 servings ); expect(result.totalNutrition.calories).toBe(400); expect(result.perServingNutrition.calories).toBe(200); expect(result.totalNutrition.protein).toBe(40); expect(result.perServingNutrition.protein).toBe(20); }); it('sums contributions from multiple ingredients', () => { const p1 = makeProduct({ nutrition: { calories: 100, protein: 10, carbs: 5, fat: 4 } }); const p2 = makeProduct({ nutrition: { calories: 200, protein: 20, carbs: 10, fat: 8 } }); const productMap = new Map([ ['p1', p1], ['p2', p2], ]); const result = service.calculateRecipeNutrition( [ { productId: 'p1', quantity: 100 }, // 1× serving { productId: 'p2', quantity: 100 }, // 1× serving ], productMap, 1, ); expect(result.totalNutrition.calories).toBe(300); expect(result.perServingNutrition.calories).toBe(300); }); it('uses zero nutrition for unknown product', () => { const productMap = new Map>(); const result = service.calculateRecipeNutrition( [{ productId: 'missing', quantity: 100 }], productMap, 1, ); expect(result.totalNutrition.calories).toBe(0); }); it('propagates optional nutrients (sodium, fiber, sugar)', () => { const product = makeProduct({ nutrition: { calories: 100, protein: 5, carbs: 10, fat: 2, sodium: 800, fiber: 4, sugar: 12, }, }); const productMap = new Map([['p1', product]]); const result = service.calculateRecipeNutrition( [{ productId: 'p1', quantity: 100 }], productMap, 1, ); expect(result.totalNutrition.sodium).toBe(800); expect(result.totalNutrition.fiber).toBe(4); expect(result.totalNutrition.sugar).toBe(12); }); it('propagates saturatedFat and cholesterol across multiple ingredients', () => { const p1 = makeProduct({ nutrition: { calories: 100, protein: 5, carbs: 10, fat: 3, saturatedFat: 1.5, cholesterol: 30, }, }); const p2 = makeProduct({ nutrition: { calories: 150, protein: 8, carbs: 12, fat: 5, saturatedFat: 2.5, cholesterol: 50, }, }); const productMap = new Map([ ['p1', p1], ['p2', p2], ]); const result = service.calculateRecipeNutrition( [ { productId: 'p1', quantity: 100 }, { productId: 'p2', quantity: 100 }, ], productMap, 1, ); expect(result.totalNutrition.saturatedFat).toBe(4); expect(result.totalNutrition.cholesterol).toBe(80); }); it('handles product with zero servingSize', () => { const product = makeProduct({ servingSize: 0 }); const productMap = new Map([['p1', product]]); const result = service.calculateRecipeNutrition( [{ productId: 'p1', quantity: 100 }], productMap, 1, ); // ratio = 0 when servingSize = 0 expect(result.totalNutrition.calories).toBe(0); }); it('handles zero servings', () => { const product = makeProduct(); const productMap = new Map([['p1', product]]); const result = service.calculateRecipeNutrition( [{ productId: 'p1', quantity: 100 }], productMap, 0, ); expect(result.perServingNutrition.calories).toBe(0); }); it('multiplies saturatedFat and cholesterol by ratio', () => { const product = makeProduct({ nutrition: { calories: 100, protein: 5, carbs: 10, fat: 3, saturatedFat: 2, cholesterol: 40, }, }); const productMap = new Map([['p1', product]]); const result = service.calculateRecipeNutrition( [{ productId: 'p1', quantity: 200 }], // 2x serving productMap, 2, ); // 2x ratio, then divide by 2 servings = same as per serving expect(result.totalNutrition.saturatedFat).toBe(4); expect(result.totalNutrition.cholesterol).toBe(80); expect(result.perServingNutrition.saturatedFat).toBe(2); expect(result.perServingNutrition.cholesterol).toBe(40); }); }); describe('generateWarnings', () => { it('flags HIGH_CALORIES when > 800 kcal/serving', () => { const warnings = service.generateWarnings({ calories: 900, protein: 20, carbs: 50, fat: 30, }); expect(warnings).toContain(NutritionWarning.HIGH_CALORIES); }); it('flags HIGH_SODIUM when > 1500 mg/serving', () => { const warnings = service.generateWarnings({ calories: 400, protein: 15, carbs: 30, fat: 10, sodium: 1600, }); expect(warnings).toContain(NutritionWarning.HIGH_SODIUM); }); it('flags LOW_PROTEIN when < 10 g/serving', () => { const warnings = service.generateWarnings({ calories: 300, protein: 5, carbs: 40, fat: 10, }); expect(warnings).toContain(NutritionWarning.LOW_PROTEIN); }); it('flags LOW_FIBER when fiber is present and < 3 g/serving', () => { const warnings = service.generateWarnings({ calories: 300, protein: 15, carbs: 40, fat: 10, fiber: 1, }); expect(warnings).toContain(NutritionWarning.LOW_FIBER); }); it('does not flag LOW_FIBER when fiber is absent', () => { const warnings = service.generateWarnings({ calories: 300, protein: 15, carbs: 40, fat: 10, }); expect(warnings).not.toContain(NutritionWarning.LOW_FIBER); }); it('returns no warnings for a healthy meal', () => { const warnings = service.generateWarnings({ calories: 450, protein: 30, carbs: 40, fat: 12, sodium: 600, fiber: 8, sugar: 10, saturatedFat: 4, cholesterol: 80, }); expect(warnings).toHaveLength(0); }); it('can return multiple warnings', () => { const warnings = service.generateWarnings({ calories: 900, protein: 5, carbs: 80, fat: 40, sodium: 2000, sugar: 30, saturatedFat: 20, cholesterol: 250, fiber: 1, }); expect(warnings.length).toBeGreaterThan(1); }); }); });