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