473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
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,
|
||
depersonalize,
|
||
easterSunday,
|
||
HEALTH_VIEW_WEIGHTS,
|
||
isEventActive,
|
||
midsummerEve,
|
||
NON_PERSONALIZED_WEIGHTS,
|
||
parseCraving,
|
||
PANTRY_VIEW_WEIGHTS,
|
||
rankAll,
|
||
renderProvenance,
|
||
scoreCandidate,
|
||
seasonForDate,
|
||
TASTE_VIEW_WEIGHTS,
|
||
viewWeights,
|
||
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> = {}): 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);
|
||
});
|
||
});
|
||
|
||
describe("S4 rekommendationsvyer", () => {
|
||
const viewCtx: RecommendationContext = {
|
||
...ctx,
|
||
personalizationEnabled: true,
|
||
memoryItems: [],
|
||
tasteSignals: [],
|
||
cookingAssumptions: [],
|
||
};
|
||
|
||
const tasty = candidate({
|
||
recipeId: "tasty",
|
||
titleSv: "Svensk köttbullsgryta",
|
||
cuisine: "swedish",
|
||
coverage: fullCoverage,
|
||
nutritionPerPortion: { ...nutrition, proteinG: 20 },
|
||
});
|
||
|
||
const pantry = candidate({
|
||
recipeId: "pantry",
|
||
titleSv: "Italiensk pastarätt",
|
||
cuisine: "italian",
|
||
coverage: {
|
||
...fullCoverage,
|
||
expiringUsed: [
|
||
{
|
||
canonicalIngredientId: "pasta",
|
||
displayNameSv: "pastan",
|
||
required: 200,
|
||
unit: "GRAM",
|
||
availableInUnit: 250,
|
||
covered: true,
|
||
optional: false,
|
||
mostUrgentDaysLeft: 1,
|
||
usesExpiringItem: true,
|
||
},
|
||
],
|
||
},
|
||
nutritionPerPortion: { ...nutrition, proteinG: 15 },
|
||
});
|
||
|
||
const healthy = candidate({
|
||
recipeId: "healthy",
|
||
titleSv: "Kyckling och quinoa",
|
||
cuisine: "greek",
|
||
coverage: fullCoverage,
|
||
nutritionPerPortion: { ...nutrition, proteinG: 50 },
|
||
});
|
||
|
||
it("smak-vyn höjer recept i favoritkök", () => {
|
||
const ranked = rankAll([pantry, healthy, tasty], viewCtx, TASTE_VIEW_WEIGHTS);
|
||
expect(ranked[0]?.recipeId).toBe("tasty");
|
||
});
|
||
|
||
it("lager-vyn höjer recept som räddar varor nära utgångsdatum", () => {
|
||
const ranked = rankAll([tasty, healthy, pantry], viewCtx, PANTRY_VIEW_WEIGHTS);
|
||
expect(ranked[0]?.recipeId).toBe("pantry");
|
||
});
|
||
|
||
it("hälsa-vyn höjer recept som matchar näringsmål", () => {
|
||
const ranked = rankAll([tasty, pantry, healthy], viewCtx, HEALTH_VIEW_WEIGHTS);
|
||
expect(ranked[0]?.recipeId).toBe("healthy");
|
||
});
|
||
|
||
it("viewWeights returnerar rätt vikter för varje vy", () => {
|
||
expect(viewWeights("default").taste).toBe(DEFAULT_WEIGHTS.taste);
|
||
expect(viewWeights("taste").taste).toBe(TASTE_VIEW_WEIGHTS.taste);
|
||
expect(viewWeights("health").nutritionFit).toBe(HEALTH_VIEW_WEIGHTS.nutritionFit);
|
||
expect(viewWeights("pantry").coverage).toBe(PANTRY_VIEW_WEIGHTS.coverage);
|
||
});
|
||
|
||
it("opersonliga vyerna fungerar utan samtycke genom depersonalize", () => {
|
||
const nonPersonalCtx: RecommendationContext = {
|
||
...viewCtx,
|
||
personalizationEnabled: false,
|
||
memoryItems: undefined,
|
||
tasteSignals: undefined,
|
||
cookingAssumptions: undefined,
|
||
};
|
||
|
||
const tasty = candidate({
|
||
recipeId: "tasty",
|
||
titleSv: "Svensk köttbullsgryta",
|
||
cuisine: "swedish",
|
||
coverage: fullCoverage,
|
||
nutritionPerPortion: { ...nutrition, proteinG: 20 },
|
||
});
|
||
|
||
const pantry = candidate({
|
||
recipeId: "pantry",
|
||
titleSv: "Italiensk pastarätt",
|
||
cuisine: "italian",
|
||
coverage: {
|
||
...fullCoverage,
|
||
expiringUsed: [
|
||
{
|
||
canonicalIngredientId: "pasta",
|
||
displayNameSv: "pastan",
|
||
required: 200,
|
||
unit: "GRAM",
|
||
availableInUnit: 250,
|
||
covered: true,
|
||
optional: false,
|
||
mostUrgentDaysLeft: 1,
|
||
usesExpiringItem: true,
|
||
},
|
||
],
|
||
},
|
||
nutritionPerPortion: { ...nutrition, proteinG: 15 },
|
||
});
|
||
|
||
const healthy = candidate({
|
||
recipeId: "healthy",
|
||
titleSv: "Kyckling och quinoa",
|
||
cuisine: "greek",
|
||
coverage: fullCoverage,
|
||
nutritionPerPortion: { ...nutrition, proteinG: 50 },
|
||
});
|
||
|
||
// default utan samtycke = depersonalize(DEFAULT_WEIGHTS) == NON_PERSONALIZED_WEIGHTS
|
||
const defaultRanked = rankAll([pantry, healthy, tasty], nonPersonalCtx, depersonalize(viewWeights("default")));
|
||
const nonPersonalDefaultRanked = rankAll([pantry, healthy, tasty], nonPersonalCtx, NON_PERSONALIZED_WEIGHTS);
|
||
expect(defaultRanked.map((r) => r.recipeId)).toEqual(nonPersonalDefaultRanked.map((r) => r.recipeId));
|
||
|
||
// vyerna är fortfarande skilda från default även utan samtycke
|
||
expect(rankAll([tasty, healthy, pantry], nonPersonalCtx, depersonalize(viewWeights("pantry")))[0]?.recipeId).toBe("pantry");
|
||
expect(rankAll([tasty, pantry, healthy], nonPersonalCtx, depersonalize(viewWeights("health")))[0]?.recipeId).toBe("healthy");
|
||
|
||
// taste-vyn honoreras men personliga axlar är nollade
|
||
const tasteWeights = depersonalize(viewWeights("taste"));
|
||
expect(tasteWeights.memoryFit).toBe(0);
|
||
expect(tasteWeights.tasteFit).toBe(0);
|
||
expect(tasteWeights.cookingAssumptionFit).toBe(0);
|
||
expect(tasteWeights.taste).toBe(TASTE_VIEW_WEIGHTS.taste);
|
||
});
|
||
});
|