feat(recept): Baka-kategori + 16 nya redaktionella recept

- Ny 'baking'-tagg (RECIPE_TAGS, text[] - ingen migration). Browse-vyn far
  en 'Baka'-chip som filtrerar pa taggen; befintliga lussekatter +
  saffransbullar taggas ocksa.
- extra-recipes.ts: 16 nya recept som fyller de glesa kategorierna -
  4 bak (kanelbullar, kardemummabullar, appelsmulpaj, frukostfrallor),
  3 frukost, 3 mellanmal (tomma kategorin -> fylld), 3 efterratt, 3 lunch.
- Endast verifierade canonical-ingredienser + berakningsbara enheter, sa
  seed harleder narings-/allergendata utan att krascha (run.ts 675/691).
- Nytt rent invariant-test (ingen DB) kor riktiga computeRecipeNutrition pa
  alla 16: id:n finns, naring berakningsbar, inga slug-krockar. 5 tester grona.
- Seed ar additiv (onConflictDoUpdate) - 'pnpm db:seed' lagger till + taggar
  om utan dataforlust. Full typecheck 20/20.
This commit is contained in:
Claude
2026-08-18 23:35:47 +00:00
parent 7d0fca0dad
commit 100d70dc2d
6 changed files with 799 additions and 12 deletions
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { computeRecipeNutrition, type IngredientNutritionSource } from "@app/nutrition-engine";
import { SEED_INGREDIENTS } from "../src/seed/data/ingredients.js";
import { EXTRA_RECIPES } from "../src/seed/data/extra-recipes.js";
import { VERIFIED_RECIPES } from "../src/seed/data/verified-recipes.js";
/**
* Rent invariant-test (ingen DB) för de nya extrarecepten. Speglar exakt de
* kontroller som seed-körningen gör (run.ts rad 675 + 691) så att ett trasigt
* recept fångas här i stället för mitt i en seed-körning:
* - varje ingrediens-id måste finnas i katalogen,
* - näringen måste gå att beräkna för varje ingrediens (rätt enhet),
* - inga slug-krockar med den befintliga katalogen.
*/
const byId = new Map(SEED_INGREDIENTS.map((i) => [i.id, i]));
function sourcesFor(
recipe: (typeof EXTRA_RECIPES)[number],
): Map<string, IngredientNutritionSource> {
const sources = new Map<string, IngredientNutritionSource>();
for (const ri of recipe.ingredients) {
const info = byId.get(ri.ing);
if (!info) continue; // fångas separat av id-testet nedan
sources.set(ri.ing, {
nutritionPer100: info.nutritionPer100,
densityGPerMl: info.densityGPerMl ?? null,
gramsPerPiece: info.gramsPerPiece ?? null,
});
}
return sources;
}
describe("extrarecept seed-invarianter", () => {
it("har minst 16 recept", () => {
expect(EXTRA_RECIPES.length).toBeGreaterThanOrEqual(16);
});
it("alla ingrediens-id:n finns i katalogen (run.ts rad 675)", () => {
const unknown: string[] = [];
for (const recipe of EXTRA_RECIPES) {
for (const ri of recipe.ingredients) {
if (!byId.has(ri.ing)) unknown.push(`${recipe.slug}: ${ri.ing}`);
}
}
expect(unknown).toEqual([]);
});
it("näringen går att beräkna för varje recept (run.ts rad 691)", () => {
const failed: string[] = [];
for (const recipe of EXTRA_RECIPES) {
const calc = recipe.ingredients.map((ri) => ({
canonicalIngredientId: ri.ing,
quantity: ri.qty,
unit: ri.unit,
optional: ri.optional ?? false,
}));
const nutrition = computeRecipeNutrition(calc, recipe.portions, sourcesFor(recipe));
if (nutrition.uncomputableIngredientIds.length > 0) {
failed.push(`${recipe.slug}: ${nutrition.uncomputableIngredientIds.join(", ")}`);
}
// Rimlig näring per portion (fångar grova enhetsfel).
expect(nutrition.perPortion.kcal).toBeGreaterThan(0);
}
expect(failed).toEqual([]);
});
it("inga slug-krockar med befintliga recept och inga interna dubbletter", () => {
const existing = new Set(VERIFIED_RECIPES.map((r) => r.slug));
const seen = new Set<string>();
const collisions: string[] = [];
for (const recipe of EXTRA_RECIPES) {
if (existing.has(recipe.slug)) collisions.push(`krock: ${recipe.slug}`);
if (seen.has(recipe.slug)) collisions.push(`dubblett: ${recipe.slug}`);
seen.add(recipe.slug);
}
expect(collisions).toEqual([]);
});
it("varje bak-recept är taggat 'baking' (driver Baka-kategorin)", () => {
const baking = EXTRA_RECIPES.filter((r) => r.tags.includes("baking"));
expect(baking.length).toBeGreaterThanOrEqual(4);
});
});