9 fichiers à plat -> recipe-sources/ (recipe-source-adapter, recipe-source- errors, recipe-source-registry) et recipe-matching/ (recipe-translation, ingredient-matcher, tech-step-matcher). jwt.ts, safe-profile.ts et logger.service.ts restent à la racine de lib/ (pas de sous-domaine partagé avec les autres). Chemins relatifs corrigés dans les fichiers déplacés (profondeur +1 vers db/) et chez tous leurs importeurs (modules/sources, modules/recipe, sources/*, db/recipe-source-sync.ts, 12 fichiers de test), doc mise à jour (specs/backend-architecture.md, specs/batch-cooking-architecture.md). Vérifié : tsc --noEmit, biome check, build complet, 303 tests API. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
import { expect } from "chai";
|
|
import { prisma } from "../src/db/prisma.js";
|
|
import {
|
|
loadTechStepMappingRules,
|
|
matchTechStepSpans,
|
|
matchTechSteps,
|
|
normalizeText,
|
|
type TechStepMappingRule,
|
|
} from "../src/lib/recipe-matching/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("matchTechStepSpans", () => {
|
|
// Same fixtures as `matchTechSteps` above (kept local to this describe
|
|
// block rather than shared — each block's fixtures should be readable
|
|
// on their own).
|
|
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("returns the matched span alongside the techStepId for a simple match", () => {
|
|
// "Faire mijoter à feu doux" — "mijoter" starts right after "Faire ".
|
|
expect(matchTechStepSpans("Faire mijoter à feu doux", [simmer])).to.deep.equal([
|
|
{ techStepId: 1, start: 6, end: 13 },
|
|
]);
|
|
});
|
|
|
|
it("returns an empty list when nothing matches", () => {
|
|
expect(matchTechStepSpans("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]);
|
|
});
|
|
|
|
it("returns each distinct technique's own span, in reading order", () => {
|
|
const text = "Préchauffer la poêle, puis faire fondre le beurre";
|
|
const result = matchTechStepSpans(text, [preheat, melt]);
|
|
expect(result).to.have.length(2);
|
|
expect(result[0].techStepId).to.equal(4);
|
|
expect(result[1].techStepId).to.equal(5);
|
|
// Each span, sliced back out of the original text, is exactly the
|
|
// word(s) that triggered that match — what the frontend needs to
|
|
// highlight the right characters.
|
|
expect(text.slice(result[0].start, result[0].end).toLowerCase()).to.equal("préchauffer");
|
|
expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre");
|
|
});
|
|
|
|
it("keeps only the winning span when two techniques' expressions overlap", () => {
|
|
// `bake` (weight 25) wins over `cook` (weight 10) for "cuire au four"
|
|
// — only bake's span survives, not two overlapping entries.
|
|
const text = "Cuire au four pendant 30 minutes";
|
|
const result = matchTechStepSpans(text, [cook, bake]);
|
|
expect(result).to.deep.equal([{ techStepId: 3, start: 0, end: 13 }]);
|
|
expect(text.slice(0, 13)).to.equal("Cuire au four");
|
|
});
|
|
});
|
|
|
|
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" } });
|
|
// "de" has no seeded mappings at all (unlike "fr"/"en", which the
|
|
// real catalog now both populate) — a clean locale to attach one
|
|
// synthetic row to without conflating it with real seed data.
|
|
await prisma.techStepMapping.create({
|
|
data: { techStepId: simmer.id, locale: "de", expression: "\\bsimmer\\b", weight: 15 },
|
|
});
|
|
|
|
// The seeded catalog (26 "fr" mappings) must be untouched by the extra
|
|
// "de" row — same count, and none of them carry its expression.
|
|
const frRules = await loadTechStepMappingRules("fr");
|
|
expect(frRules).to.have.length(26);
|
|
expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b");
|
|
|
|
const deRules = await loadTechStepMappingRules("de");
|
|
expect(deRules).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([]);
|
|
});
|
|
});
|
|
});
|