import { useState } from "react"; import "../../src/i18n/i18n"; import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover"; // Mounts the popover in isolation (no StepDescription/selection plumbing // around it) — same "generic component test" posture as CheckboxOption.cy.tsx, // but this one needs `../../src/i18n/i18n` imported for its side effect // (initializes the default i18next instance `useTranslation` falls back to // with no `` in the tree — see that module's own doc // comment) since, unlike Checkbox/Radio, this component calls // `useTranslation()`. const cook = { id: 1, key: "cook" }; const simmer = { id: 3, key: "simmer" }; const butter = { id: 10, key: "butter" }; const pan = { id: 20, key: "pan" }; const gram = { id: 30, key: "gram" }; /** Long enough that `range={start:0,end:5}` ("Cuire") and a resolved metadata span at `[20,26)` ("Beurre") both fall within it — see {@link Harness}'s fixed `onRequestSpan` stub. */ const DESCRIPTION = "Cuire un peu de beurre."; /** * A real `StepDescription` resolves `onRequestSpan` into a fresh * `resolvedMetadataSpan` via an actual browser text selection — out of * scope for a component test of the popover alone (covered by the e2e * scenario instead). This harness fakes that round-trip with a fixed * span, so tests here can exercise everything the popover itself is * responsible for once a span comes back, without needing a real * `StepDescription` in the tree. */ function Harness({ previousTechStepId = null, existingIngredients = [], existingUtensils = [], existingTemperatures = [], onClose = () => {}, onSubmitted = () => {}, }: Partial<{ previousTechStepId: number | null; existingIngredients: unknown[]; existingUtensils: unknown[]; existingTemperatures: unknown[]; onClose: () => void; onSubmitted: (result: unknown) => void; }>) { const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{ nonce: number; kind: "ingredient" | "utensil" | "temperature"; range: { start: number; end: number }; text: string; } | null>(null); return (
{/* A genuinely separate sibling to click for the "outside click closes it" test — clicking blindly at a viewport coordinate would risk still landing inside the popover, which fills most of the mounted area on its own. */}
setResolvedMetadataSpan({ nonce: Date.now(), kind, range: { start: 20, end: 26 }, text: "Beurre", }) } onClose={onClose} // biome-ignore lint/suspicious/noExplicitAny: see above. onSubmitted={onSubmitted as any} />
); } describe("TechStepCorrectionPopover", () => { beforeEach(() => { cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as( "getTechSteps", ); cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [butter] }).as( "getIngredients", ); cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [gram] }).as("getUnits"); cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [pan] }).as( "getUtensils", ); }); it("shows the selected text, the technique picker (searchable) and all three metadata sections together", () => { cy.mount(); cy.wait("@getTechSteps"); cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible"); // Merged editor (see TechStepCorrectionPopover's own doc comment) — no // separate "pick, then metadata reveals itself" step, all four // sections render at once, and the technique catalog goes through the // same searchable `CatalogSearchPicker` as the ingredient/utensil // sub-flows (a plain unfiltered list of the real ~74-entry catalog // isn't browsable) — visible by default since nothing's picked yet. cy.get( ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", ).should("have.length", 2); cy.contains(".tech-step-correction-popover__section-title", "Ingrédients").should("be.visible"); cy.contains(".tech-step-correction-popover__section-title", "Ustensiles").should("be.visible"); cy.contains(".tech-step-correction-popover__section-title", "Température").should("be.visible"); cy.contains("button", "Valider").should("be.visible"); }); it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => { cy.mount(); cy.wait("@getTechSteps"); cy.get(".tech-step-correction-popover__remove-match").should("not.exist"); cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée"); cy.mount(); cy.wait("@getTechSteps"); cy.get(".tech-step-correction-popover__remove-match").should("exist"); cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire"); // The picker itself hides behind "Changer" once something's already // picked (see TechStepCorrectionPopover's own doc comment) — reopened // here just to confirm it still marks the current pick correctly. cy.contains("button", "Changer").click(); cy.contains(".catalog-search-picker__list button", "Cuire").should( "have.class", "catalog-search-picker__item--selected", ); }); it("picking a technique from the catalog selects it without submitting immediately", () => { cy.mount(); cy.wait("@getTechSteps"); cy.contains( ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", "Mijoter", ).click(); cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter"); cy.contains("button", "Valider").should("be.visible"); }); it("Valider stays disabled until a technique is actually picked", () => { cy.mount(); cy.wait("@getTechSteps"); cy.contains("button", "Valider").should("be.disabled"); cy.contains( ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", "Mijoter", ).click(); cy.contains("button", "Valider").should("not.be.disabled"); }); it("submits the selected technique (no metadata touched) with ingredients/utensils/temperatures omitted from the request", () => { cy.intercept("POST", "**/recipes/2/steps/2/corrections", { statusCode: 201, body: { id: 1, start: 0, end: 5, previousTechStep: null, correctedTechStep: simmer, createdAt: new Date().toISOString(), }, }).as("submitCorrection"); const onSubmitted = cy.stub().as("onSubmitted"); cy.mount(); cy.wait("@getTechSteps"); cy.contains( ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", "Mijoter", ).click(); cy.contains("button", "Valider").click(); cy.wait("@submitCorrection").its("request.body").should("deep.equal", { start: 0, end: 5, previousTechStepId: null, correctedTechStepId: simmer.id, }); cy.get("@onSubmitted").should("have.been.calledOnce"); }); it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => { cy.intercept("POST", "**/recipes/2/steps/2/corrections", { statusCode: 201, body: { id: 1, start: 0, end: 5, previousTechStep: null, correctedTechStep: simmer, createdAt: new Date().toISOString(), }, }).as("submitCorrection"); cy.mount(); cy.wait("@getTechSteps"); cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]); cy.contains( ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", "Mijoter", ).click(); cy.contains("button", "Ajouter un ingrédient").click(); cy.contains(".catalog-search-picker button", "Beurre").click(); cy.get('input[type="number"]').type("50"); cy.get("select").select(String(gram.id)); cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click(); cy.contains(".tech-step-correction-popover__row", "Beurre") .should("contain.text", "Beurre") .and("contain.text", "50 g"); cy.contains("button", "Valider").click(); cy.wait("@submitCorrection") .its("request.body") .should("deep.equal", { start: 0, end: 5, previousTechStepId: null, correctedTechStepId: simmer.id, ingredients: [ { ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 }, ], utensils: [], temperatures: [], }); }); it("adds a temperature via the span-selection flow and its 3-way mode switch, included in the submitted request", () => { cy.intercept("POST", "**/recipes/2/steps/2/corrections", { statusCode: 201, body: { id: 1, start: 0, end: 5, previousTechStep: null, correctedTechStep: simmer, createdAt: new Date().toISOString(), }, }).as("submitCorrection"); cy.mount(); cy.wait("@getTechSteps"); cy.contains( ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", "Mijoter", ).click(); cy.contains("button", "Ajouter une température").click(); // Defaults to the qualitative mode — switching to °C swaps the input // shown, never both at once (mutually exclusive, see // `manualStepTechStepTemperatureInputSchema`'s own doc comment, // packages/shared). cy.contains(".tech-step-correction-popover__mode-switch button", "°C").click(); cy.get('.tech-step-correction-popover__value-line input[type="number"]').type("180"); cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click(); cy.contains(".tech-step-correction-popover__row", "180") .should("contain.text", "°C") .and("contain.text", "Beurre"); // the (stubbed) source quote cy.contains("button", "Valider").click(); cy.wait("@submitCorrection") .its("request.body") .should("deep.equal", { start: 0, end: 5, previousTechStepId: null, correctedTechStepId: simmer.id, ingredients: [], utensils: [], temperatures: [{ celsius: 180, gasMark: null, qualitative: null, start: 20, end: 26 }], }); }); it("pre-seeds existing ingredients/utensils/temperatures, each removable via its own row", () => { cy.mount( , ); // An existing match starts pre-selected on itself (see // TechStepCorrectionPopover's own doc comment) — the metadata // sections, pre-seeded from `existingIngredients`/`existingUtensils`/ // `existingTemperatures`, are visible immediately, no need to re-pick // "Cuire" from a list first. cy.wait("@getTechSteps"); cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]); cy.contains(".tech-step-correction-popover__row", "Beurre").should("be.visible"); cy.contains(".tech-step-correction-popover__row", "Feu doux").should("be.visible"); cy.contains(".tech-step-correction-popover__row", "Beurre") .find(".tech-step-correction-popover__row-remove") .click(); cy.contains(".tech-step-correction-popover__row", "Beurre").should("not.exist"); }); it("shows an error message and stays open when the submission fails", () => { cy.intercept("POST", "**/recipes/2/steps/2/corrections", { statusCode: 404, body: { code: 4051, message: "TechStep not found" }, }).as("submitCorrection"); const onClose = cy.stub().as("onClose"); cy.mount(); cy.wait("@getTechSteps"); cy.contains( ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", "Cuire", ).click(); cy.contains("button", "Valider").click(); cy.wait("@submitCorrection"); cy.get(".field-error").should("be.visible"); cy.get("@onClose").should("not.have.been.called"); }); it("calls onClose on an outside click", () => { const onClose = cy.stub().as("onClose"); cy.mount(); cy.wait("@getTechSteps"); cy.get('[data-testid="outside-popover"]').click(); cy.get("@onClose").should("have.been.calledOnce"); }); });