Etend le flux de correction existant (TechStepCorrectionPopover) pour que l'utilisateur associe lui-meme des ingredients (avec quantite/unite) et des ustensiles a la technique qu'il corrige, avec le meme marquage source: "manual" que la technique elle-meme. Backend : - submitTechStepCorrectionSchema (packages/shared) accepte des tableaux ingredients/utensils optionnels, chacun avec son propre span [start,end) selectionne par l'utilisateur. Omis = ne touche pas aux metadonnees existantes ; tableau (meme vide) = remplace tout ce qui existait sur cette occurrence (auto ET manuel precedent - decision validee avec l'utilisateur). - applyManualCorrection (recipe-tech-step-correction.service.ts) ecrit les nouvelles lignes StepTechStepIngredient/StepTechStepUtensil apres avoir vide celles de l'occurrence via deleteMany - meme chemin de code que ce soit une creation ou une mise a jour de la technique. - Nouveaux asserts d'existence (ingredient/unite/ustensile) + validation de span, nouveau code d'erreur UTENSIL_NOT_FOUND. - source ajoute a StepTechStepIngredientView/StepTechStepUtensilView (le calque manquait ce que la colonne DB portait deja). Frontend : - TechStepCorrectionPopover passe d'un clic = soumission immediate a un flux selection-puis-confirmation, avec deux nouvelles sections Ingredients/Ustensiles pre-remplies avec l'existant. - Ajouter un ingredient/ustensile demande une selection de texte dediee dans la description encore visible (StepDescription geree via un nouvel etat pendingSpanRequest/resolvedMetadataSpan) - pas de raccourci sur le span de la correction elle-meme. - Nouveau CatalogSearchPicker.tsx, plus leger que IngredientPicker pour ce contexte de popover, reutilise pour les deux catalogues. - getUtensils() ajoute a apiClient. Tests : nouveaux cas Mocha (attache/remplace/omission/validations) dans recipe-tech-step-correction.test.ts, TechStepCorrectionPopover.cy.tsx etendu avec le nouveau flux, recipes.ts (e2e) ajuste au clic Valider supplementaire. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
237 lines
8.7 KiB
TypeScript
237 lines
8.7 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" };
|
|
|
|
/**
|
|
* 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 (
|
|
<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}
|
|
selectedText="Cuire"
|
|
range={{ start: 0, end: 5 }}
|
|
previousTechStepId={previousTechStepId}
|
|
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
|
|
existingIngredients={existingIngredients as any}
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
existingUtensils={existingUtensils 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 and every technique option once loaded", () => {
|
|
cy.mount(<Harness />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
|
cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
|
|
});
|
|
|
|
it("offers a 'no technique here' option only when correcting an existing match", () => {
|
|
cy.mount(<Harness previousTechStepId={null} />);
|
|
cy.wait("@getTechSteps");
|
|
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
|
|
|
cy.mount(<Harness previousTechStepId={cook.id} />);
|
|
cy.wait("@getTechSteps");
|
|
cy.get(".tech-step-correction-popover__remove").should("exist");
|
|
});
|
|
|
|
it("selecting a technique reveals the Ingrédients/Ustensiles sections instead of submitting immediately", () => {
|
|
cy.mount(<Harness />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
|
|
|
cy.get(".tech-step-correction-popover__list").should("not.exist");
|
|
cy.contains("h4", "Ingrédients").should("be.visible");
|
|
cy.contains("h4", "Ustensiles").should("be.visible");
|
|
cy.contains("button", "Valider").should("be.visible");
|
|
});
|
|
|
|
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(<Harness onSubmitted={onSubmitted} />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains(".tech-step-correction-popover__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.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
|
cy.contains("button", "+ Ajouter un ingrédient").click();
|
|
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
|
|
|
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(
|
|
<Harness
|
|
previousTechStepId={cook.id}
|
|
existingIngredients={[
|
|
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
|
|
]}
|
|
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
|
|
/>,
|
|
);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
|
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(<Harness onClose={onClose} />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains(".tech-step-correction-popover__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");
|
|
});
|
|
});
|