Les corrections utilisateur (via TechStepCorrectionPopover) sont
désormais écrites directement dans StepTechStep, avec une colonne
`source` ("auto" | "manual") qui les distingue des matches du
classifieur NLP :
- Migration `step_tech_step_source` ajoutant `source` (défaut "auto")
- `applyManualCorrection`/`renumberStepTechSteps` dans
recipe-tech-step-correction.service.ts : une correction met à jour
ou crée l'entrée StepTechStep concernée (source "manual"), la
réponse de l'endpoint inclut désormais le techSteps à jour du step
(SubmitTechStepCorrectionResult), pas seulement l'audit de
correction
- backfill-tech-steps.ts préserve les entrées "manual" existantes :
seules les entrées "auto" sont recalculées, et un nouveau match
auto chevauchant une correction manuelle est ignoré plutôt
qu'inséré en doublon — vérifié en base réelle (une correction
manuelle survit intacte à un backfill complet)
- Le front distingue visuellement les deux (StepDescription.tsx,
recipes.scss : `.step-tech-step--manual`, couleur Turmeric au lieu
de Basil), avec un tooltip "(correction manuelle)" et un indicateur
de découvrabilité de la fonctionnalité dans RecipeDetailPanel
Corrige aussi deux bugs trouvés en testant en conditions réelles :
- StepDescription.tsx : le clic sur un highlight existant lisait la
variable `offset` (mutable, partagée par la boucle) au lieu d'une
valeur capturée, envoyant un `end` erroné (fin de la description
entière au lieu du span du mot cliqué)
- backfill-tech-steps.ts : le garde `import.meta.url ===
file://${process.argv[1]}` ne matche jamais sur Windows (chemins à
antislash), le script ne faisait donc rien en exécution directe ;
remplacé par `pathToFileURL(process.argv[1]).href`
335 tests apps/api passants, 40/40 composants Cypress, 75/76 e2e
Cypress (1 flake pré-existant sans rapport, non touché ici).
227 lines
9.3 KiB
TypeScript
227 lines
9.3 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). `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" },
|
|
]);
|
|
});
|
|
|
|
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 },
|
|
]);
|
|
});
|
|
});
|
|
});
|