batchCooking/apps/api/test/tech-step-matcher.test.ts
Nicolas ea1dfc5ad7 test(recipes): couverture Mocha complète pour les tech steps
Étend les tests ajoutés pour le catalogue de techniques culinaires :

- tech-step-matcher.test.ts : normalizeText (cédille/ligature, texte
  déjà normalisé, chaîne vide), matchTechStep (listes/description
  vides, plusieurs mappings pointant vers le même techStep, respect
  des frontières de mot — évite les faux positifs type "recuire"/
  "précuit"), et loadTechStepMappingRules (filtrage par locale contre
  une vraie base, locale sans mapping).
- reference.test.ts : ordre alphabétique par key, idempotence du
  reseed (pas de doublon en rappelant seedReferenceData sans
  truncate).
- recipe.test.ts : détection indépendante par étape sur une recette à
  plusieurs étapes (ordre préservé, y compris une étape sans match),
  et résolution de bout en bout du mapping le plus spécifique quand
  une description matche plusieurs techniques.

129 tests passent (13 nouveaux).

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

140 lines
5.3 KiB
TypeScript

import { expect } from "chai";
import { prisma } from "../src/db/prisma.js";
import {
type TechStepMappingRule,
loadTechStepMappingRules,
matchTechStep,
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("matchTechStep", () => {
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,
};
it("matches an exact expression", () => {
expect(matchTechStep("Faire mijoter à feu doux", [simmer])).to.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(matchTechStep("FAIRE MIJOTER", [simmer])).to.equal(1);
expect(matchTechStep("faire mijote", [simmer])).to.equal(1);
});
it("returns null when nothing matches", () => {
expect(matchTechStep("Servir immédiatement", [simmer, cook, bake])).to.be.null;
});
it("returns null for an empty mappings list", () => {
expect(matchTechStep("Faire mijoter à feu doux", [])).to.be.null;
});
it("returns null for an empty description", () => {
expect(matchTechStep("", [simmer, cook, bake])).to.be.null;
});
it("picks the highest-weight match when several mappings match", () => {
// "Cuire au four" matches both `cook` (weight 10) and `bake` (weight 25).
expect(matchTechStep("Cuire au four pendant 30 minutes", [cook, bake])).to.equal(3);
// Order-independent.
expect(matchTechStep("Cuire au four pendant 30 minutes", [bake, cook])).to.equal(3);
});
it("breaks a 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(matchTechStep("Mélanger les ingrédients", [a, b])).to.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(matchTechStep("Faire mijoter à feu doux", [wholeWord, withAdverb])).to.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(matchTechStep("Faire recuire la sauce", [cook])).to.be.null;
expect(matchTechStep("Un plat précuit", [cook])).to.be.null;
// The standalone forms still match.
expect(matchTechStep("Faire cuire la sauce", [cook])).to.equal(2);
expect(matchTechStep("Le riz est cuit", [cook])).to.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 (25 "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(25);
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([]);
});
});
});