From ea1dfc5ad7dcca88ea8965bd932918a6749942dc Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 06:38:00 +0200 Subject: [PATCH] =?UTF-8?q?test(recipes):=20couverture=20Mocha=20compl?= =?UTF-8?q?=C3=A8te=20pour=20les=20tech=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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 --- apps/api/test/recipe.test.ts | 47 ++++++++++++++ apps/api/test/reference.test.ts | 18 ++++++ apps/api/test/tech-step-matcher.test.ts | 85 ++++++++++++++++++++++++- 3 files changed, 148 insertions(+), 2 deletions(-) diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 0ed2192..684134f 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -276,6 +276,53 @@ describe("Recipes", () => { expect(step.techStepId).to.be.null; }); + it("detects each step's technique independently, preserving order", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + const simmer = await techStepId("simmer"); + const chop = await techStepId("chop"); + + const res = await agent.post("/recipes").send({ + name: "Ragoût", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [ + { description: "Hacher les oignons" }, + { description: "Servir immédiatement" }, + { description: "Faire mijoter à feu doux" }, + ], + }); + + expect(res.status).to.equal(201); + const steps = await prisma.step.findMany({ + where: { recipeId: res.body.id }, + orderBy: { order: "asc" }, + }); + expect(steps.map((s) => s.techStepId)).to.deep.equal([chop, null, simmer]); + }); + + it("picks the more specific technique end-to-end when a description matches more than one", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + const bake = await techStepId("bake"); + + const res = await agent.post("/recipes").send({ + name: "Gratin", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + // Matches both `cook` (weight 10) and `bake` (weight 25, "au four"). + steps: [{ description: "Cuire au four pendant 30 minutes" }], + }); + + expect(res.status).to.equal(201); + const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); + expect(step.techStepId).to.equal(bake); + }); + it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => { const { agent } = await signup(); const piece = await unitId("piece"); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index e8dda4e..934ed98 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; +import { seedReferenceData } from "../src/db/reference-seed-data.js"; import { resetDatabase } from "../test-support/reset-db.js"; describe("Reference data", () => { @@ -106,5 +107,22 @@ describe("Reference data", () => { expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer"); expect(res.body[0]).to.have.keys(["id", "key"]); }); + + it("orders techniques alphabetically by key", async () => { + const res = await request(app).get("/reference/tech-steps"); + + const keys = res.body.map((t: { key: string }) => t.key); + expect(keys).to.deep.equal([...keys].sort()); + }); + + it("reseeding is idempotent — no duplicate techniques or mappings", async () => { + // resetDatabase already seeded once in beforeEach; seed a second time + // on top of that without truncating, the way a redeploy would. + await seedReferenceData(prisma); + + const res = await request(app).get("/reference/tech-steps"); + expect(res.body).to.have.length(25); + expect(await prisma.techStepMapping.count()).to.equal(25); + }); }); }); diff --git a/apps/api/test/tech-step-matcher.test.ts b/apps/api/test/tech-step-matcher.test.ts index b79de6e..d4ca4fc 100644 --- a/apps/api/test/tech-step-matcher.test.ts +++ b/apps/api/test/tech-step-matcher.test.ts @@ -1,15 +1,30 @@ 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", () => { @@ -34,15 +49,25 @@ describe("tech-step-matcher", () => { expect(matchTechStep("Faire mijoter à feu doux", [simmer])).to.equal(1); }); - it("is case- and accent-insensitive", () => { + 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 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); @@ -55,5 +80,61 @@ describe("tech-step-matcher", () => { 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([]); + }); }); });