import { describe, expect, it } from "vitest"; import type { CoverageResult } from "@app/recipe-engine"; import { easterSunday, isEventActive, midsummerEve, parseCraving, rankAll, scoreCandidate, seasonForDate, 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, }; 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); }); });