import { describe, expect, it } from "vitest"; import { applySubstitution, checkRecipeSafety, computeCoverage, deriveRecipeAllergens, estimateCost, isRecipeSafe, scaleIngredients, type IngredientSafetyInfo, } from "../src/index.js"; const INFO = new Map([ [ "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); }); });