Files
Cibello-app/packages/recommendation-engine/test/engine.test.ts
T
Claude 2e702d6582 fix(rekommendationer): vardagens tidsgräns gömmer inte längre högtids-/bakrecept
Buggen: när användaren uttryckligen valde en högtid (t.ex. Kräftskiva), en
tagg (Baka) eller sökte, hårdfiltrerade scoreCandidate bort alla recept vars
totalTimeMinutes översteg hushållets maxCookingMinutesWeekday (parts.time=-1,
rankAll släpper <0). De smala högtidspoolerna kollapsade då till 0-1 recept
trots att flera fanns korrekt taggade.

Reproducerat mot riktig seedad databas: med en 20-min vardagsgräns gav
kräftskiva 2, grillsäsong 2, skolstart 0, bak nästan tomt. Med fixen ger de
4, 5, 3 respektive 18 – medan standardvyn "Rekommenderat" fortfarande
respekterar tidsgränsen (3 snabba middagar).

Fix: uttrycklig avsikt (holiday/tag/search) förbigår vardagens tidsgräns –
tiden blir mjuk rankning i stället för hårt filter. En uttrycklig q.maxMinutes
i förfrågan respekteras fortfarande. Regressionstest låser mekanismen.
2026-08-19 15:10:04 +00:00

568 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("utan tidsgräns behålls långsamma recept (t.ex. vid uttrycklig högtid/bak)", () => {
// Fixen: när användaren uttryckligen valt en högtid/tagg/sök sätter
// endpointen maxMinutes=undefined, så kräftskiva och bak inte gömms av
// vardagens tidsgräns. Långsamma recept ska då rankas inte försvinna.
const slow = candidate({ recipeId: "slow", totalTimeMinutes: 90 });
const ranked = rankAll([slow], { ...ctx, maxMinutes: undefined });
expect(ranked).toHaveLength(1);
expect(ranked[0]!.recipeId).toBe("slow");
});
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 1925 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);
});
});
describe("diet-affinitet (spec §diet, mjuk rankning)", () => {
it("utan matvana är affiniteten neutral (0.5)", () => {
const scored = scoreCandidate(candidate(), ctx);
expect(scored.parts.dietAffinity).toBe(0.5);
});
it("low_carb/LCHF premierar låg kolhydrat", () => {
const low = scoreCandidate(candidate({ nutritionPerPortion: { ...nutrition, carbsG: 10 } }), {
...ctx,
dietPattern: "low_carb",
});
const high = scoreCandidate(candidate({ nutritionPerPortion: { ...nutrition, carbsG: 60 } }), {
...ctx,
dietPattern: "low_carb",
});
expect(low.parts.dietAffinity).toBe(1);
expect(high.parts.dietAffinity).toBe(0.15);
});
it("explicit low_carb-tagg räcker oavsett makro", () => {
const tagged = scoreCandidate(
candidate({ tags: ["low_carb"], nutritionPerPortion: { ...nutrition, carbsG: 55 } }),
{ ...ctx, dietPattern: "keto" },
);
expect(tagged.parts.dietAffinity).toBe(1);
});
it("carnivore premierar högt protein + låg kolhydrat", () => {
const meaty = scoreCandidate(
candidate({ nutritionPerPortion: { ...nutrition, proteinG: 45, carbsG: 5 } }),
{ ...ctx, dietPattern: "carnivore" },
);
const carby = scoreCandidate(
candidate({ nutritionPerPortion: { ...nutrition, proteinG: 12, carbsG: 70 } }),
{ ...ctx, dietPattern: "carnivore" },
);
expect(meaty.parts.dietAffinity).toBe(1);
expect(meaty.parts.dietAffinity).toBeGreaterThan(carby.parts.dietAffinity ?? 0);
});
it("nordisk premierar svenskt/nordiskt kök", () => {
const swedish = scoreCandidate(candidate({ cuisine: "swedish" }), {
...ctx,
dietPattern: "nordic",
});
const italian = scoreCandidate(candidate({ cuisine: "italian" }), {
...ctx,
dietPattern: "nordic",
});
expect(swedish.parts.dietAffinity).toBe(0.85);
expect(swedish.parts.dietAffinity).toBeGreaterThan(italian.parts.dietAffinity ?? 0);
});
it("är mjuk rankning, inte hårt filter ej matchande recept finns kvar", () => {
const lowCarbRecipe = candidate({
recipeId: "lowcarb",
nutritionPerPortion: { ...nutrition, carbsG: 8 },
});
const pastaRecipe = candidate({
recipeId: "pasta",
nutritionPerPortion: { ...nutrition, carbsG: 80 },
});
const ranked = rankAll([pastaRecipe, lowCarbRecipe], { ...ctx, dietPattern: "low_carb" });
// Båda recepten finns kvar (inget filtreras bort), men lågkolhydrat rankas högre.
expect(ranked).toHaveLength(2);
expect(ranked[0]?.recipeId).toBe("lowcarb");
});
});