215 lines
6.8 KiB
TypeScript
215 lines
6.8 KiB
TypeScript
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();
|
||
});
|
||
});
|