diff --git a/apps/api/src/lib/recipe-translation.ts b/apps/api/src/lib/recipe-translation.ts new file mode 100644 index 0000000..6eedfac --- /dev/null +++ b/apps/api/src/lib/recipe-translation.ts @@ -0,0 +1,86 @@ +import type { ParsedRecipe, ParsedRecipeStep } from "./recipe-source-adapter.js"; +import { + type TechStepMappingRule, + loadTechStepMappingRules, + matchTechSteps, +} from "./tech-step-matcher.js"; + +/** + * The "Traduction en étapes" stage of the import pipeline described in + * specs/batch-cooking-architecture.md (Import depuis source → **Traduction + * en étapes** → Sauvegarde) — takes a source-agnostic {@link ParsedRecipe} + * (recipe-source-adapter.ts's `parse()` output) and declares each step's + * technique sequence, the same `techStepIds: number[]` shape + * `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist. + * + * Deliberately doesn't touch ingredients — resolving free-text ingredient + * lines against our `Ingredient`/`Unit` catalogs is a separate, not-yet-built + * concern (see `ParsedRecipeIngredient`'s doc comment) — and doesn't turn + * the result into a saveable `Recipe` either (no `dietIds`/`visibility`/ + * author, a source can't know those). This is one step of the pipeline, not + * the whole thing. + * + * `translateRecipeSteps` is pure (takes `techStepMappings` as a plain + * argument, same convention as `matchTechSteps` itself) so it's unit-testable + * without a database; `translateRecipe` is the DB-backed convenience wrapper + * a caller reaches for in practice, mirroring `tech-step-matcher.ts`'s own + * pure/DB-touching split. + */ + +/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */ +export interface TranslatedRecipeStep extends ParsedRecipeStep { + /** Ordered sequence of detected `TechStep` ids (see `matchTechSteps`) — empty if this step doesn't mention any known technique. */ + techStepIds: number[]; +} + +/** A {@link ParsedRecipe} whose `steps` have been translated — everything else (name, ingredients, portions, …) passes through unchanged. */ +export interface TranslatedRecipe extends Omit { + steps: TranslatedRecipeStep[]; +} + +/** + * Declares each of `recipe`'s steps' technique sequence against + * `techStepMappings`, leaving everything else about the recipe untouched. + * Pure — testable with a hand-built mapping list, no database involved (see + * `translateRecipe` for the DB-backed loader). `techStepMappings` should + * already be filtered to the locale the caller cares about, same + * requirement `matchTechSteps` itself has. + */ +export function translateRecipeSteps( + recipe: ParsedRecipe, + techStepMappings: TechStepMappingRule[], +): TranslatedRecipe { + return { + ...recipe, + steps: recipe.steps.map((step) => ({ + ...step, + techStepIds: matchTechSteps(step.description, techStepMappings), + })), + }; +} + +/** + * Convenience wrapper around {@link translateRecipeSteps} that loads + * `locale`'s mapping catalog itself — what a caller reaches for when + * translating a single recipe on its own (e.g. the eventual "import this + * one recipe" endpoint). A caller translating many recipes at once should + * call `loadTechStepMappingRules` once and reuse it across + * `translateRecipeSteps` calls instead, the same "don't requery per item" + * reasoning `recipe.service.ts`'s `createRecipe`/`updateRecipe` already + * follow for manually-authored recipes. + * + * No user- or recipe-level language preference exists anywhere in the app + * yet (see `tech-step-matcher.ts`'s `loadTechStepMappingRules`) — callers + * pass a locale explicitly rather than this module guessing one. Note that + * an English-language source (e.g. TheMealDB) translated against `"fr"` + * mappings will currently get an empty `techStepIds` sequence on every + * step — matching-language mappings for that source's language don't exist + * yet, this stage doesn't invent them. + */ +export async function translateRecipe( + recipe: ParsedRecipe, + locale: string, +): Promise { + const techStepMappings = await loadTechStepMappingRules(locale); + return translateRecipeSteps(recipe, techStepMappings); +} diff --git a/apps/api/test/recipe-translation.test.ts b/apps/api/test/recipe-translation.test.ts new file mode 100644 index 0000000..a984b2f --- /dev/null +++ b/apps/api/test/recipe-translation.test.ts @@ -0,0 +1,143 @@ +import { expect } from "chai"; +import { prisma } from "../src/db/prisma.js"; +import type { ParsedRecipe } from "../src/lib/recipe-source-adapter.js"; +import { translateRecipe, translateRecipeSteps } from "../src/lib/recipe-translation.js"; +import type { TechStepMappingRule } from "../src/lib/tech-step-matcher.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +/** A minimal fixture `ParsedRecipe` — only `steps` (built from `descriptions`) matters for most tests here, the rest is filler to prove it survives translation untouched. */ +function buildParsedRecipe(descriptions: string[]): ParsedRecipe { + return { + name: "Test Recipe", + description: "A recipe for testing", + picture: "https://example.test/recipe.jpg", + portions: 4, + sourceUrl: "https://example.test/recipes/1", + ingredients: [{ rawText: "1 egg", quantity: null, unit: null, name: "egg" }], + steps: descriptions.map((description, i) => ({ + description, + picture: i === 0 ? "https://example.test/step1.jpg" : null, + })), + }; +} + +describe("recipe-translation", () => { + describe("translateRecipeSteps", () => { + const simmer: TechStepMappingRule = { + techStepId: 1, + expression: "\\bmijot(er|ez|e|ant|é)\\b", + weight: 15, + }; + const preheat: TechStepMappingRule = { + techStepId: 2, + expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", + weight: 20, + }; + const melt: TechStepMappingRule = { + techStepId: 3, + expression: "\\bfaire fondre\\b|\\bfaites fondre\\b", + weight: 15, + }; + + it("declares each step's technique sequence, preserving order", () => { + const recipe = buildParsedRecipe([ + "Préchauffer la poêle, puis faire fondre le beurre", + "Servir immédiatement", + "Faire mijoter à feu doux", + ]); + + const translated = translateRecipeSteps(recipe, [simmer, preheat, melt]); + + expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[2, 3], [], [1]]); + }); + + it("leaves description/picture untouched on each step", () => { + const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir"]); + + const translated = translateRecipeSteps(recipe, [simmer]); + + expect(translated.steps[0]).to.deep.equal({ + description: "Faire mijoter à feu doux", + picture: "https://example.test/step1.jpg", + techStepIds: [1], + }); + expect(translated.steps[1]).to.deep.equal({ + description: "Servir", + picture: null, + techStepIds: [], + }); + }); + + it("passes every other field through unchanged", () => { + const recipe = buildParsedRecipe(["Servir"]); + + const translated = translateRecipeSteps(recipe, []); + + expect(translated.name).to.equal(recipe.name); + expect(translated.description).to.equal(recipe.description); + expect(translated.picture).to.equal(recipe.picture); + expect(translated.portions).to.equal(recipe.portions); + expect(translated.sourceUrl).to.equal(recipe.sourceUrl); + expect(translated.ingredients).to.deep.equal(recipe.ingredients); + }); + + it("gives every step an empty sequence when there are no mappings at all", () => { + const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Préchauffer le four"]); + + const translated = translateRecipeSteps(recipe, []); + + expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]); + }); + + it("handles a recipe with no steps without error", () => { + const recipe = buildParsedRecipe([]); + + const translated = translateRecipeSteps(recipe, [simmer]); + + expect(translated.steps).to.deep.equal([]); + }); + }); + + describe("translateRecipe", () => { + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + it("resolves real TechStep ids from the seeded French catalog", async () => { + const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }); + const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }); + const recipe = buildParsedRecipe(["Hacher les oignons", "Faire mijoter à feu doux"]); + + const translated = await translateRecipe(recipe, "fr"); + + expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([ + [chop.id], + [simmer.id], + ]); + }); + + it("finds nothing for English text against the French catalog — the current locale gap", async () => { + // A step lifted verbatim from a real TheMealDB recipe. + const recipe = buildParsedRecipe([ + "Bring a large saucepan of salted water to the boil", + "Chop the onions finely", + ]); + + const translated = await translateRecipe(recipe, "fr"); + + expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]); + }); + + it("finds nothing for a locale with no mappings, even for text that would otherwise match", async () => { + const recipe = buildParsedRecipe(["Faire mijoter à feu doux"]); + + const translated = await translateRecipe(recipe, "en"); + + expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[]]); + }); + }); +});