fix(recipes): rend StepDescription tolérant à un techSteps absent
All checks were successful
CI / lint (push) Successful in 4m3s
CI / build (push) Successful in 4m36s
CI / intent-service-test (push) Successful in 5m53s
CI / e2e (push) Successful in 8m56s
CI / test (push) Successful in 18m54s

StepDescription faisait `useState(techSteps)` puis `liveTechSteps.flatMap(...)` :
quand la prop `techSteps` arrivait `undefined`, `.flatMap` levait
`TypeError: Cannot read properties of undefined (reading 'flatMap')`, remontée
par Cypress comme une erreur applicative non capturée. Observé de façon
intermittente sur le parcours « modifier une recette » : après enregistrement,
`RecipeFormPage` navigue vers `/recettes/:id`, dont le détail se rendait parfois
avant que `techSteps` ne soit peuplé.

- `StepDescription` : `techSteps` devient optionnel et `liveTechSteps` retombe
  sur `[]` (dans le `useState` et le `useEffect` de resync), pour que tous les
  consommateurs (`.flatMap`, `splitDescriptionSegments`,
  `findTechStepForTemperature`, lookup `activeStepTechStep`) aient toujours un
  vrai tableau — même posture « tolérer la donnée manquante, ne pas planter »
  que le reste du composant.
- `recipe-form.ts` : le mock de la recette 7 envoyait une étape sans `techSteps`,
  contrairement au vrai `StepView` et à tous les autres mocks de la suite —
  corrigé en `techSteps: []`.
- `recipes.cy.ts` : nouveau test de non-régression déterministe — un payload
  d'étape sans `techSteps` doit rendre la description en texte brut. Vérifié
  qu'il échoue bien (`flatMap` undefined) sans le correctif du composant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-06 09:04:36 +02:00
parent 713d6c520d
commit 8e55f3d3f9
3 changed files with 78 additions and 12 deletions

View file

@ -108,7 +108,11 @@ Given("recipe 7 exists with an egg omelette", () => {
diets: [], diets: [],
isFavorite: false, isFavorite: false,
ingredients: [{ ingredient: egg, quantity: 3, unit: pieceUnit }], ingredients: [{ ingredient: egg, quantity: 3, unit: pieceUnit }],
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], // `techSteps: []` matches the real `StepView` shape (packages/shared) —
// every other recipe-step mock in the suite sends it, and its absence
// here let `/recettes/7` (rendered after this recipe saves) hit
// `StepDescription` with `techSteps === undefined`.
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0, techSteps: [] }],
}; };
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe });
}); });

View file

@ -166,6 +166,29 @@ const brownStepRecipe = {
], ],
}; };
/**
* A recipe whose step object is missing `techSteps` entirely (not `[]`) —
* a partial/older payload shape. `StepDescription` must render its
* description as plain text rather than crashing on `liveTechSteps.flatMap`.
* Regression: an intermittent `recipe-form.feature` failure where saving an
* edited recipe navigates to `/recettes/:id`, whose detail loaded before
* `techSteps` was populated.
*/
const techStepsMissingDetail = {
id: 7,
name: "Omelette",
description: null,
picture: null,
portions: 2,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 1 }],
};
function interceptAuth() { function interceptAuth() {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
@ -284,6 +307,27 @@ describe("Recipe catalog", () => {
}); });
}); });
// Regression: a step payload missing `techSteps` entirely (see
// `techStepsMissingDetail`) made `StepDescription` throw
// `Cannot read properties of undefined (reading 'flatMap')`, surfacing as
// an uncaught application error. An uncaught exception fails the spec on
// its own, so simply asserting the description renders is enough — no
// extra `cy.on("uncaught:exception")` needed.
it("renders a step whose techSteps field is absent from the payload, as plain text", () => {
cy.viewport(1600, 900);
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: techStepsMissingDetail }).as(
"getRecipe",
);
cy.visit("/recettes/7");
cy.wait("@getRecipe");
cy.get(".recipe-detail-panel__steps").within(() => {
cy.contains("Battre les œufs.").should("be.visible");
});
});
// Regression: `.recipe-detail-panel__steps`'s CSS step counter // Regression: `.recipe-detail-panel__steps`'s CSS step counter
// (recipes.scss) used a bare descendant `li` selector, matching *any* // (recipes.scss) used a bare descendant `li` selector, matching *any*
// `<li>` nested inside a step — including ones several levels down // `<li>` nested inside a step — including ones several levels down

View file

@ -71,14 +71,15 @@ function findTechStepForTemperature(
* no click handlers) — this mode is purely additive, not a rewrite of the * no click handlers) — this mode is purely additive, not a rewrite of the
* read-only rendering. * read-only rendering.
* *
* Maintains its own local copy of `techSteps` (seeded from the prop, then * Maintains its own local copy of `techSteps` (seeded from the prop — or
* replaced with whatever `POST .../corrections` returns on a successful * `[]` if it's momentarily absent, see the prop's own note — then replaced
* submit — see `SubmitTechStepCorrectionResult`'s doc comment, * with whatever `POST .../corrections` returns on a successful submit — see
* `packages/shared`) so a correction's effect (a new/relabeled/removed * `SubmitTechStepCorrectionResult`'s doc comment, `packages/shared`) so a
* highlight) appears immediately, without needing the parent to re-fetch * correction's effect (a new/relabeled/removed highlight) appears
* the whole recipe. Resynced whenever the `techSteps` prop itself changes * immediately, without needing the parent to re-fetch the whole recipe.
* (e.g. the parent reloaded the recipe for an unrelated reason) so this * Resynced whenever the `techSteps` prop itself changes (e.g. the parent
* never keeps showing stale local state past that. * reloaded the recipe for an unrelated reason) so this never keeps showing
* stale local state past that.
*/ */
export function StepDescription({ export function StepDescription({
description, description,
@ -88,15 +89,32 @@ export function StepDescription({
stepId, stepId,
}: { }: {
description: string; description: string;
techSteps: StepTechStepView[]; /**
* The step's detected technique sequence. `StepView.techSteps`
* (`packages/shared`) always sends an array — `[]` when nothing matched —
* but this is optional so a partial/not-yet-resolved payload degrades to
* "render the description as plain text" instead of crashing on
* `.flatMap`, the same "tolerate missing data, don't crash" posture this
* component already takes for a match with no `contextStart`/`contextEnd`
* or a temperature with no owning occurrence. Seen `undefined` in
* practice on the post-save navigation of the "edit an existing recipe"
* flow, before the reloaded recipe's detail had resolved
* (`recipe-form.feature`).
*/
techSteps?: StepTechStepView[];
/** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */ /** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */
editable?: boolean; editable?: boolean;
recipeId?: number; recipeId?: number;
stepId?: number; stepId?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [liveTechSteps, setLiveTechSteps] = useState(techSteps); // `?? []` here, not just at each read site, so every consumer below
useEffect(() => setLiveTechSteps(techSteps), [techSteps]); // (`.flatMap` for `temperatures`, `splitDescriptionSegments`,
// `findTechStepForTemperature`, the `activeStepTechStep` lookup) always
// has a real array — see the `techSteps` prop's own note for when it
// arrives absent.
const [liveTechSteps, setLiveTechSteps] = useState(techSteps ?? []);
useEffect(() => setLiveTechSteps(techSteps ?? []), [techSteps]);
// Temperatures are attached per technique-clause server-side; for the // Temperatures are attached per technique-clause server-side; for the
// read-only highlight we only care *where* in the description they are, // read-only highlight we only care *where* in the description they are,