Merge pull request #34 from kyuno053/feat/tech-steps-catalog
feat(recipes): catalogue de techniques culinaires (tech steps)
This commit is contained in:
commit
4a163b9317
15 changed files with 895 additions and 13 deletions
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
-- Replaces `Step.tech_step_id` (single nullable FK — at most one technique
|
||||
-- per step) with `step_tech_step`, an ordered join table — a step can
|
||||
-- genuinely involve more than one technique (e.g. "Dans une poêle chaude,
|
||||
-- faire chauffer une noix de beurre" is both `preheat` and `melt`). Per PR
|
||||
-- review feedback on the first version of this feature; `tech_step`/`step`
|
||||
-- have never carried real recipe data yet (this feature isn't released),
|
||||
-- so no backfill is needed.
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "step" DROP CONSTRAINT "step_tech_step_id_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "step" DROP COLUMN "tech_step_id";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "step_tech_step" (
|
||||
"step_id" INTEGER NOT NULL,
|
||||
"tech_step_id" INTEGER NOT NULL,
|
||||
"order" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "step_tech_step_pkey" PRIMARY KEY ("step_id", "order")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -556,20 +556,32 @@ 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.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
|
||||
model TechStep {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique
|
||||
|
||||
steps Step[]
|
||||
steps StepTechStep[]
|
||||
mappings TechStepMapping[]
|
||||
|
||||
@@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, or overlap-resolution score when two mappings match the
|
||||
/// same span of text — see `matchTechSteps`). `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
|
||||
|
||||
|
|
@ -588,10 +600,30 @@ model Step {
|
|||
description String
|
||||
picture String?
|
||||
order Int
|
||||
techStepId Int? @map("tech_step_id")
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull)
|
||||
techSteps StepTechStep[]
|
||||
|
||||
@@map("step")
|
||||
}
|
||||
|
||||
/// A single step's *ordered sequence* of detected techniques — one
|
||||
/// instruction can genuinely involve more than one (e.g. "Dans une poêle
|
||||
/// chaude, faire chauffer une noix de beurre" is both `preheat` and
|
||||
/// `melt`), which is why this replaced the original single nullable
|
||||
/// `Step.techStepId` FK (per PR review feedback on the first version of
|
||||
/// this feature). `order` is the position within *this step* (0-based, in
|
||||
/// the order `matchTechSteps` — `tech-step-matcher.ts` — detected the
|
||||
/// techniques in the description), not a global ordering across different
|
||||
/// steps of the recipe (that's `Step.order`).
|
||||
model StepTechStep {
|
||||
stepId Int @map("step_id")
|
||||
techStepId Int @map("tech_step_id")
|
||||
order Int
|
||||
|
||||
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
||||
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([stepId, order])
|
||||
@@map("step_tech_step")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,161 @@ 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(s) a
|
||||
// free-text `Step.description` corresponds to (a step can mention several,
|
||||
// e.g. "faire chauffer une poêle puis y faire fondre le beurre" is both
|
||||
// `preheat` and `melt` — see `Step.techSteps`/`StepTechStep` in
|
||||
// schema.prisma). 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.<key>`.
|
||||
// `expression` is a regex source matched (case/accent-insensitive, via
|
||||
// `normalizeText`) against the step description; `weight` breaks ties when
|
||||
// two *different* techniques' expressions match the same span of text
|
||||
// (highest weight wins) — see `tech-step-matcher.ts`'s `matchTechSteps`.
|
||||
// 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
|
||||
// the same words. `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: "melt",
|
||||
mappings: [
|
||||
{
|
||||
locale: "fr",
|
||||
expression:
|
||||
"\\bfondre\\b|\\bfondu(e|es|s)?\\b|\\bfaire fondre\\b|\\bfaites fondre\\b|\\bfaire chauffer\\b|\\bfaites chauffer\\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 +1280,39 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
146
apps/api/src/lib/tech-step-matcher.ts
Normal file
146
apps/api/src/lib/tech-step-matcher.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { prisma } from "../db/prisma.js";
|
||||
|
||||
/**
|
||||
* Auto-detects which cooking techniques (`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`).
|
||||
*
|
||||
* A single instruction can genuinely involve more than one technique (e.g.
|
||||
* "Dans une poêle chaude, faire chauffer une noix de beurre" is both
|
||||
* `preheat` and `melt`) — `matchTechSteps` returns the whole *ordered
|
||||
* sequence* it finds, not a single winner, matching `Step.techSteps`
|
||||
* (schema.prisma's `StepTechStep`, an ordered join table).
|
||||
*
|
||||
* `normalizeText`/`matchTechSteps` 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 `matchTechSteps` per step, rather than querying
|
||||
* once per step.
|
||||
*/
|
||||
|
||||
/** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} 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();
|
||||
}
|
||||
|
||||
/** Where in the (normalized) description one mapping matched, alongside the rule that matched — the raw material {@link matchTechSteps} resolves into a final sequence. */
|
||||
interface MatchCandidate extends TechStepMappingRule {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Whether two candidates' matched spans share any character position — the case where two *different* techniques' expressions matched the same words (e.g. generic `cook`'s "cuire" inside specific `bake`'s "cuire au four"), meaning only one of them should survive. */
|
||||
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
|
||||
return a.start < b.end && b.start < a.end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects every technique `description` mentions among `mappings`, as an
|
||||
* ordered sequence of `techStepId`s — empty if none match. The algorithm:
|
||||
*
|
||||
* 1. Test every mapping against the normalized description; each one that
|
||||
* matches becomes a candidate carrying *where* it matched (so
|
||||
* overlapping matches can be compared).
|
||||
* 2. Within a single technique, several of its own mappings might all
|
||||
* match (different phrasings for the same `techStepId`) — keep only
|
||||
* that technique's best candidate (highest weight, ties broken by
|
||||
* earliest match), the same tie-break this function always used for a
|
||||
* single winner.
|
||||
* 3. Across *different* techniques, two candidates can still overlap (a
|
||||
* generic pattern matching inside a more specific one's span, e.g.
|
||||
* `cook` vs `bake` both matching "cuire au four") — resolve greedily by
|
||||
* weight: take candidates highest-weight first, accept a candidate only
|
||||
* if it doesn't overlap one already accepted. This is what keeps
|
||||
* `bake` and drops the redundant `cook` for that phrase, while letting
|
||||
* two genuinely distinct, non-overlapping techniques (e.g. `preheat`
|
||||
* and `melt` in "Dans une poêle chaude, faire chauffer une noix de
|
||||
* beurre") both survive.
|
||||
* 4. Sort what's left by where it appears in the text — the sequence
|
||||
* reads in the same order as the instruction itself.
|
||||
*
|
||||
* 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 matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] {
|
||||
const normalizedDescription = normalizeText(description);
|
||||
|
||||
const candidates: MatchCandidate[] = [];
|
||||
for (const mapping of mappings) {
|
||||
const pattern = new RegExp(normalizeText(mapping.expression), "i");
|
||||
const match = pattern.exec(normalizedDescription);
|
||||
if (match === null) continue;
|
||||
candidates.push({ ...mapping, start: match.index, end: match.index + match[0].length });
|
||||
}
|
||||
|
||||
// Step 2: one best candidate per techStepId.
|
||||
const bestByTechStep = new Map<number, MatchCandidate>();
|
||||
for (const candidate of candidates) {
|
||||
const current = bestByTechStep.get(candidate.techStepId);
|
||||
if (
|
||||
current === undefined ||
|
||||
candidate.weight > current.weight ||
|
||||
(candidate.weight === current.weight && candidate.start < current.start)
|
||||
) {
|
||||
bestByTechStep.set(candidate.techStepId, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: resolve cross-technique overlaps, highest weight first.
|
||||
const byWeightDesc = [...bestByTechStep.values()].sort(
|
||||
(a, b) => b.weight - a.weight || a.techStepId - b.techStepId,
|
||||
);
|
||||
const accepted: MatchCandidate[] = [];
|
||||
for (const candidate of byWeightDesc) {
|
||||
if (accepted.some((other) => overlaps(candidate, other))) continue;
|
||||
accepted.push(candidate);
|
||||
}
|
||||
|
||||
// Step 4: reading order.
|
||||
accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
|
||||
return accepted.map((candidate) => candidate.techStepId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TechStepMappingRule[]> {
|
||||
return prisma.techStepMapping.findMany({
|
||||
where: { locale },
|
||||
select: { techStepId: true, expression: true, weight: true },
|
||||
});
|
||||
}
|
||||
|
|
@ -13,6 +13,14 @@ import {
|
|||
} from "@batch-cooking/shared";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { loadTechStepMappingRules, matchTechSteps } 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,12 @@ export async function createRecipe(
|
|||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
techSteps: {
|
||||
create: matchTechSteps(step.description, techStepMappings).map((techStepId, order) => ({
|
||||
techStepId,
|
||||
order,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
|
|
@ -366,6 +381,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 +407,14 @@ export async function updateRecipe(
|
|||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
techSteps: {
|
||||
create: matchTechSteps(step.description, techStepMappings).map(
|
||||
(techStepId, order) => ({
|
||||
techStepId,
|
||||
order,
|
||||
}),
|
||||
),
|
||||
},
|
||||
})),
|
||||
},
|
||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<UnitView[]> {
|
|||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TechStepView[]> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export async function resetDatabase() {
|
|||
TRUNCATE TABLE
|
||||
"user_profile_allergy", "user_preference", "allergy", "category",
|
||||
"planning_item", "planning",
|
||||
"recipe_ingredient", "step", "tech_step_mapping", "tech_step",
|
||||
"recipe_ingredient", "step_tech_step", "step", "tech_step_mapping", "tech_step",
|
||||
"recipe", "ingredients", "sources", "unit",
|
||||
"user_profiles", "diet", "house"
|
||||
RESTART IDENTITY CASCADE;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ async function unitId(key: string): Promise<number> {
|
|||
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<number> {
|
||||
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
|
||||
return techStep.id;
|
||||
}
|
||||
|
||||
/** A step's detected technique sequence, in order — mirrors `matchTechSteps`' return shape (`../src/lib/tech-step-matcher.js`) so tests can assert on it directly. */
|
||||
async function stepTechStepIds(stepId: number): Promise<number[]> {
|
||||
const links = await prisma.stepTechStep.findMany({
|
||||
where: { stepId },
|
||||
orderBy: { order: "asc" },
|
||||
select: { techStepId: true },
|
||||
});
|
||||
return links.map((link) => link.techStepId);
|
||||
}
|
||||
|
||||
describe("Recipes", () => {
|
||||
const app = createApp();
|
||||
|
||||
|
|
@ -233,6 +249,115 @@ 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 it", 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);
|
||||
// Not in the API response (see StepView) — check via Prisma directly.
|
||||
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
|
||||
expect(await stepTechStepIds(step.id)).to.deep.equal([simmer]);
|
||||
});
|
||||
|
||||
it("leaves a step's technique sequence empty when its 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(await stepTechStepIds(step.id)).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("detects each step's technique(s) independently, preserving order", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
const simmer = await techStepId("simmer");
|
||||
const chop = await techStepId("chop");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Ragoût",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
steps: [
|
||||
{ description: "Hacher les oignons" },
|
||||
{ description: "Servir immédiatement" },
|
||||
{ description: "Faire mijoter à feu doux" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
const steps = await prisma.step.findMany({
|
||||
where: { recipeId: res.body.id },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
expect(await Promise.all(steps.map((s) => stepTechStepIds(s.id)))).to.deep.equal([
|
||||
[chop],
|
||||
[],
|
||||
[simmer],
|
||||
]);
|
||||
});
|
||||
|
||||
it("picks the more specific technique end-to-end when a description matches more than one", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
const bake = await techStepId("bake");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Gratin",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
// Matches both `cook` (weight 10) and `bake` (weight 25, "au four").
|
||||
steps: [{ description: "Cuire au four pendant 30 minutes" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
|
||||
expect(await stepTechStepIds(step.id)).to.deep.equal([bake]);
|
||||
});
|
||||
|
||||
it("detects a sequence of several distinct techniques within a single step, in reading order", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
const preheat = await techStepId("preheat");
|
||||
const melt = await techStepId("melt");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Poêlée",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
|
||||
// The case that motivated the sequence model: one instruction, two techniques.
|
||||
steps: [{ description: "Préchauffer la poêle, puis faire fondre le beurre" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
|
||||
expect(await stepTechStepIds(step.id)).to.deep.equal([preheat, melt]);
|
||||
});
|
||||
|
||||
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
||||
const { agent } = await signup();
|
||||
const piece = await unitId("piece");
|
||||
|
|
@ -445,6 +570,32 @@ describe("Recipes", () => {
|
|||
expect(res.body.steps).to.have.length(2);
|
||||
});
|
||||
|
||||
it("recomputes each replaced step's technique sequence", 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(await stepTechStepIds(step.id)).to.deep.equal([mince]);
|
||||
});
|
||||
|
||||
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { expect } from "chai";
|
|||
import request from "supertest";
|
||||
import { createApp } from "../src/app.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
describe("Reference data", () => {
|
||||
|
|
@ -96,4 +97,32 @@ 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(26);
|
||||
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||
expect(res.body[0]).to.have.keys(["id", "key"]);
|
||||
});
|
||||
|
||||
it("orders techniques alphabetically by key", async () => {
|
||||
const res = await request(app).get("/reference/tech-steps");
|
||||
|
||||
const keys = res.body.map((t: { key: string }) => t.key);
|
||||
expect(keys).to.deep.equal([...keys].sort());
|
||||
});
|
||||
|
||||
it("reseeding is idempotent — no duplicate techniques or mappings", async () => {
|
||||
// resetDatabase already seeded once in beforeEach; seed a second time
|
||||
// on top of that without truncating, the way a redeploy would.
|
||||
await seedReferenceData(prisma);
|
||||
|
||||
const res = await request(app).get("/reference/tech-steps");
|
||||
expect(res.body).to.have.length(26);
|
||||
expect(await prisma.techStepMapping.count()).to.equal(26);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
185
apps/api/test/tech-step-matcher.test.ts
Normal file
185
apps/api/test/tech-step-matcher.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { expect } from "chai";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import {
|
||||
type TechStepMappingRule,
|
||||
loadTechStepMappingRules,
|
||||
matchTechSteps,
|
||||
normalizeText,
|
||||
} from "../src/lib/tech-step-matcher.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
describe("tech-step-matcher", () => {
|
||||
describe("normalizeText", () => {
|
||||
it("lowercases and strips accents", () => {
|
||||
expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four");
|
||||
});
|
||||
|
||||
it("strips a variety of diacritics, including cedilla", () => {
|
||||
expect(normalizeText("Façon Œuf à l'Étouffée")).to.equal("facon œuf a l'etouffee");
|
||||
});
|
||||
|
||||
it("leaves already-plain text unchanged, aside from casing", () => {
|
||||
expect(normalizeText("Mix everything")).to.equal("mix everything");
|
||||
});
|
||||
|
||||
it("returns an empty string for an empty input", () => {
|
||||
expect(normalizeText("")).to.equal("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchTechSteps", () => {
|
||||
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,
|
||||
};
|
||||
const preheat: TechStepMappingRule = {
|
||||
techStepId: 4,
|
||||
expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b",
|
||||
weight: 20,
|
||||
};
|
||||
const melt: TechStepMappingRule = {
|
||||
techStepId: 5,
|
||||
expression: "\\bfondre\\b|\\bfaire fondre\\b|\\bfaites fondre\\b",
|
||||
weight: 15,
|
||||
};
|
||||
|
||||
it("matches an exact expression", () => {
|
||||
expect(matchTechSteps("Faire mijoter à feu doux", [simmer])).to.deep.equal([1]);
|
||||
});
|
||||
|
||||
it("is case- and accent-insensitive, on both the description and the expression itself", () => {
|
||||
// `simmer`'s own expression source contains a literal "é" — exercises
|
||||
// normalizeText being applied to the expression, not just the description.
|
||||
expect(matchTechSteps("FAIRE MIJOTER", [simmer])).to.deep.equal([1]);
|
||||
expect(matchTechSteps("faire mijote", [simmer])).to.deep.equal([1]);
|
||||
});
|
||||
|
||||
it("returns an empty sequence when nothing matches", () => {
|
||||
expect(matchTechSteps("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("returns an empty sequence for an empty mappings list", () => {
|
||||
expect(matchTechSteps("Faire mijoter à feu doux", [])).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("returns an empty sequence for an empty description", () => {
|
||||
expect(matchTechSteps("", [simmer, cook, bake])).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("detects several distinct, non-overlapping techniques as an ordered sequence", () => {
|
||||
// The motivating case: "Dans une poêle chaude, faire chauffer une noix
|
||||
// de beurre" involves both preheating and melting — a step can name
|
||||
// more than one technique, in the order they're mentioned.
|
||||
expect(
|
||||
matchTechSteps("Préchauffer la poêle, puis faire fondre le beurre", [preheat, melt]),
|
||||
).to.deep.equal([4, 5]);
|
||||
// Order in the output follows order of mention in the text, not
|
||||
// argument order.
|
||||
expect(
|
||||
matchTechSteps("Préchauffer la poêle, puis faire fondre le beurre", [melt, preheat]),
|
||||
).to.deep.equal([4, 5]);
|
||||
});
|
||||
|
||||
it("reverses the sequence when the techniques are mentioned in the opposite order", () => {
|
||||
expect(
|
||||
matchTechSteps("Faire fondre le beurre puis préchauffer le four", [preheat, melt]),
|
||||
).to.deep.equal([5, 4]);
|
||||
});
|
||||
|
||||
it("keeps only the highest-weight technique when two different techniques' expressions overlap the same words", () => {
|
||||
// "Cuire au four" matches both `cook` (weight 10) and `bake` (weight
|
||||
// 25) at essentially the same span — only the more specific `bake`
|
||||
// should survive, not both.
|
||||
expect(matchTechSteps("Cuire au four pendant 30 minutes", [cook, bake])).to.deep.equal([3]);
|
||||
// Order-independent.
|
||||
expect(matchTechSteps("Cuire au four pendant 30 minutes", [bake, cook])).to.deep.equal([3]);
|
||||
});
|
||||
|
||||
it("still keeps a non-overlapping technique alongside an overlap-resolved one", () => {
|
||||
// `bake` wins over `cook` for "cuire au four" (overlap), but `melt`
|
||||
// matches an entirely different, non-overlapping span and survives.
|
||||
const result = matchTechSteps("Faire fondre le beurre, puis cuire au four", [
|
||||
cook,
|
||||
bake,
|
||||
melt,
|
||||
]);
|
||||
expect(result).to.deep.equal([5, 3]);
|
||||
});
|
||||
|
||||
it("breaks a same-span 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(matchTechSteps("Mélanger les ingrédients", [a, b])).to.deep.equal([2]);
|
||||
});
|
||||
|
||||
it("still resolves to one techStep when two of its own mappings both match", () => {
|
||||
const wholeWord: TechStepMappingRule = {
|
||||
techStepId: 7,
|
||||
expression: "\\bmijoter\\b",
|
||||
weight: 15,
|
||||
};
|
||||
const withAdverb: TechStepMappingRule = {
|
||||
techStepId: 7,
|
||||
expression: "\\bmijoter à feu doux\\b",
|
||||
weight: 15,
|
||||
};
|
||||
expect(matchTechSteps("Faire mijoter à feu doux", [wholeWord, withAdverb])).to.deep.equal([
|
||||
7,
|
||||
]);
|
||||
});
|
||||
|
||||
it("respects word boundaries — a technique's verb embedded in a longer word doesn't false-positive", () => {
|
||||
// "recuire"/"précuit" contain "cuire"/"cuit" as a substring, but not as
|
||||
// a standalone word — the \b-anchored expression must not match them.
|
||||
expect(matchTechSteps("Faire recuire la sauce", [cook])).to.deep.equal([]);
|
||||
expect(matchTechSteps("Un plat précuit", [cook])).to.deep.equal([]);
|
||||
// The standalone forms still match.
|
||||
expect(matchTechSteps("Faire cuire la sauce", [cook])).to.deep.equal([2]);
|
||||
expect(matchTechSteps("Le riz est cuit", [cook])).to.deep.equal([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadTechStepMappingRules", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("only returns mappings for the requested locale", async () => {
|
||||
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
|
||||
await prisma.techStepMapping.create({
|
||||
data: { techStepId: simmer.id, locale: "en", expression: "\\bsimmer\\b", weight: 15 },
|
||||
});
|
||||
|
||||
// The seeded catalog (26 "fr" mappings) must be untouched by the extra
|
||||
// "en" row — same count, and none of them carry the English expression.
|
||||
const frRules = await loadTechStepMappingRules("fr");
|
||||
expect(frRules).to.have.length(26);
|
||||
expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b");
|
||||
|
||||
const enRules = await loadTechStepMappingRules("en");
|
||||
expect(enRules).to.deep.equal([
|
||||
{ techStepId: simmer.id, expression: "\\bsimmer\\b", weight: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list for a locale with no mappings at all", async () => {
|
||||
expect(await loadTechStepMappingRules("de")).to.deep.equal([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -343,6 +343,34 @@
|
|||
"pescatarian": "Pescétarien",
|
||||
"glutenFree": "Sans gluten"
|
||||
},
|
||||
"techSteps": {
|
||||
"cook": "Cuire",
|
||||
"fry": "Frire",
|
||||
"melt": "Faire fondre",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ export interface RecipeIngredientView {
|
|||
}
|
||||
|
||||
/**
|
||||
* A single preparation step within a recipe, in `order`. `tech_step` is
|
||||
* deliberately not surfaced here — it's tied to the (not yet built) recipe
|
||||
* import pipeline, out of scope for the manually-authored catalog.
|
||||
* A single preparation step within a recipe, in `order`. Its detected
|
||||
* technique sequence (`Step.techSteps`/`StepTechStep` in schema.prisma,
|
||||
* auto-computed at save time via `tech-step-matcher.ts`) is deliberately
|
||||
* not surfaced here — groundwork for a future batch-cooking optimization
|
||||
* algorithm, not yet consumed by any UI.
|
||||
*/
|
||||
export interface StepView {
|
||||
id: number;
|
||||
|
|
|
|||
|
|
@ -187,6 +187,29 @@ 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 — a step's technique sequence
|
||||
* (`Step.techSteps`/`StepTechStep` in schema.prisma) is computed
|
||||
* server-side at save time (see `recipe.service.ts`'s `createRecipe`/
|
||||
* `updateRecipe`, via `tech-step-matcher.ts`'s `matchTechSteps`) 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/
|
||||
|
|
|
|||
Loading…
Reference in a new issue