batchCooking/apps/web/cypress/component/highlight-tech-steps.cy.tsx
Nicolas a04d796167
Some checks failed
CI / build (push) Successful in 3m31s
CI / lint (push) Successful in 4m15s
CI / e2e (push) Failing after 8m37s
CI / intent-service-test (push) Successful in 12m2s
CI / test (push) Failing after 21m35s
feat(tech-steps): detecte les temperatures dans les etapes
3e type de metadonnee de clause, a cote des ingredients et des ustensiles :
temperature en °C (« 180°C », « 200 degres », °F converti), numero de
thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » ->
low/medium/high).

services/tech-step-intent-service :
- `temperature_extraction.py` : `extract_temperatures(text, locale)` pur,
  a base de regex (independant du modele spaCy et de l'entrainement).
- `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif
  au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les
  extrait meme pour une locale pas encore entrainee.
- Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` :
  2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas.

apps/api :
- `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere).
- `TechStepMatch.temperatures` : filtrees par appartenance de span a la
  clause, meme regle que les ustensiles.
- `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la
  donnee) + migration manuelle `20260829120000_step_tech_step_temperature`
  (cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a
  reset-db.ts). Persistance + lecture dans `recipe.service.ts`
  (`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche
  du service de correction ; `sources.service.ts` (apercu d'import) les
  fait transiter.

packages/shared : `StepTechStepTemperatureView` + `temperatures` sur
`StepTechStepView`.

apps/web :
- `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et
  redecoupe les segments non-keyword autour des spans de temperature (la
  logique technique intriquee reste intacte). `?? []` tolere une payload
  d'avant `temperatures`.
- `StepDescription` : surlignage `.step-temperature` + Tooltip via
  `temperatureLabel` ; i18n `recipes.temperature.*`.
- Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les
  ustensiles).

Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le
flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ;
pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres +
intent-service requis) non lance ici.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:38:02 +02:00

332 lines
13 KiB
TypeScript

import type { StepTechStepTemperatureView, StepTechStepView } from "@batch-cooking/shared";
import {
splitDescriptionByTechSteps,
splitDescriptionSegments,
} 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). `source` defaults to `"auto"`, the common case every test not specifically about the manual/auto distinction uses. */
function techStep(
key: string,
id: number,
start: number,
end: number,
context?: { start: number; end: number },
source: StepTechStepView["source"] = "auto",
): StepTechStepView {
return {
techStep: { id, key },
start,
end,
source,
...(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, source: null },
]);
});
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, source: null },
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true, source: "auto" },
{ text: " à feu doux", techStep: null, isKeyword: false, source: null },
]);
});
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, source: "auto" },
{ text: " les oignons", techStep: null, isKeyword: false, source: null },
]);
});
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, source: null },
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true, source: "auto" },
]);
});
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,
source: "auto",
});
});
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, source: null },
]);
});
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, source: null },
]);
});
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, source: null },
]);
});
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, source: "auto" },
]);
});
it("returns a single empty-ish segment for an empty description with no matches", () => {
expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]);
});
it("carries a manual correction's source through its segments, distinct from an auto match", () => {
const text = "Faire mijoter le riz, puis dresser dans les assiettes";
const result = splitDescriptionByTechSteps(text, [
techStep("simmer", 1, 6, 13),
techStep("plate", 2, 28, 35, undefined, "manual"),
]);
const keywordSegments = result.filter((s) => s.isKeyword);
expect(keywordSegments.map((s) => ({ key: s.techStep?.key, source: s.source }))).to.deep.equal([
{ key: "simmer", source: "auto" },
{ key: "plate", source: "manual" },
]);
});
it("never lets one match's wider context swallow another match's own keyword span", () => {
// The motivating real bug (found via live testing, not invented for
// this test): "simmer" is the only NER candidate `splitIntoClauses`
// found, so its context spans the *entire* description — before this
// was fixed, that wide context advanced `cursor` past 39, silently
// dropping "setAside"'s own keyword span (a manual correction on
// "materiel", a word with no relation to "simmer" at all) instead of
// rendering it.
const text = "Faire mijoter la sauce, puis ranger le materiel.";
const result = splitDescriptionByTechSteps(text, [
techStep("simmer", 1, 6, 13, { start: 0, end: 48 }),
techStep("setAside", 2, 39, 47, undefined, "manual"),
]);
const keywordSegments = result.filter((s) => s.isKeyword);
expect(
keywordSegments.map((s) => ({ key: s.techStep?.key, text: s.text, source: s.source })),
).to.deep.equal([
{ key: "simmer", text: "mijoter", source: "auto" },
{ key: "setAside", text: "materiel", source: "manual" },
]);
expect(result.map((s) => s.text).join("")).to.equal(text);
});
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,
source: "auto",
},
{
text: "poêle chaude",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
{
text: ", faire chauffer une noix de beurre",
techStep: null,
isKeyword: false,
source: null,
},
]);
});
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,
source: "auto",
},
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false, source: "auto" },
]);
});
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, source: null },
{
text: "le four à ",
techStep: { id: 4, key: "preheat" },
isKeyword: false,
source: "auto",
},
{
text: "préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
]);
});
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, source: null },
]);
});
});
});
/** Builds a `StepTechStepTemperatureView` — only the fields a test cares about, the rest nulled. */
function temperature(
start: number,
end: number,
fields: Partial<StepTechStepTemperatureView> = {},
): StepTechStepTemperatureView {
return {
start,
end,
celsius: null,
gasMark: null,
qualitative: null,
raw: "",
source: "auto",
...fields,
};
}
describe("splitDescriptionSegments", () => {
it("adds temperature: null to every segment when there are no temperatures", () => {
const result = splitDescriptionSegments("Hacher les oignons", [techStep("chop", 1, 0, 6)], []);
expect(result).to.deep.equal([
{
text: "Hacher",
techStep: { id: 1, key: "chop" },
isKeyword: true,
source: "auto",
temperature: null,
},
{ text: " les oignons", techStep: null, isKeyword: false, source: null, temperature: null },
]);
});
it("splits a plain run around a temperature mention, keeping the technique keyword untouched", () => {
const text = "Enfourner à 180°C environ 30 min";
const temp = temperature(12, 17, { celsius: 180, raw: "180°C" });
const result = splitDescriptionSegments(text, [techStep("bake", 2, 0, 9)], [temp]);
expect(result).to.deep.equal([
{
text: "Enfourner",
techStep: { id: 2, key: "bake" },
isKeyword: true,
source: "auto",
temperature: null,
},
{ text: " à ", techStep: null, isKeyword: false, source: null, temperature: null },
{ text: "180°C", techStep: null, isKeyword: false, source: null, temperature: temp },
{
text: " environ 30 min",
techStep: null,
isKeyword: false,
source: null,
temperature: null,
},
]);
});
it("splits around two temperatures in one description", () => {
const text = "Saisir à feu vif puis 180°C";
const vif = temperature(9, 16, { qualitative: "high", raw: "feu vif" });
const celsius = temperature(22, 27, { celsius: 180, raw: "180°C" });
const result = splitDescriptionSegments(text, [], [vif, celsius]);
expect(result.filter((segment) => segment.temperature !== null)).to.deep.equal([
{ text: "feu vif", techStep: null, isKeyword: false, source: null, temperature: vif },
{ text: "180°C", techStep: null, isKeyword: false, source: null, temperature: celsius },
]);
});
it("skips a temperature span that isn't fully inside a single non-keyword run", () => {
// Span straddles the keyword — dropped rather than producing a garbled slice.
const result = splitDescriptionSegments(
"Cuire au four",
[techStep("bake", 1, 0, 5)],
[temperature(3, 10)],
);
expect(result.some((segment) => segment.temperature !== null)).to.equal(false);
});
});