Files
Cibello-app/packages/recommendation-engine/test/engine.test.ts
T
Sven (AAMOS AI) f4a603e977 fix(recipe-generation): BRAND.name-interpolation för creatorDisplayName + rubriker/filnamn
feat(recommendation-engine,api): S4 Smak/Hälsa/Lager-vyer för 'Vad ska vi äta?'

- Ersätter hårdkodat 'Cibello' i scale-batch, scale-smoke-test och export-verified.
- DB-backfill: 224 recept hade 'Cibello AI' i creator_display_name (värdena
  motsvarar nuvarande BRAND.name, ingen rad ändrades men kontrollen är gjord).
- Lägger till view-query-param (default|taste|health|pantry) med fördefinierade
  ScoringWeights och samtyckesgrind.
- Unit-tester för vyer; integrationstester för vy-param, validering och
  fallback utan personalization-samtycke.
- brand-guard grön; pnpm typecheck 19/19; pnpm test --force x2 grönt (34 tasks,
  275 tester).
2026-08-10 05:17:43 +07:00

407 lines
12 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,
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 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);
});
});