Files
Cibello-app/packages/recipe-generation/test/verification.test.ts
T
Sven (AAMOS AI) 9c5adaa921 fix(recipe-generation): allergen-säkerhet + re-derive + dedup
- Lägger regressionstester: optional allergen-ingrediens deklareras,
  optional rå fågel kräver genomstekningssteg, optional mjölk deklareras.
- Re-derive allergener för alla seedade recept (18 uppdaterade).
- Raderar 4 recept med saknade ingredienser/steg.
- Raderar 1 ordningsdubblett: Ugnsbakad lax med dill och citron.
- 246 godkända recept kvar, 0 dublett-slugs.
2026-08-10 01:29:31 +07:00

371 lines
16 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "../src/verification.js";
import type { RecipeCandidate } from "../src/types.js";
const mockIngredients: CanonicalIngredientLookup = {
getById(id: string) {
const db: Record<string, ReturnType<CanonicalIngredientLookup["getById"]>> = {
kycklingfile: {
id: "kycklingfile",
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
},
},
defaultUnit: "GRAM",
densityGPerMl: null,
gramsPerPiece: null,
allergens: [],
isVegan: false,
isVegetarian: false,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
ris: {
id: "ris",
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 130, proteinG: 2.7, carbsG: 28, fatG: 0.3,
saturatedFatG: 0.1, fiberG: 0.4, sugarG: 0.1, saltG: 0,
},
},
defaultUnit: "GRAM",
densityGPerMl: null,
gramsPerPiece: null,
allergens: [],
isVegan: true,
isVegetarian: true,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
"krossade_tomater": {
id: "krossade_tomater",
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 32, proteinG: 1.6, carbsG: 5.8, fatG: 0.3,
saturatedFatG: 0, fiberG: 1.2, sugarG: 4, saltG: 0.1,
},
},
defaultUnit: "GRAM",
densityGPerMl: null,
gramsPerPiece: null,
allergens: [],
isVegan: true,
isVegetarian: true,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
chicken_breast: {
id: "chicken_breast",
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
},
},
defaultUnit: "GRAM",
densityGPerMl: null,
gramsPerPiece: null,
allergens: [],
isVegan: false,
isVegetarian: false,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
sesame_seeds: {
id: "sesame_seeds",
nutritionPer100: {
basis: "per_100_g",
values: { kcal: 600, proteinG: 18, carbsG: 12, fatG: 50, saturatedFatG: 7, fiberG: 12, sugarG: 0, saltG: 0 },
},
defaultUnit: "GRAM",
densityGPerMl: 0.6,
gramsPerPiece: null,
allergens: ["sesame"],
isVegan: true,
isVegetarian: true,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
milk_3: {
id: "milk_3",
nutritionPer100: {
basis: "per_100_g",
values: { kcal: 60, proteinG: 3.4, carbsG: 4.7, fatG: 3, saturatedFatG: 1.9, fiberG: 0, sugarG: 4.7, saltG: 0.1 },
},
defaultUnit: "DECILITER",
densityGPerMl: 1.03,
gramsPerPiece: null,
allergens: ["milk"],
isVegan: false,
isVegetarian: true,
containsGluten: false,
containsLactose: true,
isPork: false,
isBeef: false,
isAlcohol: false,
},
};
return db[id] ?? undefined;
},
};
const noSimilarity: SimilarityLookup = {
async hasSimilarity() {
return false;
},
};
function makeCandidate(overrides?: Partial<RecipeCandidate>): RecipeCandidate {
return {
titleSv: "Testrecept",
descriptionSv: "Ett enkelt testrecept.",
cuisine: "swedish",
mealTypes: ["dinner"],
prepTimeMinutes: 10,
cookTimeMinutes: 20,
totalTimeMinutes: 30,
portions: 4,
spiceLevel: 1,
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen i en panna.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt tomater och ris, låt koka.", timerSeconds: 900, temperatureC: null, tip: null },
],
storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.",
mealPrepFriendly: false,
freezerFriendly: false,
sourceType: "ai_generated",
confidence: 0.9,
...overrides,
};
}
describe("verifyCandidate", () => {
it("passerar en giltig kandidat som verified", async () => {
const result = await verifyCandidate(makeCandidate(), mockIngredients, noSimilarity);
expect(result.status).toBe("verified");
expect(result.reasons).toHaveLength(0);
expect(result.allergens).toHaveLength(0);
expect(result.nutritionPerPortion).not.toBeNull();
expect(result.nutritionPerPortion!.kcal).toBeGreaterThan(0);
});
it("avvisar kandidat med okänd ingrediens", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "fantasi_gronsak", displayNameSv: "fantasigrönsak", quantity: 100, unit: "GRAM", optional: false, note: null },
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("rejected");
expect(result.reasons.some((r) => r.includes("fantasi_gronsak"))).toBe(true);
});
it("markerar unverified vid orimlig tid", async () => {
const c = makeCandidate({ prepTimeMinutes: 200, cookTimeMinutes: 10, totalTimeMinutes: 210 });
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { maxPrepTimeMinutes: 60 });
expect(result.status).toBe("unverified");
expect(result.reasons.some((r) => r.includes("Förberedelsetid"))).toBe(true);
});
it("markerar unverified vid för få steg", async () => {
const c = makeCandidate({ steps: [{ stepNumber: 1, instructionSv: "Gör allt.", timerSeconds: null, temperatureC: null, tip: null }] });
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { minSteps: 2 });
expect(result.status).toBe("unverified");
expect(result.reasons.some((r) => r.includes("steg"))).toBe(true);
});
it("härleder allergener korrekt", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.allergens).toEqual([]);
});
it("överkör AI:n när AI påstår inga allergener men ingrediens har mjölk", async () => {
const milkIngredient: ReturnType<CanonicalIngredientLookup["getById"]> = {
id: "milk_3",
nutritionPer100: {
basis: "per_100_g",
values: { kcal: 60, proteinG: 3.4, carbsG: 4.7, fatG: 3, saturatedFatG: 1.9, fiberG: 0, sugarG: 4.7, saltG: 0.1 },
},
defaultUnit: "DECILITER",
densityGPerMl: 1.03,
gramsPerPiece: null,
allergens: ["milk"],
isVegan: false,
isVegetarian: true,
containsGluten: false,
containsLactose: true,
isPork: false,
isBeef: false,
isAlcohol: false,
};
const lookup: CanonicalIngredientLookup = {
getById(id: string) {
return id === "milk_3" ? milkIngredient : undefined;
},
};
const c = makeCandidate({
aiClaimedAllergens: [],
ingredients: [
{ canonicalIngredientId: "milk_3", displayNameSv: "mjölk", quantity: 5, unit: "DECILITER", optional: false, note: null },
],
});
const result = await verifyCandidate(c, lookup, noSimilarity);
expect(result.allergens).toContain("milk");
});
it("slänger AI:n:s falska gluten-allergen när ingen ingrediens innehåller gluten", async () => {
const c = makeCandidate({
aiClaimedAllergens: ["gluten"],
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.allergens).not.toContain("gluten");
expect(result.status).toBe("verified");
});
it("detekterar dubblett", async () => {
const dupSimilarity: SimilarityLookup = {
async hasSimilarity() {
return true;
},
};
const result = await verifyCandidate(makeCandidate(), mockIngredients, dupSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons.some((r) => r.includes("dubblett"))).toBe(true);
});
it("kräver genomstekningssteg för rå fågel (positivt)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen tills den är genomstekt och innertemperaturen är 72°C.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och tomater, koka klart.", timerSeconds: null, temperatureC: null, tip: null },
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("verified");
expect(result.reasons).not.toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
});
it("markerar unverified när rå fågel bara beskrivs som gyllenbrun (utseende räcker inte)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen tills den är gyllenbrun.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och koka klart.", timerSeconds: null, temperatureC: null, tip: null },
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
});
it("markerar unverified när rå fågel saknar genomstekningssteg (negativt)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen i pannan.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och tomater, koka klart.", timerSeconds: null, temperatureC: null, tip: null },
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
});
it("härleder allergen även från valfri ingrediens (optional sesame)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "sesame_seeds", displayNameSv: "sesamfrön till garnering", quantity: 10, unit: "GRAM", optional: true, note: null },
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen genomstekt.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och sesamefrön.", timerSeconds: null, temperatureC: null, tip: null },
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.allergens).toContain("sesame");
});
it("kräver genomstekningssteg även för optional rå fågel", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 300, unit: "GRAM", optional: true, note: null },
],
steps: [
{ stepNumber: 1, instructionSv: "Koka riset enligt anvisningen.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt tomater och kyckling.", timerSeconds: null, temperatureC: null, tip: null },
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
});
it("härleder mjölk-allergen från optional mjölk", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "milk_3", displayNameSv: "mjölk till servering", quantity: 2, unit: "DECILITER", optional: true, note: null },
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen genomstekt.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och mjölk.", timerSeconds: null, temperatureC: null, tip: null },
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.allergens).toContain("milk");
});
});