Phases 6-7
This commit is contained in:
parent
76a516a417
commit
029940b079
111 changed files with 17247 additions and 447 deletions
|
|
@ -0,0 +1,279 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue