MeshiTrack/packages/api/src/modules/recipes/nutrition-calculator.service.test.ts

280 lines
7.6 KiB
TypeScript
Raw Normal View History

2026-05-14 14:47:23 +09:00
import { describe, it, expect } from 'vitest';
import { NutritionCalculatorService } from './nutrition-calculator.service.js';
import { NutritionWarning } from '@meshitrack/shared';
const service = new NutritionCalculatorService();
function makeProduct(
overrides: Partial<{
servingSize: number;
servingUnit: string;
nutrition: Record<string, number>;
}> = {},
) {
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<string, ReturnType<typeof makeProduct>>();
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);
});
});
});