batchCooking/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx
Nicolas b49ef5bb40
Some checks failed
CI / lint (push) Successful in 1m37s
CI / intent-service-test (push) Successful in 5m11s
CI / build (push) Successful in 1m9s
CI / e2e (push) Failing after 7m13s
CI / test (push) Failing after 18m46s
CI / build-android (push) Has been skipped
feat(recipes): signaler une technique absente du catalogue (#130)
Calque le pattern « ingrédient hors-catalogue » sur `TechStep` : cinq
colonnes additives (`isPlaceholder`/`displayName`/`createdById`/`createdAt`/
`reviewedAt`), migration purement additive, FK `createdById → user_profiles`
en `SET NULL`.

Correction d'étape : `submitTechStepCorrectionSchema` accepte
`correctedTechStepName` (mutuellement exclusif de `correctedTechStepId`) ;
`submitTechStepCorrection` crée une `TechStep` placeholder dans la
transaction et poursuit comme si son id avait été fourni. La correction
s'applique immédiatement (`source: manual`) ; nouvel event analytics
`tech_step.placeholder_proposed`.

`TechStepView` gagne `isPlaceholder`/`displayName` ; `GET
/reference/tech-steps` exclut les placeholders. Nouveau helper web
`techStepLabel` (`displayName ?? t(catalog.techSteps.<key>)`), utilisé par
`StepDescription` et le popover. Le popover a une échappatoire « Ma
technique n'est pas dans la liste » (champ libre).

Admin : `admin-tech-step-catalog.service.ts` + routes
`GET/PATCH /admin/api/catalog/tech-step-placeholders` (liste plate — les
propositions de technique sont rares) ; `CatalogPage` gagne un sélecteur
Ingrédients / Techniques.

Tests : Mocha (proposition placeholder, rejet id+nom, exclusion catalogue,
liste + mark-reviewed admin) + Cypress (composant popover, e2e admin
catalogue). Assertions `.techStep` existantes mises à jour pour les 2
nouveaux champs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:40:35 +02:00

385 lines
15 KiB
TypeScript

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 `<I18nextProvider>` 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 (
<div>
{/* 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. */}
<div data-testid="outside-popover" style={{ height: 20 }} />
<TechStepCorrectionPopover
recipeId={2}
stepId={2}
description={DESCRIPTION}
selectedText="Cuire"
range={{ start: 0, end: 5 }}
previousTechStepId={previousTechStepId}
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView/TemperatureView props — precise typing isn't the point here.
existingIngredients={existingIngredients as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingUtensils={existingUtensils as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingTemperatures={existingTemperatures as any}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={(kind) =>
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}
/>
</div>
);
}
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(<Harness />);
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(<Harness previousTechStepId={null} />);
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(<Harness previousTechStepId={cook.id} />);
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(<Harness />);
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(<Harness />);
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(<Harness onSubmitted={onSubmitted} />);
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("proposes a technique the catalog is missing via the 'pas dans la liste' escape hatch (issue #130)", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
id: 1,
start: 0,
end: 5,
previousTechStep: null,
correctedTechStep: {
id: 99,
key: "placeholder:x",
isPlaceholder: true,
displayName: "Flamber au chalumeau",
},
createdAt: new Date().toISOString(),
},
}).as("submitCorrection");
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains("button", "Ma technique n'est pas dans la liste").click();
cy.get(".tech-step-correction-popover__missing-technique input").type("Flamber au chalumeau");
cy.contains(".tech-step-correction-popover__missing-technique button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__chosen-technique", "Flamber au chalumeau");
cy.contains("button", "Valider").should("not.be.disabled").click();
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
start: 0,
end: 5,
previousTechStepId: null,
correctedTechStepName: "Flamber au chalumeau",
});
});
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(<Harness />);
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(<Harness />);
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(
<Harness
previousTechStepId={cook.id}
existingIngredients={[
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
]}
existingUtensils={[{ utensil: pan, start: 9, end: 15, source: "auto" }]}
existingTemperatures={[
{ celsius: null, gasMark: null, qualitative: "low", start: 16, end: 22, source: "auto" },
]}
/>,
);
// 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(<Harness onClose={onClose} />);
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(<Harness onClose={onClose} />);
cy.wait("@getTechSteps");
cy.get('[data-testid="outside-popover"]').click();
cy.get("@onClose").should("have.been.calledOnce");
});
});