import { describe, expect, it } from "vitest"; import type { CoverageResult } from "@app/recipe-engine"; import type { MemoryItem, TasteSignal } from "@app/shared-types"; import { containsForbiddenCopy, DEFAULT_WEIGHTS, easterSunday, isEventActive, midsummerEve, NON_PERSONALIZED_WEIGHTS, parseCraving, rankAll, renderProvenance, scoreCandidate, seasonForDate, type CookingAssumption, type RecommendationCandidate, type RecommendationContext, } from "../src/index.js"; const fullCoverage: CoverageResult = { coverage: 1, matches: [], missing: [], expiringUsed: [] }; const nutrition = { kcal: 550, proteinG: 45, carbsG: 50, fatG: 18, saturatedFatG: 6, fiberG: 6, sugarG: 4, saltG: 1.5, }; function candidate(overrides: Partial = {}): RecommendationCandidate { return { recipeId: "r1", titleSv: "Kycklinggryta", cuisine: "swedish", tags: [], totalTimeMinutes: 30, nutritionPerPortion: nutrition, estimatedCostMinorPerPortion: 2200, ratingAverage: null, ratingCount: 0, peakSeasons: [], holidayTags: [], spiceLevel: 1, coverage: fullCoverage, ...overrides, }; } const ctx: RecommendationContext = { mealType: "dinner", persons: 4, currentSeason: "summer", activeHolidayTags: [], isWeekday: true, favoriteCuisines: ["swedish"], remainingProteinG: 60, remainingKcal: 800, personalizationEnabled: false, }; describe("poängsättning (spec §18)", () => { it("utgångsvaror lyfter rekommendationen", () => { const expiring = candidate({ recipeId: "expiring", coverage: { coverage: 1, matches: [], missing: [], expiringUsed: [ { canonicalIngredientId: "chicken", displayNameSv: "kycklingen", required: 500, unit: "GRAM", availableInUnit: 600, covered: true, optional: false, mostUrgentDaysLeft: 1, usesExpiringItem: true, }, ], }, }); const fresh = candidate({ recipeId: "fresh" }); const ranked = rankAll([fresh, expiring], ctx); expect(ranked[0]?.recipeId).toBe("expiring"); expect(ranked[0]?.whySv).toContain("senast i morgon"); }); it("förklaringen innehåller täckning och protein (specens exempel)", () => { const scored = scoreCandidate( candidate({ coverage: { ...fullCoverage, coverage: 0.92 }, nutritionPerPortion: { ...nutrition, proteinG: 58 }, }), ctx, ); expect(scored.whySv).toContain("92 %"); expect(scored.whySv).toContain("58 gram protein"); }); it("recept över tidsgränsen filtreras bort", () => { const slow = candidate({ recipeId: "slow", totalTimeMinutes: 90 }); const ranked = rankAll([slow], { ...ctx, maxMinutes: 45 }); expect(ranked).toHaveLength(0); }); it("nyligen lagat straffas (variation)", () => { const recent = scoreCandidate(candidate({ daysSinceLastCooked: 2 }), ctx); const old = scoreCandidate(candidate({ daysSinceLastCooked: 30 }), ctx); expect(old.score).toBeGreaterThan(recent.score); }); }); describe("'jag är sugen på' (spec §19)", () => { it("tolkar kök, taggar och kcal-gräns", () => { const parsed = parseCraving("något krämigt och asiatiskt under 500 kcal"); expect(parsed.cuisine).toBe("thai"); expect(parsed.tags).toContain("creamy"); expect(parsed.maxKcal).toBe(500); }); it("tolkar svenska uttryck", () => { expect(parseCraving("snabb husmanskost").tags).toEqual( expect.arrayContaining(["quick", "comfort"]), ); expect(parseCraving("barnvänliga tacos").cuisine).toBe("mexican"); }); }); describe("säsongs- och eventmotor (spec §28)", () => { // Motorn räknar i UTC – testerna använder UTC-datum så de är sanna i alla tidszoner. it("årstider", () => { expect(seasonForDate(new Date("2026-07-15T00:00:00Z"))).toBe("summer"); expect(seasonForDate(new Date("2026-01-15T00:00:00Z"))).toBe("winter"); }); it("midsommarafton är alltid en fredag 19–25 juni", () => { for (const year of [2025, 2026, 2027, 2028]) { const eve = midsummerEve(year); expect(eve.getUTCDay()).toBe(5); expect(eve.getUTCMonth()).toBe(5); expect(eve.getUTCDate()).toBeGreaterThanOrEqual(19); expect(eve.getUTCDate()).toBeLessThanOrEqual(25); } }); it("påskdagen: kända referensår", () => { expect(easterSunday(2026).toISOString().slice(0, 10)).toBe("2026-04-05"); expect(easterSunday(2027).toISOString().slice(0, 10)).toBe("2027-03-28"); }); it("event aktiveras inom leadDays", () => { const midsummer = { dateRule: { kind: "computed", algorithm: "midsummer" } as const, leadDays: 10, }; expect(isEventActive(midsummer, new Date("2026-06-15T00:00:00Z"))).toBe(true); expect(isEventActive(midsummer, new Date("2026-03-01T00:00:00Z"))).toBe(false); const jul = { dateRule: { kind: "range", startMonthDay: "12-20", endMonthDay: "12-26" } as const, leadDays: 21, }; expect(isEventActive(jul, new Date("2026-12-05T00:00:00Z"))).toBe(true); expect(isEventActive(jul, new Date("2026-08-02T00:00:00Z"))).toBe(false); }); }); describe("S1 personalisering", () => { const memoryBase: MemoryItem = { id: "m1", userId: "u1", kind: "structured_fact", key: "favorite_cuisine_swedish", summarySv: "Gillar svensk mat", value: { favoriteCuisine: "swedish" }, origin: "user_stated", confidence: 1, verifiedByUser: true, paused: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; const aiMemory: MemoryItem = { ...memoryBase, id: "m2", origin: "ai_inferred", confidence: 0.5, verifiedByUser: false, value: { favoriteCuisine: "swedish" }, }; const tastePositive: TasteSignal = { id: "t1", userId: "u1", axis: "spice", direction: 1, strength: 0.8, origin: "user_stated", createdAt: new Date().toISOString(), }; const tasteNegative: TasteSignal = { ...tastePositive, id: "t2", direction: -1, }; const cookingAssumption: CookingAssumption = { canonicalIngredientId: "chicken", averageEatenPortions: 4, averageLeftoverPortions: 0.5, observationCount: 5, }; it("memoryFit boostar verified user_stated högre än ai_inferred", () => { const verifiedCtx: RecommendationContext = { ...ctx, personalizationEnabled: true, memoryItems: [memoryBase], tasteSignals: [], cookingAssumptions: [], }; const aiCtx: RecommendationContext = { ...ctx, personalizationEnabled: true, memoryItems: [aiMemory], tasteSignals: [], cookingAssumptions: [], }; const verified = scoreCandidate(candidate({ cuisine: "swedish" }), verifiedCtx); const ai = scoreCandidate(candidate({ cuisine: "swedish" }), aiCtx); expect(verified.parts.memoryFit ?? 0).toBeGreaterThan(ai.parts.memoryFit ?? 0); }); it("tasteFit ger positiv riktning för positiv signal", () => { const spicyCandidate = candidate({ spiceLevel: 3 }); const positiveCtx: RecommendationContext = { ...ctx, personalizationEnabled: true, memoryItems: [], tasteSignals: [tastePositive], cookingAssumptions: [], }; const negativeCtx: RecommendationContext = { ...ctx, personalizationEnabled: true, memoryItems: [], tasteSignals: [tasteNegative], cookingAssumptions: [], }; const positive = scoreCandidate(spicyCandidate, positiveCtx); const negative = scoreCandidate(spicyCandidate, negativeCtx); expect(positive.parts.tasteFit ?? 0).toBeGreaterThan(negative.parts.tasteFit ?? 0); }); it("cookingAssumptionFit boostar ingrediens som hushållet brukar äta upp", () => { const ctxWithAssumption: RecommendationContext = { ...ctx, personalizationEnabled: true, memoryItems: [], tasteSignals: [], cookingAssumptions: [cookingAssumption], }; const boosted = scoreCandidate( candidate({ ingredientIds: ["chicken", "onion"] }), ctxWithAssumption, ); const noMatch = scoreCandidate( candidate({ ingredientIds: ["beef", "onion"] }), ctxWithAssumption, ); expect(boosted.parts.cookingAssumptionFit ?? 0).toBeGreaterThan( noMatch.parts.cookingAssumptionFit ?? 0, ); }); it("utan personalizationEnabled är personliga delpoäng noll", () => { const noPersonalizationCtx: RecommendationContext = { ...ctx, personalizationEnabled: false, memoryItems: [memoryBase], tasteSignals: [tastePositive], cookingAssumptions: [cookingAssumption], }; const scored = scoreCandidate( candidate({ cuisine: "swedish", spiceLevel: 3 }), noPersonalizationCtx, ); expect(scored.parts.memoryFit).toBe(0); expect(scored.parts.tasteFit).toBe(0); expect(scored.parts.cookingAssumptionFit).toBe(0); }); it("proveniens renderas från mall med grundade fakta", () => { const rendered = renderProvenance( [{ key: "favoriteCuisine", args: { cuisine: "svensk" } }], "sv-SE", ); expect(rendered).toContain("svensk"); expect(rendered).toContain("berättat"); }); it("förbjuden copy upptäcks", () => { expect(containsForbiddenCopy("Du har överskridit ditt kalorimål")).toBe(true); expect(containsForbiddenCopy("Passar ditt proteinmål")).toBe(false); }); it("scoring är deterministisk", () => { const personalCtx: RecommendationContext = { ...ctx, personalizationEnabled: true, memoryItems: [memoryBase], tasteSignals: [tastePositive], cookingAssumptions: [cookingAssumption], }; const c = candidate({ ingredientIds: ["chicken"], spiceLevel: 3 }); const a = scoreCandidate(c, personalCtx); const b = scoreCandidate(c, personalCtx); expect(a.score).toBe(b.score); expect(a.whySv).toBe(b.whySv); }); it("icke-personaliserade vikter har noll för S1-komponenter", () => { expect(NON_PERSONALIZED_WEIGHTS.memoryFit).toBe(0); expect(NON_PERSONALIZED_WEIGHTS.tasteFit).toBe(0); expect(NON_PERSONALIZED_WEIGHTS.cookingAssumptionFit).toBe(0); // Befintliga vikter ska vara oförändrade. expect(NON_PERSONALIZED_WEIGHTS.coverage).toBe(DEFAULT_WEIGHTS.coverage); }); });