712 lines
19 KiB
TypeScript
712 lines
19 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");
|
|
});
|
|
});
|