160 lines
4.9 KiB
TypeScript
160 lines
4.9 KiB
TypeScript
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> = {}): 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);
|
||
});
|
||
});
|