import { describe, expect, it } from "vitest"; import type { NutritionDeclaration } from "@app/shared-types"; import { bmrMifflinStJeor, computeDailyTargets, computeItemNutrition, computeRecipeNutrition, convert, scaleNutrition, summarizeDay, sumNutrition, toGrams, } from "../src/index.js"; const chicken: NutritionDeclaration = { basis: "per_100_g", values: { kcal: 106, proteinG: 22, carbsG: 0, fatG: 2, saturatedFatG: 0.6, fiberG: 0, sugarG: 0, saltG: 0.2, }, }; const milk: NutritionDeclaration = { basis: "per_100_ml", values: { kcal: 60, proteinG: 3.4, carbsG: 4.7, fatG: 3, saturatedFatG: 1.9, fiberG: 0, sugarG: 4.7, saltG: 0.1, }, }; describe("enhetskonvertering", () => { it("konverterar massa", () => { expect(toGrams(1, "KILOGRAM")).toBe(1000); expect(toGrams(250, "GRAM")).toBe(250); }); it("konverterar volym via densitet", () => { expect(toGrams(1, "LITER", { densityGPerMl: 1.03 })).toBeCloseTo(1030); expect(toGrams(1, "DECILITER", { densityGPerMl: 1 })).toBe(100); expect(toGrams(1, "TABLESPOON", { densityGPerMl: 0.92 })).toBeCloseTo(13.8); }); it("konverterar styck via styckvikt", () => { expect(toGrams(2, "COUNT", { gramsPerPiece: 58 })).toBe(116); }); it("vägrar gissa: null utan densitet/styckvikt (spec §61.4)", () => { expect(toGrams(1, "DECILITER")).toBeNull(); expect(toGrams(1, "COUNT")).toBeNull(); expect(convert(1, "DECILITER", "GRAM")).toBeNull(); }); }); describe("näringsberäkning", () => { it("räknar per 100 g", () => { const result = computeItemNutrition(300, "GRAM", chicken); expect(result?.kcal).toBeCloseTo(318); expect(result?.proteinG).toBeCloseTo(66); }); it("räknar per 100 ml med volymenhet", () => { const result = computeItemNutrition(2, "DECILITER", milk, { densityGPerMl: 1.03 }); expect(result?.kcal).toBeCloseTo(120); }); it("returnerar null när data saknas – aldrig påhitt", () => { expect(computeItemNutrition(1, "COUNT", chicken)).toBeNull(); }); it("summerar och skalar", () => { const a = computeItemNutrition(100, "GRAM", chicken)!; const total = sumNutrition([a, a]); expect(total.kcal).toBeCloseTo(212); expect(scaleNutrition(total, 0.5).kcal).toBeCloseTo(106); }); }); describe("receptnäring", () => { it("beräknar per portion och redovisar oberäkneliga", () => { const sources = new Map([ ["chicken", { nutritionPer100: chicken }], ["milk", { nutritionPer100: milk, densityGPerMl: 1.03 }], ]); const result = computeRecipeNutrition( [ { canonicalIngredientId: "chicken", quantity: 400, unit: "GRAM" }, { canonicalIngredientId: "milk", quantity: 2, unit: "DECILITER" }, { canonicalIngredientId: "unknown", quantity: 1, unit: "COUNT" }, ], 4, sources, ); expect(result.uncomputableIngredientIds).toEqual(["unknown"]); expect(result.perPortion.kcal).toBeGreaterThan(100); expect(result.perPortion.kcal * 4).toBeCloseTo(result.total.kcal, 0); }); it("exkluderar valfria ingredienser", () => { const sources = new Map([["chicken", { nutritionPer100: chicken }]]); const withOptional = computeRecipeNutrition( [ { canonicalIngredientId: "chicken", quantity: 400, unit: "GRAM" }, { canonicalIngredientId: "chicken", quantity: 400, unit: "GRAM", optional: true }, ], 4, sources, ); expect(withOptional.total.kcal).toBeCloseTo(424); }); }); describe("energi & dagsmål", () => { it("Mifflin–St Jeor referensvärde (man 80 kg, 180 cm, 30 år)", () => { const bmr = bmrMifflinStJeor({ sex: "male", weightKg: 80, heightCm: 180, age: 30 }); expect(bmr).toBeCloseTo(10 * 80 + 6.25 * 180 - 5 * 30 + 5); // 1780 }); it("viktnedgång ger underskott men aldrig under golvet", () => { const normal = computeDailyTargets({ sex: "female", age: 35, heightCm: 165, weightKg: 60, activityLevel: "moderate", primaryGoal: "lose_weight", }); expect(normal.targets.kcal).toBeLessThan(normal.basis.tdeeKcal); const extreme = computeDailyTargets({ sex: "female", age: 80, heightCm: 145, weightKg: 40, activityLevel: "sedentary", primaryGoal: "lose_weight", }); expect(extreme.targets.kcal).toBeGreaterThanOrEqual(1200); }); it("proteinmål skalar med kroppsvikt och mål", () => { const base = computeDailyTargets({ sex: "male", age: 30, heightCm: 180, weightKg: 80, activityLevel: "moderate", }); const muscle = computeDailyTargets({ sex: "male", age: 30, heightCm: 180, weightKg: 80, activityLevel: "moderate", primaryGoal: "build_muscle", }); expect(muscle.targets.proteinG).toBeGreaterThan(base.targets.proteinG); expect(muscle.targets.proteinG).toBe(Math.round(80 * 1.8)); }); }); describe("dagsöversikt", () => { it("summerar måltider och flaggar salt", () => { const meal = { kcal: 700, proteinG: 40, carbsG: 60, fatG: 25, saturatedFatG: 8, fiberG: 6, sugarG: 5, saltG: 4, }; const summary = summarizeDay([meal, meal], { kcal: 2000, proteinG: 100, carbsG: 220, fatG: 65, fiberG: 25, saltMaxG: 6, }); expect(summary.consumed.kcal).toBe(1400); expect(summary.remaining.kcal).toBe(600); expect(summary.saltWarning).toBe(true); expect(summary.progress.proteinG).toBeCloseTo(0.8); }); }); describe("i18n-enheter (i18n-spec §9–§11)", () => { it("amerikanska volymer konverterar enligt dokumenterad standard", () => { expect(toGrams(1, "CUP_US", { densityGPerMl: 1 })).toBeCloseTo(236.59); expect(toGrams(2, "FLUID_OUNCE_US", { densityGPerMl: 1 })).toBeCloseTo(59.14); expect(toGrams(1, "TEASPOON", { densityGPerMl: 1 })).toBe(5); expect(toGrams(1, "TABLESPOON", { densityGPerMl: 1 })).toBe(15); expect(toGrams(2, "PINCH", { densityGPerMl: 1 })).toBe(1); }); it("amerikanska massor är massa – ingen densitet behövs", () => { expect(toGrams(1, "OUNCE")).toBeCloseTo(28.35); expect(toGrams(1, "POUND")).toBeCloseTo(453.59); expect(convert(1, "POUND", "OUNCE")).toBeCloseTo(16, 1); }); it("DECILITER och cup↔dl-konvertering utan förlust", () => { expect(convert(1, "CUP_US", "DECILITER")).toBeCloseTo(2.3659); expect(convert(2.3659, "DECILITER", "CUP_US")).toBeCloseTo(1, 4); }); it("volym↔massa vägrar fortfarande utan ingrediensdensitet (i18n-spec §10)", () => { expect(toGrams(1, "CUP_US")).toBeNull(); expect(convert(100, "GRAM", "CUP_US")).toBeNull(); }); it("count-enheter (CLOVE, CAN …) kräver styckvikt", () => { expect(toGrams(2, "CLOVE", { gramsPerPiece: 5 })).toBe(10); expect(toGrams(1, "CAN")).toBeNull(); }); });