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" }; /** * 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 = [], onClose = () => {}, onSubmitted = () => {}, }: Partial<{ previousTechStepId: number | null; existingIngredients: unknown[]; existingUtensils: unknown[]; onClose: () => void; onSubmitted: (result: unknown) => void; }>) { const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{ nonce: number; kind: "ingredient" | "utensil"; 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 catalog (searchable) and the metadata sections all 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, both 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). cy.get( ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", ).should("have.length", 2); cy.contains("h4", "Ingrédients").should("be.visible"); cy.contains("h4", "Ustensiles").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").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").should("exist"); cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire"); 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 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("button", "Ajouter").click(); cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible"); 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: [], }); }); it("pre-seeds existing ingredients/utensils, removable via their own chip", () => { cy.mount( , ); // An existing match starts pre-selected on itself (see // TechStepCorrectionPopover's own doc comment) — the metadata sections, // pre-seeded from `existingIngredients`/`existingUtensils`, 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__chip", "50 g Beurre").find("button").click(); cy.contains(".tech-step-correction-popover__chip", "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"); }); });