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

279 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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);
});
});
});