Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user