Some checks failed
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>
385 lines
15 KiB
TypeScript
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");
|
|
});
|
|
});
|