Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@app/recipe-engine",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Receptmatchning, deterministisk allergi-/dietfiltrering, substitution och skalning (spec §17, §20, §61.2)",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/inventory-engine": "workspace:*",
|
||||
"@app/nutrition-engine": "workspace:*",
|
||||
"@app/shared-types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { toGrams, type IngredientUnitInfo } from "@app/nutrition-engine";
|
||||
|
||||
export interface CostableIngredient {
|
||||
canonicalIngredientId: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface PriceInfo extends IngredientUnitInfo {
|
||||
/** SEK per kg – från kvittohistorik, användardata eller standardvärde (spec §26). */
|
||||
pricePerKgMinor: number;
|
||||
source: "receipt_history" | "user" | "default";
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
totalMinor: number | null;
|
||||
perPortionMinor: number | null;
|
||||
/** Andel av ingredienserna (viktat) som hade prisdata. */
|
||||
priceCoverage: number;
|
||||
sourcesUsed: PriceInfo["source"][];
|
||||
}
|
||||
|
||||
/** Kostnadsuppskattning per portion – alltid märkt som uppskattning. */
|
||||
export function estimateCost(
|
||||
ingredients: CostableIngredient[],
|
||||
portions: number,
|
||||
prices: Map<string, PriceInfo>,
|
||||
): CostEstimate {
|
||||
let total = 0;
|
||||
let pricedCount = 0;
|
||||
let mandatoryCount = 0;
|
||||
const sources = new Set<PriceInfo["source"]>();
|
||||
|
||||
for (const ing of ingredients) {
|
||||
if (ing.optional) continue;
|
||||
mandatoryCount += 1;
|
||||
const price = prices.get(ing.canonicalIngredientId);
|
||||
if (!price) continue;
|
||||
const grams = toGrams(ing.quantity, ing.unit, price);
|
||||
if (grams == null) continue;
|
||||
total += (grams / 1000) * price.pricePerKgMinor;
|
||||
pricedCount += 1;
|
||||
sources.add(price.source);
|
||||
}
|
||||
|
||||
if (pricedCount === 0 || mandatoryCount === 0) {
|
||||
return { totalMinor: null, perPortionMinor: null, priceCoverage: 0, sourcesUsed: [] };
|
||||
}
|
||||
|
||||
const coverage = pricedCount / mandatoryCount;
|
||||
return {
|
||||
totalMinor: Math.round(total),
|
||||
perPortionMinor: portions > 0 ? Math.round(total / portions) : null,
|
||||
priceCoverage: Math.round(coverage * 100) / 100,
|
||||
sourcesUsed: [...sources],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./safety.js";
|
||||
export * from "./matching.js";
|
||||
export * from "./scaling.js";
|
||||
export * from "./substitution.js";
|
||||
export * from "./cost.js";
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { convert, type IngredientUnitInfo } from "@app/nutrition-engine";
|
||||
import { classifyExpiry, type ExpiryInput } from "@app/inventory-engine";
|
||||
|
||||
export interface PantryItem extends ExpiryInput {
|
||||
id: string;
|
||||
canonicalIngredientId?: string | null;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
}
|
||||
|
||||
export interface RecipeIngredientRequirement {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface IngredientMatch {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
required: number;
|
||||
unit: Unit;
|
||||
availableInUnit: number;
|
||||
covered: boolean;
|
||||
optional: boolean;
|
||||
/** Mest brådskande status bland matchande lagerposter. */
|
||||
mostUrgentDaysLeft: number | null;
|
||||
usesExpiringItem: boolean;
|
||||
}
|
||||
|
||||
export interface CoverageResult {
|
||||
/** Andel obligatoriska ingredienser som täcks helt (0–1). */
|
||||
coverage: number;
|
||||
matches: IngredientMatch[];
|
||||
missing: IngredientMatch[];
|
||||
/** Ingredienser som finns hemma och snart går ut – guld för rekommendationen. */
|
||||
expiringUsed: IngredientMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Matcha receptets ingredienser mot hushållets lager (spec §17–18).
|
||||
* Enhetskonvertering via nutrition-engine; poster som inte kan konverteras
|
||||
* räknas som otäckta i stället för att gissas.
|
||||
*/
|
||||
export function computeCoverage(
|
||||
requirements: RecipeIngredientRequirement[],
|
||||
pantry: PantryItem[],
|
||||
unitInfo: Map<string, IngredientUnitInfo>,
|
||||
today: Date = new Date(),
|
||||
): CoverageResult {
|
||||
const byIngredient = new Map<string, PantryItem[]>();
|
||||
for (const item of pantry) {
|
||||
if (!item.canonicalIngredientId || item.quantity <= 0) continue;
|
||||
const list = byIngredient.get(item.canonicalIngredientId) ?? [];
|
||||
list.push(item);
|
||||
byIngredient.set(item.canonicalIngredientId, list);
|
||||
}
|
||||
|
||||
const matches: IngredientMatch[] = [];
|
||||
for (const req of requirements) {
|
||||
const stock = byIngredient.get(req.canonicalIngredientId) ?? [];
|
||||
const info = unitInfo.get(req.canonicalIngredientId) ?? {};
|
||||
let available = 0;
|
||||
let mostUrgent: number | null = null;
|
||||
let usesExpiring = false;
|
||||
|
||||
for (const item of stock) {
|
||||
const inReqUnit = convert(item.quantity, item.unit, req.unit, info);
|
||||
if (inReqUnit == null) continue;
|
||||
available += inReqUnit;
|
||||
const expiry = classifyExpiry(item, today);
|
||||
if (expiry.daysLeft != null) {
|
||||
mostUrgent = mostUrgent == null ? expiry.daysLeft : Math.min(mostUrgent, expiry.daysLeft);
|
||||
}
|
||||
if (expiry.status === "expiring" || expiry.status === "use_soon") usesExpiring = true;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
canonicalIngredientId: req.canonicalIngredientId,
|
||||
displayNameSv: req.displayNameSv,
|
||||
required: req.quantity,
|
||||
unit: req.unit,
|
||||
availableInUnit: Math.round(available * 1000) / 1000,
|
||||
covered: available + 1e-9 >= req.quantity,
|
||||
optional: req.optional,
|
||||
mostUrgentDaysLeft: mostUrgent,
|
||||
usesExpiringItem: usesExpiring,
|
||||
});
|
||||
}
|
||||
|
||||
const mandatory = matches.filter((m) => !m.optional);
|
||||
const coveredCount = mandatory.filter((m) => m.covered).length;
|
||||
const coverage = mandatory.length === 0 ? 1 : coveredCount / mandatory.length;
|
||||
|
||||
return {
|
||||
coverage: Math.round(coverage * 100) / 100,
|
||||
matches,
|
||||
missing: matches.filter((m) => !m.covered),
|
||||
expiringUsed: matches.filter((m) => m.covered && m.usesExpiringItem),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Allergen, DietPattern, ReligiousRule } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* DETERMINISTISK säkerhets- och kostfiltrering (spec §13, §61.2).
|
||||
* Ingen AI får någonsin avgöra allergisäkerhet. Denna modul är enda vägen.
|
||||
*
|
||||
* Försiktighetsprincip: om en ingrediens saknar data ("unknown") behandlas
|
||||
* receptet som EJ säkert för berörda filter och flaggas för granskning.
|
||||
*/
|
||||
|
||||
export interface IngredientSafetyInfo {
|
||||
id: string;
|
||||
allergens: Allergen[];
|
||||
mayContainAllergens?: Allergen[];
|
||||
isVegan: boolean;
|
||||
isVegetarian: boolean;
|
||||
containsGluten: boolean;
|
||||
containsLactose: boolean;
|
||||
isPork: boolean;
|
||||
isBeef: boolean;
|
||||
isAlcohol: boolean;
|
||||
/** true när datan är verifierad; ovverifierad data ger varning i stället för tyst OK. */
|
||||
dataVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface DietaryConstraints {
|
||||
allergens: Allergen[];
|
||||
intolerances?: string[];
|
||||
dietPattern?: DietPattern;
|
||||
religiousRule?: ReligiousRule;
|
||||
avoidIngredientIds?: string[];
|
||||
spiceLevelMax?: number;
|
||||
/** Behandla "kan innehålla spår av" som blockerande (default true vid allergi). */
|
||||
blockMayContain?: boolean;
|
||||
}
|
||||
|
||||
export type ViolationSeverity = "blocker" | "warning";
|
||||
|
||||
export interface SafetyViolation {
|
||||
severity: ViolationSeverity;
|
||||
code:
|
||||
| "allergen"
|
||||
| "allergen_may_contain"
|
||||
| "diet_pattern"
|
||||
| "religious_rule"
|
||||
| "avoided_ingredient"
|
||||
| "spice_level"
|
||||
| "unverified_data";
|
||||
ingredientId?: string;
|
||||
allergen?: Allergen;
|
||||
messageSv: string;
|
||||
}
|
||||
|
||||
export interface RecipeSafetyInput {
|
||||
ingredients: Array<{ canonicalIngredientId: string; optional: boolean }>;
|
||||
spiceLevel: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returnerar ALLA överträdelser (tom lista = säkert enligt tillgänglig data).
|
||||
* Valfria ingredienser ger varning i stället för blocker (kan uteslutas).
|
||||
*/
|
||||
export function checkRecipeSafety(
|
||||
recipe: RecipeSafetyInput,
|
||||
constraints: DietaryConstraints,
|
||||
ingredientInfo: Map<string, IngredientSafetyInfo>,
|
||||
): SafetyViolation[] {
|
||||
const violations: SafetyViolation[] = [];
|
||||
const userAllergens = new Set(constraints.allergens);
|
||||
const avoid = new Set(constraints.avoidIngredientIds ?? []);
|
||||
const blockMayContain = constraints.blockMayContain ?? userAllergens.size > 0;
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
const info = ingredientInfo.get(ing.canonicalIngredientId);
|
||||
const severity: ViolationSeverity = ing.optional ? "warning" : "blocker";
|
||||
|
||||
if (!info) {
|
||||
violations.push({
|
||||
severity: "warning",
|
||||
code: "unverified_data",
|
||||
ingredientId: ing.canonicalIngredientId,
|
||||
messageSv: `Ingrediensen ${ing.canonicalIngredientId} saknar säkerhetsdata – kontrollera manuellt.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const allergen of info.allergens) {
|
||||
if (userAllergens.has(allergen)) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "allergen",
|
||||
ingredientId: info.id,
|
||||
allergen,
|
||||
messageSv: `Innehåller ${allergen} (${info.id}).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (blockMayContain) {
|
||||
for (const allergen of info.mayContainAllergens ?? []) {
|
||||
if (userAllergens.has(allergen)) {
|
||||
violations.push({
|
||||
severity: "warning",
|
||||
code: "allergen_may_contain",
|
||||
ingredientId: info.id,
|
||||
allergen,
|
||||
messageSv: `Kan innehålla spår av ${allergen} (${info.id}).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diet = constraints.dietPattern;
|
||||
if (diet === "vegan" && !info.isVegan) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "diet_pattern",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte veganskt.`,
|
||||
});
|
||||
} else if ((diet === "vegetarian" || diet === "pescatarian") && !info.isVegetarian) {
|
||||
const isFish = info.allergens.includes("fish") || info.allergens.includes("crustaceans");
|
||||
const allowed = diet === "pescatarian" && isFish;
|
||||
if (!allowed) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "diet_pattern",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte ${diet === "vegetarian" ? "vegetariskt" : "pescetarianskt"}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rule = constraints.religiousRule;
|
||||
if (rule === "halal" && (info.isPork || info.isAlcohol)) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte förenligt med halal (${info.isPork ? "fläsk" : "alkohol"}).`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
rule === "kosher" &&
|
||||
(info.isPork || info.allergens.includes("crustaceans") || info.allergens.includes("molluscs"))
|
||||
) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte förenligt med kosher.`,
|
||||
});
|
||||
}
|
||||
if (rule === "hindu_no_beef" && info.isBeef) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} innehåller nötkött.`,
|
||||
});
|
||||
}
|
||||
if (rule === "buddhist_vegetarian" && !info.isVegetarian) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte vegetariskt.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (avoid.has(info.id)) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "avoided_ingredient",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} finns på din undviklista.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (constraints.spiceLevelMax != null && recipe.spiceLevel > constraints.spiceLevelMax) {
|
||||
violations.push({
|
||||
severity: "warning",
|
||||
code: "spice_level",
|
||||
messageSv: `Styrka ${recipe.spiceLevel} överstiger din maxnivå ${constraints.spiceLevelMax}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** true om receptet är helt fritt från blockers. */
|
||||
export function isRecipeSafe(violations: SafetyViolation[]): boolean {
|
||||
return !violations.some((v) => v.severity === "blocker");
|
||||
}
|
||||
|
||||
/** Härled ett recepts allergener deterministiskt ur ingredienserna (spec §61.2). */
|
||||
export function deriveRecipeAllergens(
|
||||
ingredientIds: string[],
|
||||
ingredientInfo: Map<string, IngredientSafetyInfo>,
|
||||
): Allergen[] {
|
||||
const set = new Set<Allergen>();
|
||||
for (const id of ingredientIds) {
|
||||
const info = ingredientInfo.get(id);
|
||||
for (const a of info?.allergens ?? []) set.add(a);
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { UNIT_INFO, type Unit } from "@app/shared-types";
|
||||
|
||||
export interface ScalableIngredient {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
/** Enheter som avrundas till "köksvänliga" mängder vid skalning. */
|
||||
const SPOON_UNITS: ReadonlySet<Unit> = new Set(["TABLESPOON", "TEASPOON", "PINCH"]);
|
||||
|
||||
/**
|
||||
* Skala recept till annat antal portioner (Cooking Mode: "skala till sex personer").
|
||||
* Kryddmått avrundas till halva mått; styck till kvartar.
|
||||
*/
|
||||
export function scaleIngredients(
|
||||
ingredients: ScalableIngredient[],
|
||||
fromPortions: number,
|
||||
toPortions: number,
|
||||
): ScalableIngredient[] {
|
||||
if (fromPortions <= 0 || toPortions <= 0) {
|
||||
throw new Error("Portioner måste vara > 0");
|
||||
}
|
||||
const factor = toPortions / fromPortions;
|
||||
return ingredients.map((ing) => ({
|
||||
...ing,
|
||||
quantity: roundForUnit(ing.quantity * factor, ing.unit),
|
||||
}));
|
||||
}
|
||||
|
||||
function roundForUnit(value: number, unit: Unit): number {
|
||||
if (SPOON_UNITS.has(unit)) return Math.round(value * 2) / 2;
|
||||
if (UNIT_INFO[unit].kind === "count") return Math.round(value * 4) / 4;
|
||||
if (unit === "KILOGRAM" || unit === "LITER" || unit === "POUND")
|
||||
return Math.round(value * 100) / 100;
|
||||
if (unit === "CUP_US") return Math.round(value * 4) / 4;
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Substitution, Unit } from "@app/shared-types";
|
||||
|
||||
export interface SubstitutableIngredient {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface SubstitutionResult {
|
||||
ingredients: SubstitutableIngredient[];
|
||||
applied: {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
ratio: number;
|
||||
instructionsSv?: string | undefined;
|
||||
warningSv?: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutionsmotor (spec §20): byt ingrediens med mängdfaktor och
|
||||
* instruktionspåverkan. Näringen räknas ALLTID om av nutrition-engine efteråt –
|
||||
* denna modul ändrar bara ingredienslistan.
|
||||
*/
|
||||
export function applySubstitution(
|
||||
ingredients: SubstitutableIngredient[],
|
||||
substitution: Pick<
|
||||
Substitution,
|
||||
"fromIngredientId" | "toIngredientId" | "ratio" | "instructionsSv" | "notRecommendedFor"
|
||||
>,
|
||||
toDisplayNameSv: string,
|
||||
context?: string,
|
||||
): SubstitutionResult {
|
||||
const target = ingredients.find((i) => i.canonicalIngredientId === substitution.fromIngredientId);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`Ingrediensen ${substitution.fromIngredientId} finns inte i receptet och kan inte bytas.`,
|
||||
);
|
||||
}
|
||||
|
||||
let warningSv: string | undefined;
|
||||
if (context && substitution.notRecommendedFor.includes(context)) {
|
||||
warningSv = `Observera: detta byte rekommenderas inte för ${context}.`;
|
||||
}
|
||||
|
||||
const next = ingredients.map((ing) =>
|
||||
ing.canonicalIngredientId === substitution.fromIngredientId
|
||||
? {
|
||||
...ing,
|
||||
canonicalIngredientId: substitution.toIngredientId,
|
||||
displayNameSv: toDisplayNameSv,
|
||||
quantity: Math.round(ing.quantity * substitution.ratio * 100) / 100,
|
||||
}
|
||||
: ing,
|
||||
);
|
||||
|
||||
return {
|
||||
ingredients: next,
|
||||
applied: {
|
||||
fromId: substitution.fromIngredientId,
|
||||
toId: substitution.toIngredientId,
|
||||
ratio: substitution.ratio,
|
||||
instructionsSv: substitution.instructionsSv ?? undefined,
|
||||
warningSv,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applySubstitution,
|
||||
checkRecipeSafety,
|
||||
computeCoverage,
|
||||
deriveRecipeAllergens,
|
||||
estimateCost,
|
||||
isRecipeSafe,
|
||||
scaleIngredients,
|
||||
type IngredientSafetyInfo,
|
||||
} from "../src/index.js";
|
||||
|
||||
const INFO = new Map<string, IngredientSafetyInfo>([
|
||||
[
|
||||
"chicken",
|
||||
{
|
||||
id: "chicken",
|
||||
allergens: [],
|
||||
isVegan: false,
|
||||
isVegetarian: false,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"milk",
|
||||
{
|
||||
id: "milk",
|
||||
allergens: ["milk"],
|
||||
isVegan: false,
|
||||
isVegetarian: true,
|
||||
containsGluten: false,
|
||||
containsLactose: true,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"pasta",
|
||||
{
|
||||
id: "pasta",
|
||||
allergens: ["gluten"],
|
||||
mayContainAllergens: ["eggs"],
|
||||
isVegan: true,
|
||||
isVegetarian: true,
|
||||
containsGluten: true,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"bacon",
|
||||
{
|
||||
id: "bacon",
|
||||
allergens: [],
|
||||
isVegan: false,
|
||||
isVegetarian: false,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: true,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"tofu",
|
||||
{
|
||||
id: "tofu",
|
||||
allergens: ["soy"],
|
||||
isVegan: true,
|
||||
isVegetarian: true,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
describe("KRITISKT: deterministisk allergisäkerhet (spec §61.2)", () => {
|
||||
it("blockerar recept med användarens allergen", () => {
|
||||
const violations = checkRecipeSafety(
|
||||
{ ingredients: [{ canonicalIngredientId: "milk", optional: false }], spiceLevel: 0 },
|
||||
{ allergens: ["milk"] },
|
||||
INFO,
|
||||
);
|
||||
expect(isRecipeSafe(violations)).toBe(false);
|
||||
expect(violations[0]?.code).toBe("allergen");
|
||||
expect(violations[0]?.severity).toBe("blocker");
|
||||
});
|
||||
it("'kan innehålla spår' ger varning när användaren har allergi", () => {
|
||||
const violations = checkRecipeSafety(
|
||||
{ ingredients: [{ canonicalIngredientId: "pasta", optional: false }], spiceLevel: 0 },
|
||||
{ allergens: ["eggs"] },
|
||||
INFO,
|
||||
);
|
||||
expect(violations.some((v) => v.code === "allergen_may_contain")).toBe(true);
|
||||
});
|
||||
it("okänd ingrediens ger varning – aldrig tyst OK (försiktighetsprincipen)", () => {
|
||||
const violations = checkRecipeSafety(
|
||||
{ ingredients: [{ canonicalIngredientId: "mystery", optional: false }], spiceLevel: 0 },
|
||||
{ allergens: [] },
|
||||
INFO,
|
||||
);
|
||||
expect(violations.some((v) => v.code === "unverified_data")).toBe(true);
|
||||
});
|
||||
it("valfri ingrediens med allergen → varning, inte blocker", () => {
|
||||
const violations = checkRecipeSafety(
|
||||
{ ingredients: [{ canonicalIngredientId: "milk", optional: true }], spiceLevel: 0 },
|
||||
{ allergens: ["milk"] },
|
||||
INFO,
|
||||
);
|
||||
expect(isRecipeSafe(violations)).toBe(true);
|
||||
expect(violations[0]?.severity).toBe("warning");
|
||||
});
|
||||
it("vegan blockerar kött och mjölk", () => {
|
||||
const violations = checkRecipeSafety(
|
||||
{
|
||||
ingredients: [
|
||||
{ canonicalIngredientId: "chicken", optional: false },
|
||||
{ canonicalIngredientId: "milk", optional: false },
|
||||
],
|
||||
spiceLevel: 0,
|
||||
},
|
||||
{ allergens: [], dietPattern: "vegan" },
|
||||
INFO,
|
||||
);
|
||||
expect(violations.filter((v) => v.code === "diet_pattern")).toHaveLength(2);
|
||||
});
|
||||
it("halal blockerar fläsk", () => {
|
||||
const violations = checkRecipeSafety(
|
||||
{ ingredients: [{ canonicalIngredientId: "bacon", optional: false }], spiceLevel: 0 },
|
||||
{ allergens: [], religiousRule: "halal" },
|
||||
INFO,
|
||||
);
|
||||
expect(isRecipeSafe(violations)).toBe(false);
|
||||
});
|
||||
it("härleder receptallergener ur ingredienserna", () => {
|
||||
expect(deriveRecipeAllergens(["pasta", "milk", "chicken"], INFO)).toEqual(["gluten", "milk"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lagermatchning (spec §17)", () => {
|
||||
it("beräknar täckning, saknat och utgående", () => {
|
||||
const result = computeCoverage(
|
||||
[
|
||||
{
|
||||
canonicalIngredientId: "chicken",
|
||||
displayNameSv: "Kyckling",
|
||||
quantity: 500,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
canonicalIngredientId: "pasta",
|
||||
displayNameSv: "Pasta",
|
||||
quantity: 300,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "1",
|
||||
canonicalIngredientId: "chicken",
|
||||
quantity: 600,
|
||||
unit: "GRAM",
|
||||
bestBeforeDate: "2026-08-03",
|
||||
},
|
||||
],
|
||||
new Map(),
|
||||
new Date("2026-08-02T00:00:00Z"), // UTC – testet ska vara sant i alla tidszoner
|
||||
);
|
||||
expect(result.coverage).toBe(0.5);
|
||||
expect(result.missing.map((m) => m.canonicalIngredientId)).toEqual(["pasta"]);
|
||||
expect(result.expiringUsed[0]?.canonicalIngredientId).toBe("chicken");
|
||||
});
|
||||
it("mjölkprincipen: passerat bäst före räknas som tillgängligt, inte som borta", () => {
|
||||
// Mjölken som gick ut igår är inte automatiskt dålig – recepten ska
|
||||
// fortsätta föreslå den (användaren luktar/smakar) i stället för att
|
||||
// tyst behandla varan som obefintlig och driva nyinköp/svinn.
|
||||
const result = computeCoverage(
|
||||
[
|
||||
{
|
||||
canonicalIngredientId: "milk",
|
||||
displayNameSv: "Mjölk",
|
||||
quantity: 500,
|
||||
unit: "MILLILITER",
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "1",
|
||||
canonicalIngredientId: "milk",
|
||||
quantity: 1000,
|
||||
unit: "MILLILITER",
|
||||
bestBeforeDate: "2026-08-01", // gick ut igår
|
||||
},
|
||||
],
|
||||
new Map(),
|
||||
new Date("2026-08-02T00:00:00Z"), // UTC – testet ska vara sant i alla tidszoner
|
||||
);
|
||||
expect(result.coverage).toBe(1);
|
||||
expect(result.missing).toHaveLength(0);
|
||||
expect(result.expiringUsed[0]?.canonicalIngredientId).toBe("milk"); // prioriteras – "räddar mjölken"
|
||||
});
|
||||
});
|
||||
|
||||
describe("skalning", () => {
|
||||
it("skalar mängder och avrundar köksvänligt", () => {
|
||||
const scaled = scaleIngredients(
|
||||
[
|
||||
{
|
||||
canonicalIngredientId: "chicken",
|
||||
displayNameSv: "Kyckling",
|
||||
quantity: 500,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
canonicalIngredientId: "salt",
|
||||
displayNameSv: "Salt",
|
||||
quantity: 1,
|
||||
unit: "TEASPOON",
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
4,
|
||||
6,
|
||||
);
|
||||
expect(scaled[0]?.quantity).toBe(750);
|
||||
expect(scaled[1]?.quantity).toBe(1.5);
|
||||
});
|
||||
it("kastar på ogiltiga portioner", () => {
|
||||
expect(() => scaleIngredients([], 0, 4)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("substitution (spec §20)", () => {
|
||||
it("byter ingrediens med mängdfaktor och varnar för fel kontext", () => {
|
||||
const result = applySubstitution(
|
||||
[
|
||||
{
|
||||
canonicalIngredientId: "milk",
|
||||
displayNameSv: "Mjölk",
|
||||
quantity: 200,
|
||||
unit: "MILLILITER",
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
{
|
||||
fromIngredientId: "milk",
|
||||
toIngredientId: "tofu",
|
||||
ratio: 0.8,
|
||||
instructionsSv: "Tillsätt sist.",
|
||||
notRecommendedFor: ["whipping"],
|
||||
},
|
||||
"Tofu",
|
||||
"whipping",
|
||||
);
|
||||
expect(result.ingredients[0]?.canonicalIngredientId).toBe("tofu");
|
||||
expect(result.ingredients[0]?.quantity).toBe(160);
|
||||
expect(result.applied.warningSv).toContain("rekommenderas inte");
|
||||
});
|
||||
it("kastar när ingrediensen inte finns i receptet", () => {
|
||||
expect(() =>
|
||||
applySubstitution(
|
||||
[],
|
||||
{ fromIngredientId: "x", toIngredientId: "y", ratio: 1, notRecommendedFor: [] },
|
||||
"Y",
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("kostnadsuppskattning (spec §26)", () => {
|
||||
it("beräknar pris per portion med täckningsgrad", () => {
|
||||
const result = estimateCost(
|
||||
[
|
||||
{ canonicalIngredientId: "chicken", quantity: 500, unit: "GRAM", optional: false },
|
||||
{ canonicalIngredientId: "unknown", quantity: 100, unit: "GRAM", optional: false },
|
||||
],
|
||||
4,
|
||||
// 130 kr/kg = 13000 minor/kg; 500 g -> 6500 minor totalt, 1625 minor/portion.
|
||||
new Map([["chicken", { pricePerKgMinor: 13_000, source: "default" as const }]]),
|
||||
);
|
||||
expect(result.totalMinor).toBe(6500);
|
||||
expect(result.perPortionMinor).toBe(1625);
|
||||
expect(result.priceCoverage).toBe(0.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user