300 lines
8.4 KiB
TypeScript
300 lines
8.4 KiB
TypeScript
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);
|
||
});
|
||
});
|