Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user