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" }, ]); }); 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 }, ]); }); }); });