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, }, }; 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("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); }); });