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>
This commit is contained in:
parent
5026a23bd5
commit
ea1dfc5ad7
3 changed files with 148 additions and 2 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue