Files
2026-08-05 19:21:11 +07:00

300 lines
8.4 KiB
TypeScript
Raw Permalink 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, expect, it } from "vitest";
import {
applySubstitution,
checkRecipeSafety,
computeCoverage,
deriveRecipeAllergens,
estimateCost,
isRecipeSafe,
scaleIngredients,
type IngredientSafetyInfo,
} from "../src/index.js";
const INFO = new Map<string, IngredientSafetyInfo>([
[
"chicken",
{
id: "chicken",
allergens: [],
isVegan: false,
isVegetarian: false,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
[
"milk",
{
id: "milk",
allergens: ["milk"],
isVegan: false,
isVegetarian: true,
containsGluten: false,
containsLactose: true,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
[
"pasta",
{
id: "pasta",
allergens: ["gluten"],
mayContainAllergens: ["eggs"],
isVegan: true,
isVegetarian: true,
containsGluten: true,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
[
"bacon",
{
id: "bacon",
allergens: [],
isVegan: false,
isVegetarian: false,
containsGluten: false,
containsLactose: false,
isPork: true,
isBeef: false,
isAlcohol: false,
},
],
[
"tofu",
{
id: "tofu",
allergens: ["soy"],
isVegan: true,
isVegetarian: true,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
]);
describe("KRITISKT: deterministisk allergisäkerhet (spec §61.2)", () => {
it("blockerar recept med användarens allergen", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "milk", optional: false }], spiceLevel: 0 },
{ allergens: ["milk"] },
INFO,
);
expect(isRecipeSafe(violations)).toBe(false);
expect(violations[0]?.code).toBe("allergen");
expect(violations[0]?.severity).toBe("blocker");
});
it("'kan innehålla spår' ger varning när användaren har allergi", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "pasta", optional: false }], spiceLevel: 0 },
{ allergens: ["eggs"] },
INFO,
);
expect(violations.some((v) => v.code === "allergen_may_contain")).toBe(true);
});
it("okänd ingrediens ger varning aldrig tyst OK (försiktighetsprincipen)", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "mystery", optional: false }], spiceLevel: 0 },
{ allergens: [] },
INFO,
);
expect(violations.some((v) => v.code === "unverified_data")).toBe(true);
});
it("valfri ingrediens med allergen → varning, inte blocker", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "milk", optional: true }], spiceLevel: 0 },
{ allergens: ["milk"] },
INFO,
);
expect(isRecipeSafe(violations)).toBe(true);
expect(violations[0]?.severity).toBe("warning");
});
it("vegan blockerar kött och mjölk", () => {
const violations = checkRecipeSafety(
{
ingredients: [
{ canonicalIngredientId: "chicken", optional: false },
{ canonicalIngredientId: "milk", optional: false },
],
spiceLevel: 0,
},
{ allergens: [], dietPattern: "vegan" },
INFO,
);
expect(violations.filter((v) => v.code === "diet_pattern")).toHaveLength(2);
});
it("halal blockerar fläsk", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "bacon", optional: false }], spiceLevel: 0 },
{ allergens: [], religiousRule: "halal" },
INFO,
);
expect(isRecipeSafe(violations)).toBe(false);
});
it("härleder receptallergener ur ingredienserna", () => {
expect(deriveRecipeAllergens(["pasta", "milk", "chicken"], INFO)).toEqual(["gluten", "milk"]);
});
});
describe("lagermatchning (spec §17)", () => {
it("beräknar täckning, saknat och utgående", () => {
const result = computeCoverage(
[
{
canonicalIngredientId: "chicken",
displayNameSv: "Kyckling",
quantity: 500,
unit: "GRAM",
optional: false,
},
{
canonicalIngredientId: "pasta",
displayNameSv: "Pasta",
quantity: 300,
unit: "GRAM",
optional: false,
},
],
[
{
id: "1",
canonicalIngredientId: "chicken",
quantity: 600,
unit: "GRAM",
bestBeforeDate: "2026-08-03",
},
],
new Map(),
new Date("2026-08-02T00:00:00Z"), // UTC testet ska vara sant i alla tidszoner
);
expect(result.coverage).toBe(0.5);
expect(result.missing.map((m) => m.canonicalIngredientId)).toEqual(["pasta"]);
expect(result.expiringUsed[0]?.canonicalIngredientId).toBe("chicken");
});
it("mjölkprincipen: passerat bäst före räknas som tillgängligt, inte som borta", () => {
// Mjölken som gick ut igår är inte automatiskt dålig recepten ska
// fortsätta föreslå den (användaren luktar/smakar) i stället för att
// tyst behandla varan som obefintlig och driva nyinköp/svinn.
const result = computeCoverage(
[
{
canonicalIngredientId: "milk",
displayNameSv: "Mjölk",
quantity: 500,
unit: "MILLILITER",
optional: false,
},
],
[
{
id: "1",
canonicalIngredientId: "milk",
quantity: 1000,
unit: "MILLILITER",
bestBeforeDate: "2026-08-01", // gick ut igår
},
],
new Map(),
new Date("2026-08-02T00:00:00Z"), // UTC testet ska vara sant i alla tidszoner
);
expect(result.coverage).toBe(1);
expect(result.missing).toHaveLength(0);
expect(result.expiringUsed[0]?.canonicalIngredientId).toBe("milk"); // prioriteras "räddar mjölken"
});
});
describe("skalning", () => {
it("skalar mängder och avrundar köksvänligt", () => {
const scaled = scaleIngredients(
[
{
canonicalIngredientId: "chicken",
displayNameSv: "Kyckling",
quantity: 500,
unit: "GRAM",
optional: false,
},
{
canonicalIngredientId: "salt",
displayNameSv: "Salt",
quantity: 1,
unit: "TEASPOON",
optional: false,
},
],
4,
6,
);
expect(scaled[0]?.quantity).toBe(750);
expect(scaled[1]?.quantity).toBe(1.5);
});
it("kastar på ogiltiga portioner", () => {
expect(() => scaleIngredients([], 0, 4)).toThrow();
});
});
describe("substitution (spec §20)", () => {
it("byter ingrediens med mängdfaktor och varnar för fel kontext", () => {
const result = applySubstitution(
[
{
canonicalIngredientId: "milk",
displayNameSv: "Mjölk",
quantity: 200,
unit: "MILLILITER",
optional: false,
},
],
{
fromIngredientId: "milk",
toIngredientId: "tofu",
ratio: 0.8,
instructionsSv: "Tillsätt sist.",
notRecommendedFor: ["whipping"],
},
"Tofu",
"whipping",
);
expect(result.ingredients[0]?.canonicalIngredientId).toBe("tofu");
expect(result.ingredients[0]?.quantity).toBe(160);
expect(result.applied.warningSv).toContain("rekommenderas inte");
});
it("kastar när ingrediensen inte finns i receptet", () => {
expect(() =>
applySubstitution(
[],
{ fromIngredientId: "x", toIngredientId: "y", ratio: 1, notRecommendedFor: [] },
"Y",
),
).toThrow();
});
});
describe("kostnadsuppskattning (spec §26)", () => {
it("beräknar pris per portion med täckningsgrad", () => {
const result = estimateCost(
[
{ canonicalIngredientId: "chicken", quantity: 500, unit: "GRAM", optional: false },
{ canonicalIngredientId: "unknown", quantity: 100, unit: "GRAM", optional: false },
],
4,
// 130 kr/kg = 13000 minor/kg; 500 g -> 6500 minor totalt, 1625 minor/portion.
new Map([["chicken", { pricePerKgMinor: 13_000, source: "default" as const }]]),
);
expect(result.totalMinor).toBe(6500);
expect(result.perPortionMinor).toBe(1625);
expect(result.priceCoverage).toBe(0.5);
});
});