batchCooking/apps/web/cypress/component/highlight-tech-steps.cy.tsx
Nicolas 8d741ed13f feat(api): ajoute la délimitation de contexte aux tech steps et étoffe le vocabulaire du classifieur
Deux évolutions du pipeline NLP de détection des tech steps (PR #63) :

1. Délimitation de contexte — en plus du mot-clé qui déclenche un match
   (start/end), chaque TechStepMatch porte maintenant contextStart/
   contextEnd : la clause complète autour du mot-clé (ex : "poêle chaude"
   comme mot-clé, "Dans une poêle chaude" comme contexte). Persisté sur
   StepTechStep (colonnes nullables, migration dédiée), exposé via
   StepTechStepView, et rendu côté web avec un style plus discret que le
   mot-clé (StepDescription.tsx, .step-tech-step-context). splitIntoClauses
   coupe désormais sur l'espace le plus proche du milieu de l'écart entre
   deux candidats plutôt que sur le milieu brut, pour ne jamais couper un
   mot en deux (findGapSplitPoint).

2. Vocabulaire du classifieur — synonymes et locutions supplémentaires par
   technique (FR/EN) pour fiabiliser la détection sur des formulations que
   le corpus initial ne couvrait pas. Plusieurs bugs de fond trouvés et
   corrigés en cours de route, tous confirmés par la suite de tests
   complète (309 tests) :
   - un synonyme multi-mots qui est un préfixe-mot d'un synonyme plus court
     déjà enregistré pour la même technique fait matcher les deux comme
     candidats NER distincts et chevauchants, corrompant le découpage en
     clauses (parfois jusqu'à une mauvaise classification) — retiré
     partout où ce motif a été repéré (cook, fry, deglaze, simmer, boil,
     roast, chop, mince, marinate, preheat, bake, plate, coat) ;
   - "poêlé"/"poêlée" comme synonymes de panFry sont réduits à la même
     racine que le nom "poêle" par le stemmer français de node-nlp,
     provoquant un faux positif sur toute mention nue de "poêle" (dont
     celle de preheat) — retiré ;
   - "Fouetter les blancs en neige" était mal classé en foldIn (la phrase
     d'entraînement de foldIn partage la même locution) — corrigé en
     ajoutant des phrases d'entraînement dédiées à whisk ;
   - "Émincer les tomates" est passé sous le seuil de confiance vers melt
     après l'ajout du nouveau vocabulaire ailleurs dans le corpus — corrigé
     en élargissant les phrases d'entraînement de mince à un autre légume.

Le test unitaire de splitIntoClauses avec un point de coupure obsolète
(pré-datant findGapSplitPoint) est aussi corrigé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:42:32 +02:00

177 lines
7.9 KiB
TypeScript

import type { StepTechStepView } from "@batch-cooking/shared";
import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/highlight-tech-steps";
// Pure logic, no DOM/mount needed — reuses the component-test runner
// (Cypress's Mocha/Chai, same as CheckboxOption.cy.tsx) purely for its
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
// what `cypress.config.ts`'s component `specPattern` looks for.
/** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). */
function techStep(
key: string,
id: number,
start: number,
end: number,
context?: { start: number; end: number },
): StepTechStepView {
return {
techStep: { id, key },
start,
end,
...(context ? { contextStart: context.start, contextEnd: context.end } : {}),
};
}
describe("splitDescriptionByTechSteps", () => {
it("returns the whole description as one plain segment when there are no matches", () => {
expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([
{ text: "Servir immédiatement", techStep: null, isKeyword: false },
]);
});
it("splits a single keyword-only match (no context) into before/match/after segments", () => {
// "Faire mijoter à feu doux" — "mijoter" is [6, 13). Same shape as
// before context spans existed at all — the common case for a short,
// already-imperative clause where the keyword and its context coincide.
const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [
techStep("simmer", 1, 6, 13),
]);
expect(result).to.deep.equal([
{ text: "Faire ", techStep: null, isKeyword: false },
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true },
{ text: " à feu doux", techStep: null, isKeyword: false },
]);
});
it("handles a match at the very start, with nothing before it", () => {
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
expect(result).to.deep.equal([
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true },
{ text: " les oignons", techStep: null, isKeyword: false },
]);
});
it("handles a match at the very end, with nothing after it", () => {
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
expect(result).to.deep.equal([
{ text: "Faire ", techStep: null, isKeyword: false },
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true },
]);
});
it("handles several non-adjacent matches, preserving the plain text between them", () => {
const text = "Préchauffer la poêle, puis faire fondre le beurre";
const result = splitDescriptionByTechSteps(text, [
techStep("preheat", 4, 0, 11),
techStep("melt", 5, 27, 39),
]);
expect(result.map((s) => s.text).join("")).to.equal(text);
expect(result.filter((s) => s.techStep !== null)).to.have.length(2);
expect(result[0]).to.deep.equal({
text: "Préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
});
});
it("re-sorts entries that aren't already in start order", () => {
const text = "Faire fondre le beurre puis préchauffer le four";
// Passed in techStepId order, not text order — the function must sort
// by position, not trust the input order.
const result = splitDescriptionByTechSteps(text, [
techStep("preheat", 4, 28, 39),
techStep("melt", 5, 0, 12),
]);
const matches = result.filter((s) => s.techStep !== null && s.isKeyword);
expect(matches.map((s) => s.techStep?.key)).to.deep.equal(["melt", "preheat"]);
});
it("drops a match whose end is past the end of the description", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
});
it("drops a match with a negative start", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
});
it("drops a match whose start isn't before its end", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
});
it("drops a later match that overlaps one already accepted", () => {
// Two entries claiming overlapping ranges shouldn't happen in practice
// (the backend already resolves overlaps), but the splitter defends
// against it anyway rather than producing a garbled/duplicated slice.
const result = splitDescriptionByTechSteps("Cuire au four", [
techStep("bake", 3, 0, 13),
techStep("cook", 2, 0, 5),
]);
expect(result).to.deep.equal([
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true },
]);
});
it("returns a single empty-ish segment for an empty description with no matches", () => {
expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]);
});
describe("with a context span wider than the keyword", () => {
it("splits into context-before / keyword / context-after around a keyword in the middle of its clause", () => {
// The motivating example: "Dans une poêle chaude, faire chauffer une
// noix de beurre" — `preheat`'s keyword is "poêle chaude", its
// context is the whole "Dans une poêle chaude" clause around it.
const text = "Dans une poêle chaude, faire chauffer une noix de beurre";
const result = splitDescriptionByTechSteps(text, [
techStep("preheat", 4, 9, 21, { start: 0, end: 21 }),
]);
expect(result).to.deep.equal([
{ text: "Dans une ", techStep: { id: 4, key: "preheat" }, isKeyword: false },
{ text: "poêle chaude", techStep: { id: 4, key: "preheat" }, isKeyword: true },
{
text: ", faire chauffer une noix de beurre",
techStep: null,
isKeyword: false,
},
]);
});
it("omits the context-before segment when the keyword starts right at the context's own start", () => {
const result = splitDescriptionByTechSteps("préchauffer le four", [
techStep("preheat", 4, 0, 11, { start: 0, end: 19 }),
]);
expect(result).to.deep.equal([
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true },
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false },
]);
});
it("omits the context-after segment when the keyword ends right at the context's own end", () => {
const result = splitDescriptionByTechSteps("mettre le four à préchauffer", [
techStep("preheat", 4, 17, 28, { start: 7, end: 28 }),
]);
expect(result).to.deep.equal([
{ text: "mettre ", techStep: null, isKeyword: false },
{ text: "le four à ", techStep: { id: 4, key: "preheat" }, isKeyword: false },
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true },
]);
});
it("falls back to a keyword-only segment when context is absent (an older, not-yet-recomputed match)", () => {
const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [
techStep("simmer", 1, 6, 13),
]);
expect(result.some((s) => s.techStep !== null && !s.isKeyword)).to.equal(false);
});
it("drops an entry whose context doesn't actually contain its own keyword span", () => {
const result = splitDescriptionByTechSteps("Cuire au four", [
// contextEnd (5) is before the keyword's own end (13) — malformed.
techStep("bake", 3, 0, 13, { start: 0, end: 5 }),
]);
expect(result).to.deep.equal([{ text: "Cuire au four", techStep: null, isKeyword: false }]);
});
});
});