Merge pull request #39 from kyuno053/feat/recipe-step-translation

feat(recipes): traduction des étapes d'une recette importée en tech steps
This commit is contained in:
kyuno053 2026-08-20 11:57:45 +02:00 committed by GitHub
commit b9f148a674
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 229 additions and 0 deletions

View file

@ -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<ParsedRecipe, "steps"> {
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<TranslatedRecipe> {
const techStepMappings = await loadTechStepMappingRules(locale);
return translateRecipeSteps(recipe, techStepMappings);
}

View file

@ -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([[]]);
});
});
});