diff --git a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts index 27aa906..a9e4319 100644 --- a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts +++ b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts @@ -32,22 +32,24 @@ const correctionInclude = { } satisfies Prisma.StepTechStepCorrectionInclude; /** - * Loads `stepId`'s current `description` length (the only thing a - * correction needs from the step itself), or throws — `404 STEP_NOT_FOUND` - * if no such step exists, or if it exists but doesn't belong to `recipeId` - * (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a - * request naming a real step under the wrong recipe should look identical - * to naming one that doesn't exist, same "don't leak which part was wrong" - * posture `assertRecipeVisible` already has for visibility). Otherwise - * whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`, - * never `403`) if the recipe exists but isn't visible to the viewer. + * Loads `stepId`'s current `description` (a correction needs its full text + * now, not just its length — see {@link submitTechStepCorrection}'s + * `raw` derivation for a manual temperature mention), or throws — + * `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't + * belong to `recipeId` (the route's own `:id`/`:stepId` nesting is + * meaningless otherwise — a request naming a real step under the wrong + * recipe should look identical to naming one that doesn't exist, same + * "don't leak which part was wrong" posture `assertRecipeVisible` already + * has for visibility). Otherwise whatever {@link assertRecipeVisible} + * throws (`404 RECIPE_NOT_FOUND`, never `403`) if the recipe exists but + * isn't visible to the viewer. */ async function loadVisibleStepOrThrow( recipeId: number, stepId: number, viewerId: number, viewerHouseId: number | null, -): Promise<{ id: number; descriptionLength: number }> { +): Promise<{ id: number; description: string }> { try { const step = await prisma.step.findUnique({ where: { id: stepId }, @@ -57,7 +59,7 @@ async function loadVisibleStepOrThrow( throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`); } await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId); - return { id: step.id, descriptionLength: step.description.length }; + return { id: step.id, description: step.description }; } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } @@ -208,6 +210,21 @@ interface ManualUtensilMention { start: number; end: number; } +/** + * Same as {@link ManualIngredientMention}, for a temperature — `raw` is + * already resolved by the caller ({@link submitTechStepCorrection}, which + * has the step's full `description` text to slice) rather than trusted + * from the client; see `manualStepTechStepTemperatureInputSchema`'s own + * doc comment (`packages/shared`) for why. + */ +interface ManualTemperatureMention { + celsius: number | null; + gasMark: number | null; + qualitative: "low" | "medium" | "high" | null; + raw: string; + start: number; + end: number; +} /** * Applies a correction's *effect* on `stepId`'s real `StepTechStep` @@ -233,16 +250,16 @@ interface ManualUtensilMention { * * `metadata`, when given (only ever alongside a real `correctedTechStepId` * — enforced by `submitTechStepCorrectionSchema`, not re-checked here), - * replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on - * this occurrence — `source: "auto"` (the classifier's own detection) and - * any earlier `"manual"` set alike — with the newly-submitted one. This is - * "le manuel remplace tout" (confirmed with the user): the resolved - * `order` this technique ends up at (whichever branch above produced it) - * is the same `techStepOrder` both metadata tables key on, so the same - * `deleteMany` + `createMany` pair below is correct whether this call just - * updated an existing row (which may already carry auto-detected - * metadata) or created a brand new one (nothing to delete yet — a no-op - * `deleteMany`, not a special case). + * replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/ + * `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the + * classifier's own detection) and any earlier `"manual"` set alike — with + * the newly-submitted one. This is "le manuel remplace tout" (confirmed + * with the user): the resolved `order` this technique ends up at + * (whichever branch above produced it) is the same `techStepOrder` all + * three metadata tables key on, so the same `deleteMany` + `createMany` + * pair below is correct whether this call just updated an existing row + * (which may already carry auto-detected metadata) or created a brand new + * one (nothing to delete yet — a no-op `deleteMany`, not a special case). * * Runs inside the same transaction {@link submitTechStepCorrection} uses * for the audit-trail insert, so a request never leaves any of these @@ -255,7 +272,11 @@ async function applyManualCorrection( span: { start: number; end: number }, previousTechStepId: number | null, correctedTechStepId: number | null, - metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] }, + metadata?: { + ingredients: ManualIngredientMention[]; + utensils: ManualUtensilMention[]; + temperatures: ManualTemperatureMention[]; + }, ): Promise { const existing = await tx.stepTechStep.findMany({ where: { stepId } }); @@ -329,6 +350,22 @@ async function applyManualCorrection( })), }); } + await tx.stepTechStepTemperature.deleteMany({ where: { stepId, techStepOrder: order } }); + if (metadata.temperatures.length > 0) { + await tx.stepTechStepTemperature.createMany({ + data: metadata.temperatures.map((temperature) => ({ + stepId, + techStepOrder: order, + celsius: temperature.celsius, + gasMark: temperature.gasMark, + qualitative: temperature.qualitative, + raw: temperature.raw, + start: temperature.start, + end: temperature.end, + source: "manual", + })), + }); + } } } else if (target) { await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } }); @@ -366,10 +403,11 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr * @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see * {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if * `start`/`end` (the correction's own span, or any of - * `input.ingredients`/`input.utensils`' own spans) fall outside the - * step's current `description` (it may have been edited since the user - * last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/ - * `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id + * `input.ingredients`/`input.utensils`/`input.temperatures`' own spans) + * fall outside the step's current `description` (it may have been + * edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/ + * `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` + * if any referenced id * doesn't exist. */ export async function submitTechStepCorrection( @@ -381,18 +419,20 @@ export async function submitTechStepCorrection( ): Promise { try { const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId); + const descriptionLength = step.description.length; const spans = [ { start: input.start, end: input.end }, ...(input.ingredients ?? []), ...(input.utensils ?? []), + ...(input.temperatures ?? []), ]; for (const span of spans) { - if (span.start >= step.descriptionLength || span.end > step.descriptionLength) { + if (span.start >= descriptionLength || span.end > descriptionLength) { throw new HttpError( 400, ErrorCode.INVALID_CORRECTION_SPAN, - `Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`, + `Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${descriptionLength})`, ); } } @@ -428,7 +468,9 @@ export async function submitTechStepCorrection( { start: input.start, end: input.end }, input.previousTechStepId ?? null, input.correctedTechStepId ?? null, - input.ingredients === undefined && input.utensils === undefined + input.ingredients === undefined && + input.utensils === undefined && + input.temperatures === undefined ? undefined : { ingredients: (input.ingredients ?? []).map((ingredient) => ({ @@ -443,6 +485,16 @@ export async function submitTechStepCorrection( start: utensil.start, end: utensil.end, })), + // `raw` is derived here (not sent by the client) — see + // `ManualTemperatureMention`'s own doc comment. + temperatures: (input.temperatures ?? []).map((temperature) => ({ + celsius: temperature.celsius ?? null, + gasMark: temperature.gasMark ?? null, + qualitative: temperature.qualitative ?? null, + raw: step.description.slice(temperature.start, temperature.end), + start: temperature.start, + end: temperature.end, + })), }, ); diff --git a/apps/api/test/recipe/recipe-tech-step-correction.test.ts b/apps/api/test/recipe/recipe-tech-step-correction.test.ts index acebbff..757b2c3 100644 --- a/apps/api/test/recipe/recipe-tech-step-correction.test.ts +++ b/apps/api/test/recipe/recipe-tech-step-correction.test.ts @@ -518,6 +518,191 @@ describe("Recipe tech-step corrections", () => { }); }); + describe("POST /recipes/:id/steps/:stepId/corrections — temperature metadata", () => { + it("attaches a manually-selected celsius temperature, deriving raw from the step's own description", async () => { + const { agent, profileId } = await signup(); + const description = "Préchauffez à 180°C."; + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description); + const preheatId = await techStepId("preheat"); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 0, + end: 11, + correctedTechStepId: preheatId, + temperatures: [{ celsius: 180, start: 14, end: 19 }], + }); + + expect(res.status).to.equal(201); + expect(res.body.techSteps).to.deep.equal([ + { + techStep: { id: preheatId, key: "preheat" }, + start: 0, + end: 11, + source: "manual", + ingredients: [], + utensils: [], + temperatures: [ + { + celsius: 180, + gasMark: null, + qualitative: null, + // Not sent by the client — derived server-side from the span + // against the step's real description (see + // `ManualTemperatureMention`'s doc comment). + raw: "180°C", + start: 14, + end: 19, + source: "manual", + }, + ], + }, + ]); + }); + + it("attaches a manually-selected qualitative temperature", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + const simmerId = await techStepId("simmer"); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + correctedTechStepId: simmerId, + temperatures: [{ qualitative: "low", start: 0, end: 5 }], + }); + + expect(res.status).to.equal(201); + expect(res.body.techSteps[0].temperatures).to.deep.equal([ + { + celsius: null, + gasMark: null, + qualitative: "low", + raw: "Faire", + start: 0, + end: 5, + source: "manual", + }, + ]); + }); + + it("attaches a manually-selected gas-mark temperature", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + const simmerId = await techStepId("simmer"); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + correctedTechStepId: simmerId, + temperatures: [{ gasMark: 6, start: 0, end: 5 }], + }); + + expect(res.status).to.equal(201); + expect(res.body.techSteps[0].temperatures[0]).to.deep.equal({ + celsius: null, + gasMark: 6, + qualitative: null, + raw: "Faire", + start: 0, + end: 5, + source: "manual", + }); + }); + + it("rejects a temperature with more than one of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + correctedTechStepId: await techStepId("simmer"), + temperatures: [{ celsius: 180, qualitative: "high", start: 0, end: 5 }], + }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("rejects a temperature with none of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + correctedTechStepId: await techStepId("simmer"), + temperatures: [{ start: 0, end: 5 }], + }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("replaces both auto-detected and previously-manual temperatures on the same occurrence — never accumulates", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + const simmerId = await techStepId("simmer"); + const boilId = await techStepId("boil"); + + await agent + .post(`/recipes/${recipeId}/steps/${stepId}/corrections`) + .send({ start: 6, end: 13, correctedTechStepId: simmerId }); + // Simulate an auto-detected temperature already sitting on the + // occurrence, exactly as `toStepTechStepCreateInputs` would have + // written one at save time. + await prisma.stepTechStepTemperature.create({ + data: { + stepId, + techStepOrder: 0, + celsius: 100, + raw: "100°C", + start: 0, + end: 5, + source: "auto", + }, + }); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + previousTechStepId: simmerId, + correctedTechStepId: boilId, + temperatures: [{ gasMark: 6, start: 0, end: 5 }], + }); + + expect(res.status).to.equal(201); + expect(res.body.techSteps).to.have.length(1); + expect(res.body.techSteps[0].temperatures).to.deep.equal([ + { + celsius: null, + gasMark: 6, + qualitative: null, + raw: "Faire", + start: 0, + end: 5, + source: "manual", + }, + ]); + }); + + it("rejects a temperature span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => { + const { agent, profileId } = await signup(); + const description = "Court."; + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 0, + end: description.length, + correctedTechStepId: await techStepId("simmer"), + temperatures: [{ celsius: 180, start: 0, end: description.length + 10 }], + }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN); + }); + }); + describe("GET /recipes/:id/steps/:stepId/corrections", () => { it("returns every correction submitted for the step, most recent first", async () => { const { agent, profileId } = await signup(); diff --git a/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx index 2eaf71c..ee7a14f 100644 --- a/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx +++ b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx @@ -16,6 +16,9 @@ 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 @@ -29,18 +32,20 @@ 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"; + kind: "ingredient" | "utensil" | "temperature"; range: { start: number; end: number }; text: string; } | null>(null); @@ -52,13 +57,16 @@ function Harness({ setResolvedMetadataSpan({ @@ -90,34 +98,40 @@ describe("TechStepCorrectionPopover", () => { ); }); - it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => { + it("shows the selected text, the technique picker (searchable) and all three metadata sections 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). + // 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("h4", "Ingrédients").should("be.visible"); - cy.contains("h4", "Ustensiles").should("be.visible"); + 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(); cy.wait("@getTechSteps"); - cy.get(".tech-step-correction-popover__remove").should("not.exist"); + 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(); cy.wait("@getTechSteps"); - cy.get(".tech-step-correction-popover__remove").should("exist"); + 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", @@ -149,7 +163,7 @@ describe("TechStepCorrectionPopover", () => { cy.contains("button", "Valider").should("not.be.disabled"); }); - it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => { + 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: { @@ -200,14 +214,16 @@ describe("TechStepCorrectionPopover", () => { ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", "Mijoter", ).click(); - cy.contains("button", "+ Ajouter un ingrédient").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__add-form button", "Ajouter").click(); - cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible"); + 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") @@ -221,28 +237,85 @@ describe("TechStepCorrectionPopover", () => { { ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 }, ], utensils: [], + temperatures: [], }); }); - it("pre-seeds existing ingredients/utensils, removable via their own chip", () => { + 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(); + 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( , ); // 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. + // 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__chip", "50 g Beurre").find("button").click(); - cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist"); + 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", () => { diff --git a/apps/web/cypress/e2e/recipes.feature b/apps/web/cypress/e2e/recipes.feature index 29914e2..8ccdeb8 100644 --- a/apps/web/cypress/e2e/recipes.feature +++ b/apps/web/cypress/e2e/recipes.feature @@ -45,6 +45,21 @@ Feature: Managing a recipe from the catalog When I focus the highlighted technique "Cuire" Then the tooltip should show "Mijoter (correction manuelle)" + Scenario: Clicking a highlighted temperature reopens its technique's correction popover + # Same master-detail room requirement as recipes.cy.ts's own equivalent + # test — Cypress's default 1000×660 doesn't leave the detail panel + # (with the popover open below its step) enough height to show + # everything without its own scroll. + Given the viewport is 1600 by 900 + And the recipe catalog contains "Gratin" + And recipe 4's detail is available + And the tech steps reference list includes preheat + When I visit "/recettes/4" + Then I should see the highlighted temperature "feu moyen" + When I click the highlighted temperature "feu moyen" + Then I should see the technique correction options + And the chosen technique should be "Préchauffer" + Scenario: Deletes a recipe after a two-step confirmation, then clears the selection Given the recipe catalog contains "Omelette" And recipe 2's detail is available diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts index 206e4fb..fdf7eea 100644 --- a/apps/web/cypress/e2e/recipes.ts +++ b/apps/web/cypress/e2e/recipes.ts @@ -48,6 +48,50 @@ const omeletteDetail = { ], }; +/** + * A separate, self-contained fixture for the "clicking a temperature + * highlight" scenario — kept apart from `omeletteDetail` above rather than + * adding a temperature to its "Cuire" occurrence, so this doesn't entangle + * with that fixture's own "Corrects a detected technique" scenario (which + * relabels the same occurrence without touching metadata, and must keep + * meaning "nothing here changed"). + */ +const gratinDetail = { + id: 4, + name: "Gratin", + description: null, + picture: null, + portions: 4, + authorId: 1, + visibility: "PERSONAL", + allergens: [], + diets: [], + isFavorite: false, + ingredients: [], + steps: [ + { + id: 10, + // "Préchauffez" = [0, 11), "à feu moyen" = [20, 31) — counted char by + // char (accented/multi-byte characters here are each still one JS + // string index, same convention `start`/`end` use everywhere else). + description: "Préchauffez le four à feu moyen.", + picture: null, + order: 1, + techSteps: [ + { + techStep: { id: 9, key: "preheat" }, + start: 0, + end: 11, + source: "auto", + temperatures: [ + { celsius: null, gasMark: null, qualitative: "medium", start: 20, end: 31 }, + ], + }, + ], + }, + ], +}; + Given("the disliked ingredients list is empty", () => { cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); }); @@ -68,6 +112,23 @@ Given("deleting recipe 2 will succeed", () => { cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); }); +Given("recipe 4's detail is available", () => { + cy.intercept("GET", "**/recipes/4", { statusCode: 200, body: gratinDetail }); +}); + +// TechStepCorrectionPopover fetches the full technique catalog on mount +// (to resolve the chosen technique's own label) — a dedicated fixture +// naming just `preheat` (`gratinDetail`'s own technique), kept separate +// from "the tech steps reference list has options" above (that one's +// `cook`/`simmer` pair is purpose-built for the *other* scenario's +// relabel, not a generic catalog stand-in). +Given("the tech steps reference list includes preheat", () => { + cy.intercept("GET", "**/reference/tech-steps", { + statusCode: 200, + body: [{ id: 9, key: "preheat" }], + }); +}); + // Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only // existing match is `cook` (id 1) — see that fixture above. The response // mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit @@ -96,6 +157,7 @@ Given('correcting step 2\'s "Cuire" match will succeed', () => { source: "manual", ingredients: [], utensils: [], + temperatures: [], }, ], }, @@ -110,6 +172,23 @@ Then("I should see the technique correction options", () => { cy.get(".tech-step-correction-popover").should("be.visible"); }); +Then("I should see the highlighted temperature {string}", (text: string) => { + // Same "off the fold, needs an explicit scroll" reasoning as + // common.steps.ts's "I should see the highlighted technique" — a bare + // `.should("be.visible")` doesn't auto-scroll. + cy.contains(".step-temperature", text).scrollIntoView().should("be.visible"); +}); + +// Temperature is corrected through its owning technique's own popover, not +// on its own — see `StepDescription.tsx`'s `findTechStepForTemperature`. +When("I click the highlighted temperature {string}", (text: string) => { + cy.contains(".step-temperature", text).click(); +}); + +Then("the chosen technique should be {string}", (label: string) => { + cy.contains(".tech-step-correction-popover__chosen-technique", label).should("be.visible"); +}); + // Picking a technique only *selects* it — it takes a separate "Valider" // click to actually submit (room was made for attaching ingredient/utensil // metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc @@ -118,8 +197,13 @@ Then("I should see the technique correction options", () => { // technique catalog is picked via the same searchable `CatalogSearchPicker` // the ingredient/utensil sub-flows use, scoped to // `__technique-section` since that same search-and-pick component is -// reused inside this popover for more than just techniques. +// reused inside this popover for more than just techniques. This step is +// only ever used to *relabel* an existing match (this feature's one +// scenario using it) — the picker hides behind "Changer" in that case +// (nothing to decide once a technique's already confirmed correct, see +// TechStepCorrectionPopover's own doc comment), so it's reopened first. When("I choose {string} as the correct technique", (label: string) => { + cy.contains(".tech-step-correction-popover__technique-change", "Changer").click(); cy.contains( ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", label, diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index 11543d9..de13290 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -673,7 +673,10 @@ text-decoration: underline dotted var(--color-primary); text-decoration-thickness: 1px; text-underline-offset: 0.15em; - cursor: help; + // A real clickable trigger (opens `TechStepCorrectionPopover`), not just + // a hover-for-a-tooltip target — `pointer`, not `help`, same reasoning + // now shared by `.step-temperature` below. + cursor: pointer; &:hover, &:focus-visible { @@ -702,17 +705,31 @@ // `--color-primary`, so a temperature reads as its own kind of metadata at // a glance. `--low`/`--medium`/`--high` only vary the underline weight — // the qualitative scale is a nuance, not three different colors. +// +// Renders as a ` ); } @@ -263,11 +329,13 @@ export function StepDescription({