Files
Cibello-app/packages/recommendation-engine/test/engine.test.ts
T
Sven (AAMOS AI) 0885f5bceb feat(recommendation-engine): S1 personalisering för 'Vad ska vi äta?'
- Mallbaserad proveniens i 12 språk (inga fria AI-texter i rekommendationer).
- memoryFit, tasteFit, cookingAssumptionFit endast vid personalization-samtycke.
- Hård grind i API:et: läser memory_items/taste_signals/cooking_assumption_profiles
  endast när userConsents.personalization = granted.
- NON_PERSONALIZED_WEIGHTS bevarar existerande beteende vid avsaknad av samtycke.
- Positiv, icke-restriktiv näringscopy (R7).
- Deterministisk scoring + enhetstester för S1.
- Integrationstest som verifierar provenans-gate med/utan samtycke.
2026-08-10 03:23:31 +07:00

332 lines
10 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,
easterSunday,
isEventActive,
midsummerEve,
NON_PERSONALIZED_WEIGHTS,
parseCraving,
rankAll,
renderProvenance,
scoreCandidate,
seasonForDate,
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 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);
});
});