2e702d6582
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.
568 lines
18 KiB
TypeScript
568 lines
18 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,
|
||
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 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);
|
||
});
|
||
});
|
||
|
||
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");
|
||
});
|
||
});
|