Files
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

489 lines
15 KiB
TypeScript
Raw Permalink 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("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);
});
});