diff --git a/apps/api/prisma/migrations/20260819210000_tech_step_catalog_key/migration.sql b/apps/api/prisma/migrations/20260819210000_tech_step_catalog_key/migration.sql new file mode 100644 index 0000000..28009bd --- /dev/null +++ b/apps/api/prisma/migrations/20260819210000_tech_step_catalog_key/migration.sql @@ -0,0 +1,18 @@ +-- Adds `TechStep.key` (`key String @unique`) and `TechStepMapping.locale` — +-- same "stable English camelCase uid, French label lives only in apps/web's +-- locales/fr/translation.json" convention as Diet/Category/Unit. `TechStep` +-- was created in the initial migration with no way to identify a row other +-- than its numeric id; this closes that gap so reference-seed-data.ts can +-- upsert it by key like every other reference catalog. `locale` lets the +-- same TechStep carry one matching rule set per language. Neither table has +-- ever been seeded (no rows exist pre-launch), so plain NOT NULL columns +-- with no backfill are safe. + +-- AlterTable +ALTER TABLE "tech_step" ADD COLUMN "key" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "tech_step_key_key" ON "tech_step"("key"); + +-- AlterTable +ALTER TABLE "tech_step_mapping" ADD COLUMN "locale" TEXT NOT NULL; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 23bfef3..fa8aa69 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -556,8 +556,13 @@ model RecipeIngredient { @@map("recipe_ingredient") } +/// `key` is `@unique` — same convention as `Diet`/`Unit`: a stable English +/// camelCase uid (e.g. `"simmer"`), not the display label — the French +/// label lives in `apps/web`'s `locales/fr/translation.json` under +/// `catalog.techSteps.` (see `reference-seed-data.ts`'s `TECH_STEPS`). model TechStep { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) + key String @unique steps Step[] mappings TechStepMapping[] @@ -565,11 +570,16 @@ model TechStep { @@map("tech_step") } -/// Used by the recipe-import pipeline to auto-detect which technique a raw -/// instruction step corresponds to (expression = text pattern, weight = match score). +/// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe +/// step's description corresponds to (expression = regex pattern tested +/// against the description, weight = tie-break score when several +/// mappings match). `locale` (e.g. `"fr"`) lets the same TechStep carry +/// one matching rule set per language — the matcher is always called with +/// a target locale and only considers mappings for that locale. model TechStepMapping { id Int @id @default(autoincrement()) techStepId Int @map("tech_step_id") + locale String expression String weight Int diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index adb2998..4340894 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -43,6 +43,147 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> { uid: "sprig", type: "COUNT", toBaseFactor: 1 }, ]; +// Cooking-technique catalog (French recipe-step normalization) — a static +// list of common instructions, each carrying one or more text-matching +// rules used by `tech-step-matcher.ts` to auto-detect which technique a +// free-text `Step.description` corresponds to. Same "English camelCase +// uid, no French label" authoring as DIETS/UNITS — the label lives in +// apps/web's locales/fr/translation.json under `catalog.techSteps.`. +// `expression` is a regex source matched (case/accent-insensitive, via +// `normalizeText`) against the step description; `weight` breaks ties when +// a description matches more than one technique's expression (highest +// weight wins) — see `tech-step-matcher.ts`'s `matchTechStep`. Specific, +// multi-word phrases ("cuire au four", "faire revenir") are weighted +// higher than the generic single-verb forms they overlap with ("cuire", +// "sauter") so the more specific technique wins when both match. `locale` +// lets the same technique carry one matching rule set per language — every +// entry below is `"fr"` for now, the field exists so other languages can +// be added later without a schema change. +export const TECH_STEPS: Array<{ + uid: string; + mappings: Array<{ locale: string; expression: string; weight: number }>; +}> = [ + { + uid: "cook", + mappings: [ + { locale: "fr", expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", weight: 10 }, + ], + }, + { + uid: "fry", + mappings: [{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 }], + }, + { + uid: "deglaze", + mappings: [{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 }], + }, + { + uid: "simmer", + mappings: [{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 }], + }, + { + uid: "boil", + mappings: [ + { locale: "fr", expression: "\\bbouill(ir|ant|ie|ies)\\b|\\b[ée]bullition\\b", weight: 12 }, + ], + }, + { + uid: "roast", + mappings: [{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 }], + }, + { + uid: "grill", + mappings: [{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 }], + }, + { + uid: "panFry", + mappings: [{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 }], + }, + { + uid: "blanch", + mappings: [{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 }], + }, + { + uid: "marinate", + mappings: [{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 }], + }, + { + uid: "chop", + mappings: [{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 }], + }, + { + uid: "peel", + mappings: [{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 }], + }, + { + uid: "mince", + mappings: [{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 }], + }, + { + uid: "mix", + mappings: [{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 }], + }, + { + uid: "whisk", + mappings: [ + { locale: "fr", expression: "\\bfouett(er|ez|é|ée|ées)\\b|\\bau fouet\\b", weight: 15 }, + ], + }, + { + uid: "foldIn", + mappings: [{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 }], + }, + { + uid: "setAside", + mappings: [{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 }], + }, + { + uid: "season", + mappings: [{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 }], + }, + { + uid: "drain", + mappings: [{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 }], + }, + { + uid: "brown", + mappings: [ + { + locale: "fr", + expression: + "\\bfaire revenir\\b|\\bfaites revenir\\b|\\bfais revenir\\b|\\bfaire dorer\\b|\\bfaites dorer\\b", + weight: 25, + }, + ], + }, + { + uid: "rest", + mappings: [ + { locale: "fr", expression: "\\blaiss(er|ez|e) reposer\\b|\\breposer\\b", weight: 20 }, + ], + }, + { + uid: "preheat", + mappings: [{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 }], + }, + { + uid: "bake", + mappings: [ + { + locale: "fr", + expression: + "\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b", + weight: 25, + }, + ], + }, + { uid: "plate", mappings: [{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 }] }, + { + uid: "coat", + mappings: [{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 }], + }, +]; + // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // businesses to declare — a standard, defensible reference list rather than // an invented one. Split into ALLERGY (classic IgE-mediated immune @@ -1125,6 +1266,39 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { }); } + // TechStep: upsert by key (same idempotent-seed reasoning as everything + // above), then fully replace its mappings on every reseed. Mappings carry + // no natural per-row identity to upsert against, and expressions/weights + // are expected to be tuned over time — a straight "delete all, recreate + // from source" keeps the table an exact mirror of `TECH_STEPS` rather + // than accumulating stale/duplicate rows from earlier edits. Nothing else + // references `TechStepMapping.id` (`Step` only points at `TechStep`, not + // at a specific mapping), so this replace is safe. + for (const { uid: key } of TECH_STEPS) { + await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } }); + } + const techSteps = await prisma.techStep.findMany({ + where: { key: { in: TECH_STEPS.map((t) => t.uid) } }, + }); + const techStepIdByKey = new Map(techSteps.map((t) => [t.key, t.id])); + + await prisma.techStepMapping.deleteMany({ + where: { techStepId: { in: [...techStepIdByKey.values()] } }, + }); + const techStepMappingRows = TECH_STEPS.flatMap(({ uid, mappings }) => { + const techStepId = techStepIdByKey.get(uid); + if (techStepId === undefined) return []; + return mappings.map(({ locale, expression, weight }) => ({ + techStepId, + locale, + expression, + weight, + })); + }); + if (techStepMappingRows.length > 0) { + await prisma.techStepMapping.createMany({ data: techStepMappingRows }); + } + // `Allergy` itself carries no `key` — it's the selectable instance of a // keyed `Category` (see schema.prisma) — so seeding an allergen means one // Category (upserted by key) plus exactly one Allergy row under it, diff --git a/apps/api/src/lib/tech-step-matcher.ts b/apps/api/src/lib/tech-step-matcher.ts new file mode 100644 index 0000000..5c8ecdf --- /dev/null +++ b/apps/api/src/lib/tech-step-matcher.ts @@ -0,0 +1,93 @@ +import { prisma } from "../db/prisma.js"; + +/** + * Auto-detects which cooking technique (`TechStep`) a free-text recipe + * step description corresponds to, using the static `TechStepMapping` + * catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) — groundwork for a + * future batch-cooking optimization algorithm, not surfaced in the recipe + * UI yet (see `StepView` in `packages/shared`). + * + * `normalizeText`/`matchTechStep` are pure (no DB access) so they can be + * unit-tested in isolation (see `test/tech-step-matcher.test.ts`). + * `loadTechStepMappingRules` is the only DB-touching piece, kept separate + * so callers (`recipe.service.ts`) fetch the whole mapping list once per + * request and pass it to `matchTechStep` per step, rather than querying + * once per step. + */ + +/** One `TechStepMapping` row, trimmed to what {@link matchTechStep} needs. */ +export interface TechStepMappingRule { + techStepId: number; + /** + * Regex source, matched against the normalized description (see + * {@link normalizeText}) — may itself contain accented characters, + * normalized the same way before compiling. + */ + expression: string; + weight: number; +} + +/** + * Lowercases and strips diacritics (NFD decomposition + removal of + * combining marks, e.g. "Déglacer" -> "deglacer") — recipe step text and + * mapping expressions are both run through this before matching, so + * expressions can be authored with natural French accents in + * `reference-seed-data.ts` while matching stays accent/case-insensitive. + */ +const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu; + +export function normalizeText(text: string): string { + return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase(); +} + +/** + * Picks the best-matching `TechStep` for a step `description` among + * `mappings` — every mapping whose (normalized) `expression` regex tests + * true against the (normalized) description is a candidate; the candidate + * with the highest `weight` wins, ties broken by lowest `techStepId` + * (equivalent to "first defined in `TECH_STEPS`", since ids are assigned + * in that array's order on first seed). Returns `null` if nothing matches. + * + * Pure — takes `mappings` as a plain argument rather than querying Prisma + * itself, so it's testable without a database (see + * `loadTechStepMappingRules` for the DB-backed loader). `mappings` should + * already be filtered to the locale the caller cares about — this function + * has no notion of locale, it just tests the rules it's given. + */ +export function matchTechStep(description: string, mappings: TechStepMappingRule[]): number | null { + const normalizedDescription = normalizeText(description); + let best: TechStepMappingRule | null = null; + + for (const mapping of mappings) { + const pattern = new RegExp(normalizeText(mapping.expression), "i"); + if (!pattern.test(normalizedDescription)) continue; + if ( + best === null || + mapping.weight > best.weight || + (mapping.weight === best.weight && mapping.techStepId < best.techStepId) + ) { + best = mapping; + } + } + + return best?.techStepId ?? null; +} + +/** + * Loads every `TechStepMapping` row for `locale` as + * {@link TechStepMappingRule}s — meant to be fetched once per request by + * `recipe.service.ts`'s `createRecipe`/`updateRecipe` and reused across + * every step of the recipe being saved, not re-queried per step. + * + * No user-language preference exists anywhere in the app yet (a single + * `"fr"` translation file, no locale field on `User`/`UserProfile`) — + * callers pass a hardcoded locale for now; this parameter exists so that + * plugging in a real user preference later doesn't require touching this + * module. + */ +export async function loadTechStepMappingRules(locale: string): Promise { + return prisma.techStepMapping.findMany({ + where: { locale }, + select: { techStepId: true, expression: true, weight: true }, + }); +} diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 0dfe196..e5ba108 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -13,6 +13,14 @@ import { } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; +import { loadTechStepMappingRules, matchTechStep } from "../../lib/tech-step-matcher.js"; + +// No user-language preference exists anywhere in the app yet (a single +// "fr" translation file, no locale field on User/UserProfile) — steps are +// matched against this hardcoded locale for now. See +// `tech-step-matcher.ts`'s `loadTechStepMappingRules` for why the locale is +// a parameter rather than baked into that module. +const DEFAULT_TECH_STEP_LOCALE = "fr"; /** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */ function recipeInclude(viewerId: number) { @@ -312,6 +320,7 @@ export async function createRecipe( await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertDietsExist(input.dietIds); + const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE); const created = await prisma.recipe.create({ data: { @@ -334,6 +343,7 @@ export async function createRecipe( description: step.description, picture: step.picture ?? null, order: index, + techStepId: matchTechStep(step.description, techStepMappings), })), }, diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, @@ -366,6 +376,7 @@ export async function updateRecipe( await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertDietsExist(input.dietIds); + const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE); await prisma.$transaction([ prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }), @@ -391,6 +402,7 @@ export async function updateRecipe( description: step.description, picture: step.picture ?? null, order: index, + techStepId: matchTechStep(step.description, techStepMappings), })), }, diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, diff --git a/apps/api/src/modules/reference/reference.routes.ts b/apps/api/src/modules/reference/reference.routes.ts index 46f3fe3..84948e3 100644 --- a/apps/api/src/modules/reference/reference.routes.ts +++ b/apps/api/src/modules/reference/reference.routes.ts @@ -1,6 +1,12 @@ import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { Router } from "express"; -import { getAllergies, getDiets, getIngredients, getUnits } from "./reference.service.js"; +import { + getAllergies, + getDiets, + getIngredients, + getTechSteps, + getUnits, +} from "./reference.service.js"; /** * Router mounted at `/reference` in app.ts. Every route is deliberately @@ -40,3 +46,10 @@ referenceRouter.get( res.status(200).json(await getUnits()); }), ); + +referenceRouter.get( + "/tech-steps", + wrapAsyncHandler(async (_req, res) => { + res.status(200).json(await getTechSteps()); + }), +); diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 3e212dd..78dadc9 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -1,4 +1,10 @@ -import type { AllergyView, DietView, IngredientView, UnitView } from "@batch-cooking/shared"; +import type { + AllergyView, + DietView, + IngredientView, + TechStepView, + UnitView, +} from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; /** @@ -49,6 +55,15 @@ export async function getUnits(): Promise { })); } +/** + * All reference cooking techniques, ordered by key (see {@link getDiets} + * for why) — small, static list (see `reference-seed-data.ts`'s + * `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}. + */ +export async function getTechSteps(): Promise { + return prisma.techStep.findMany({ orderBy: { key: "asc" } }); +} + /** * All reference ingredients, ordered by key (see {@link getDiets} for why), * each resolved to its allergens (see `IngredientAllergy` in schema.prisma) diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 3528c10..0ed2192 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -31,6 +31,12 @@ async function unitId(key: string): Promise { return unit.id; } +/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */ +async function techStepId(key: string): Promise { + const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } }); + return techStep.id; +} + describe("Recipes", () => { const app = createApp(); @@ -233,6 +239,43 @@ describe("Recipes", () => { expect(houseRes.body.id).to.be.a("number"); // house exists, sanity check }); + it("auto-detects a step's technique from its description and persists techStepId", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + const simmer = await techStepId("simmer"); + + const res = await agent.post("/recipes").send({ + name: "Ragoût", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [{ description: "Faire mijoter à feu doux pendant 30 minutes" }], + }); + + expect(res.status).to.equal(201); + // techStepId isn't in the API response (see StepView) — check via Prisma directly. + const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); + expect(step.techStepId).to.equal(simmer); + }); + + it("leaves techStepId null when a step's description matches no known technique", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + + const res = await agent.post("/recipes").send({ + name: "Test", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [{ description: "Servir immédiatement" }], + }); + + const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); + expect(step.techStepId).to.be.null; + }); + it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => { const { agent } = await signup(); const piece = await unitId("piece"); @@ -445,6 +488,32 @@ describe("Recipes", () => { expect(res.body.steps).to.have.length(2); }); + it("recomputes techStepId for the replaced steps", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + const mince = await techStepId("mince"); + const created = await agent.post("/recipes").send({ + name: "Salade", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [{ description: "Servir immédiatement" }], + }); + + const res = await agent.patch(`/recipes/${created.body.id}`).send({ + name: "Salade", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [{ description: "Émincer les tomates" }], + }); + + expect(res.status).to.equal(200); + const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.body.id } }); + expect(step.techStepId).to.equal(mince); + }); + it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index dd8bb46..e8dda4e 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -96,4 +96,15 @@ describe("Reference data", () => { expect(byKey("pinch")).to.include({ type: "COUNT", toBaseFactor: 1 }); }); }); + + describe("GET /reference/tech-steps", () => { + it("returns the seeded techniques, no session required", async () => { + const res = await request(app).get("/reference/tech-steps"); + + expect(res.status).to.equal(200); + expect(res.body).to.have.length(25); + expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer"); + expect(res.body[0]).to.have.keys(["id", "key"]); + }); + }); }); diff --git a/apps/api/test/tech-step-matcher.test.ts b/apps/api/test/tech-step-matcher.test.ts new file mode 100644 index 0000000..b79de6e --- /dev/null +++ b/apps/api/test/tech-step-matcher.test.ts @@ -0,0 +1,59 @@ +import { expect } from "chai"; +import { + type TechStepMappingRule, + matchTechStep, + normalizeText, +} from "../src/lib/tech-step-matcher.js"; + +describe("tech-step-matcher", () => { + describe("normalizeText", () => { + it("lowercases and strips accents", () => { + expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four"); + }); + }); + + describe("matchTechStep", () => { + const simmer: TechStepMappingRule = { + techStepId: 1, + expression: "\\bmijot(er|ez|e|ant|é)\\b", + weight: 15, + }; + const cook: TechStepMappingRule = { + techStepId: 2, + expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", + weight: 10, + }; + const bake: TechStepMappingRule = { + techStepId: 3, + expression: + "\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b", + weight: 25, + }; + + it("matches an exact expression", () => { + expect(matchTechStep("Faire mijoter à feu doux", [simmer])).to.equal(1); + }); + + it("is case- and accent-insensitive", () => { + expect(matchTechStep("FAIRE MIJOTER", [simmer])).to.equal(1); + expect(matchTechStep("faire mijoter", [simmer])).to.equal(1); + }); + + it("returns null when nothing matches", () => { + expect(matchTechStep("Servir immédiatement", [simmer, cook, bake])).to.be.null; + }); + + it("picks the highest-weight match when several mappings match", () => { + // "Cuire au four" matches both `cook` (weight 10) and `bake` (weight 25). + expect(matchTechStep("Cuire au four pendant 30 minutes", [cook, bake])).to.equal(3); + // Order-independent. + expect(matchTechStep("Cuire au four pendant 30 minutes", [bake, cook])).to.equal(3); + }); + + it("breaks a weight tie by lowest techStepId", () => { + const a: TechStepMappingRule = { techStepId: 5, expression: "\\bmelanger\\b", weight: 10 }; + const b: TechStepMappingRule = { techStepId: 2, expression: "\\bmelanger\\b", weight: 10 }; + expect(matchTechStep("Mélanger les ingrédients", [a, b])).to.equal(2); + }); + }); +}); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 6a444ac..abb0cb3 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -343,6 +343,33 @@ "pescatarian": "Pescétarien", "glutenFree": "Sans gluten" }, + "techSteps": { + "cook": "Cuire", + "fry": "Frire", + "deglaze": "Déglacer", + "simmer": "Mijoter", + "boil": "Bouillir", + "roast": "Rôtir", + "grill": "Griller", + "panFry": "Sauter", + "blanch": "Blanchir", + "marinate": "Mariner", + "chop": "Hacher", + "peel": "Éplucher", + "mince": "Émincer", + "mix": "Mélanger", + "whisk": "Fouetter", + "foldIn": "Incorporer", + "setAside": "Réserver", + "season": "Assaisonner", + "drain": "Égoutter", + "brown": "Faire revenir", + "rest": "Laisser reposer", + "preheat": "Préchauffer", + "bake": "Cuire au four", + "plate": "Dresser", + "coat": "Napper" + }, "allergens": { "gluten": "Gluten", "crustaceans": "Crustacés", diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index 5af48e3..4ae1968 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -187,6 +187,28 @@ export interface UnitView { toBaseFactor: number; } +/** + * A cooking technique, as returned by `GET /reference/tech-steps` — + * reference data (`TechStep`, seeded via `reference-seed-data.ts`'s + * `TECH_STEPS`), same static/non-administrable status as + * {@link DietView}/{@link UnitView}. + * + * Not currently surfaced in the recipe UI — `Step.techStepId` is computed + * server-side at save time (see `recipe.service.ts`'s `createRecipe`/ + * `updateRecipe`, via `tech-step-matcher.ts`) but deliberately excluded + * from `StepView` (see its doc comment in `types/recipe.ts`). This + * endpoint exists for consistency with the other reference catalogs and + * for future admin/inspection tooling. + * + * `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display + * label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as + * {@link DietView.key}. + */ +export interface TechStepView { + id: number; + key: string; +} + /** * A selectable ingredient, as returned by `GET /reference/ingredients` — * reference data (`Ingredient`, seeded via `apps/api/src/db/