279 lines
7.6 KiB
TypeScript
279 lines
7.6 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|