Suite à la maquette validée : redessine TechStepCorrectionPopover et ajoute l'édition de la température, jusqu'ici la seule des 4 métadonnées sans aucune interface (ni dans l'UI, ni côté API — voir la PR back-end empilée sous celle-ci). - StepDescription.tsx : le surlignage de température devient cliquable (un <button>, plus un <span> mort) — rouvre le popover de correction de la technique à laquelle il est rattaché (findTechStepForTemperature, résolu par span puisque la température n'a pas d'existence propre, elle est une métadonnée de clause comme les ingrédients/ustensiles). - TechStepCorrectionPopover.tsx : réécrit. Les 4 sections (Technique, Ingrédients, Ustensiles, Température) restent toutes visibles en même temps — plus de bascule de tout le popover vers un sous-écran pendant l'ajout d'un ingrédient/ustensile/température, seule la section concernée affiche son propre mini-formulaire, les autres (dont le picker de technique) restent inchangées à côté. Le picker de technique se cache derrière "Changer" une fois une technique choisie (rien à décider une fois confirmée correcte). Chaque ingrédient/ustensile/ température attaché s'affiche en ligne avec sa citation exacte du texte source (nouveau : la traçabilité vers le texte existait déjà en donnée, mais n'était affichée nulle part). Nouveau sélecteur à 3 modes (°C / Thermostat / Qualitatif) pour la température, cohérent avec le modèle de données (un seul des trois à la fois). Icônes SVG par catégorie (tag/feuille/poêle/thermomètre), même langage stroke-based que layouts/nav-icons.tsx. - recipes.scss : nouveau langage visuel du popover (sections avec icône, lignes de métadonnées façon carte plutôt que puces, bouton "+ Ajouter" en pointillés plutôt qu'un lien texte, sélecteurs segmentés). Les deux surlignages (.step-tech-step, .step-temperature) passent de `cursor: help` à `cursor: pointer` — les deux sont maintenant de vrais déclencheurs cliquables. - i18n : nouvelles clés (changeTechnique, temperatureSection, addTemperature, removeTemperature, temperatureMode.*) ; clé currentTechnique supprimée (le pictogramme remplace la phrase "Technique retenue : X") ; préfixe "+ " retiré de addIngredient/addUtensil (l'icône + le porte déjà visuellement). - Tests : TechStepCorrectionPopover.cy.tsx largement réécrit (10 tests, dont 2 nouveaux sur la température) ; recipes.feature/recipes.ts : nouveau scénario Gherkin "cliquer un surlignage de température rouvre le popover de sa technique" (fixture Gratin dédiée), step "Changer" ajouté au scénario de relabel existant. Vérifié : pnpm lint + build (shared/api/web) clean. Suites Cypress exécutées localement (Chrome headless) : TechStepCorrectionPopover.cy.tsx 10/10, suite component complète 50/50, recipes.feature 5/5, recipes.cy.ts 8/8, recipe-sources.feature 6/6, planning-page.cy.ts 7/7. Un échec pré-existant et sans rapport (recipe-form.feature, "Preloads an existing recipe's ingredients when editing") reproduit identiquement avec ce travail entièrement stashé — confirmé non lié. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
350 lines
14 KiB
TypeScript
350 lines
14 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("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");
|
|
});
|
|
});
|