0885f5bceb
- 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.
332 lines
10 KiB
TypeScript
332 lines
10 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,
|
||
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 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);
|
||
});
|
||
});
|