batchCooking/apps/api/test/recipe-translation.test.ts
Nicolas 5b868e4422 feat(recipes): traduction des étapes d'une recette importée en tech steps
Ajoute la brique "Traduction en étapes" du pipeline d'import décrit
dans specs/batch-cooking-architecture.md — prend un ParsedRecipe
(sortie de parse() d'un adaptateur, recipe-source-adapter.ts) et
déclare, pour chaque étape, sa séquence de tech steps détectée.

- recipe-translation.ts : TranslatedRecipe/TranslatedRecipeStep
  (ParsedRecipe/ParsedRecipeStep + techStepIds: number[], même forme
  que Step.techSteps/StepTechStep). translateRecipeSteps() est pure
  (prend les mappings en argument, comme matchTechSteps lui-même) ;
  translateRecipe() est le wrapper qui charge le catalogue depuis la
  DB pour une locale donnée — même séparation pur/DB que
  tech-step-matcher.ts.
- Ne touche pas aux ingrédients (résolution vers Ingredient/Unit
  toujours hors scope) ni ne produit une Recipe sauvegardable (pas de
  dietIds/visibility/auteur) — une seule brique du pipeline, pas tout
  le pipeline.
- Documente explicitement la limite actuelle : le catalogue de tech
  steps n'a que des mappings "fr", donc une source anglophone comme
  TheMealDB traduite avec cette locale obtient des séquences vides
  sur toutes ses étapes (vérifié par un test dédié avec du texte
  réel de TheMealDB).

8 nouveaux tests (partie pure + partie DB avec le vrai catalogue
"fr" seedé). 180 tests passent au total. Build et lint propres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:39:15 +02:00

143 lines
5.1 KiB
TypeScript

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