Corrige le modèle de données suite à une review sur la PR #34 : "Dans une poêle chaude, faire chauffer une noix de beurre" combine deux techniques (preheat + melt), or Step.techStepId ne pouvait en porter qu'une seule (FK simple nullable). - Step.techStepId (FK simple) remplacé par StepTechStep, une table de jointure ordonnée (stepId, techStepId, order) — @@id([stepId, order]) garantit une séquence propre par étape. - tech-step-matcher.ts : matchTechStep(...) → number|null devient matchTechSteps(...) → number[]. Nouvel algorithme : chaque mapping qui matche devient un candidat avec sa position dans le texte ; on garde le meilleur candidat par technique (poids, puis position), on résout les chevauchements entre techniques différentes par poids décroissant (ex: "cuire au four" ne garde que `bake`, pas `cook` en plus), puis on trie le résultat par ordre d'apparition dans le texte — une séquence qui se lit dans le même ordre que l'instruction. - Ajout de la technique "melt" (faire fondre) au catalogue, pour pouvoir tester le cas concret du commentaire de review de bout en bout (préchauffer + faire fondre). - recipe.service.ts : câble StepTechStep via un create imbriqué à la place du champ scalaire. Tests étendus dans tech-step-matcher.test.ts (séquences non chevauchantes, résolution de chevauchement combinée à une technique distincte, etc.) et recipe.test.ts (nouveau test de bout en bout avec deux techniques dans une même étape). 133 tests passent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
7.3 KiB
TypeScript
185 lines
7.3 KiB
TypeScript
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([]);
|
|
});
|
|
});
|
|
});
|