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> = { 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 { 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 = { 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"); }); });