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