batchCooking/apps/web/cypress/e2e/recipes.cy.ts
Nicolas 7d1a69cf12
Some checks failed
CI / lint (push) Successful in 3m2s
CI / build (push) Successful in 3m5s
CI / intent-service-test (push) Successful in 5m14s
CI / e2e (push) Successful in 9m38s
CI / test (push) Failing after 18m46s
CI / build-android (push) Has been skipped
fix(mobile): renforce le catalogue de recettes et la liste de courses sur mobile (#90)
Deux correctifs, regroupés en un seul ticket comme prévu :

- Catalogue de recettes (`recipes.scss`) : `.recipe-table-wrap` n'avait
  aucun garde-fou de scroll horizontal, contrairement à la grille
  planning — sur un viewport étroit, les 4 colonnes (photo/nom/
  allergènes/régimes) se compressaient au lieu de scroller. Ajout
  d'`overflow-x: auto` sur le wrapper + `min-width: 36rem` sur la
  table (échelle réduite par rapport aux 62rem de la grille planning :
  4 colonnes plus étroites, pas 7). Partagé par le catalogue complet
  ET par `RecipePickerDialog`, qui hérite du même correctif au passage.

- Liste de courses (`shopping-list-page.scss`) : audit dédié — la page
  n'a en réalité aucune case à cocher ni bouton de suppression (elle
  est volontairement en lecture seule, voir le commentaire de
  ShoppingListPage.tsx), contrairement à ce que supposait le ticket
  d'origine ; rien à agrandir comme cible tactile de ce côté. La
  structure ne déborde déjà pas non plus (`&-name` a déjà flex:1/
  min-width:0). Amélioration retenue à la place : une ligne un peu
  plus aérée et une icône plus grande sous le breakpoint phone, pour
  une lecture plus confortable en faisant les courses (liste tenue
  d'une main, coups d'œil répétés).

Couverture Cypress : nouveau describe "Recipe catalog (phone)" dans
recipes.cy.ts (garde-fou de scroll horizontal effectivement actif) ;
nouveau fichier shopping-list-page.cy.ts, spec layout simple (pas
Gherkin, cette page n'a pas encore ce type de fichier) pour le padding
de ligne. Suite complète : biome check, tsc -b --force, build, 146/146
e2e, 71/71 composants (un skip ponctuel de TechStepCorrectionPopover
en run groupé, reconfirmé 10/10 isolément — flake local sans lien avec
ce ticket) — tous verts.

Closes #90

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:02:48 +02:00

499 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
// behavior against a real database.
//
// The favorite-toggle and delete-recipe journeys moved to recipes.feature —
// this file now only covers catalog browsing/display.
import { PHONE_VIEWPORT } from "../support/viewports";
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
const vegetarien = { id: 1, key: "vegetarian" };
const _gluten = { id: 1, key: "gluten", kind: "INTOLERANCE" };
const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" };
const ratatouille = {
id: 1,
name: "Ratatouille",
description: null,
picture: null,
portions: 4,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [vegetarien],
isFavorite: true,
};
const omelette = {
id: 2,
name: "Omelette",
description: null,
picture: null,
portions: 2,
authorId: 1,
visibility: "PERSONAL",
allergens: [oeufs],
diets: [],
isFavorite: false,
};
const omeletteDetail = {
...omelette,
description: "Une omelette toute simple.",
ingredients: [
{
ingredient: {
id: 10,
key: "egg",
icon: "EGG",
category: "dairyAndCheese",
subcategory: "eggs",
allergens: [oeufs],
diets: [],
},
quantity: 3,
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
steps: [
{ id: 1, description: "Battre les œufs.", picture: null, order: 1, techSteps: [] },
{ id: 2, description: "Cuire à la poêle.", picture: null, order: 2, techSteps: [] },
],
};
/**
* A 3-step recipe whose middle step already has an existing technique match
* carrying attached ingredient/utensil metadata — for the step-numbering
* regression test below, which needs a correction popover with several
* `<li>` rows of its own open at once (see that test's own comment).
*/
const tarteDetail = {
id: 5,
name: "Tarte",
description: null,
picture: null,
portions: 4,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [
{ id: 1, description: "Préchauffer le four.", picture: null, order: 1, techSteps: [] },
{
id: 2,
description: "Faire revenir les oignons avec une poêle.",
picture: null,
order: 2,
techSteps: [
{
techStep: { id: 2, key: "panFry" },
start: 0,
end: 13,
source: "auto",
ingredients: [
{
ingredient: {
id: 10,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
allergens: [],
diets: [],
},
quantity: 2,
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
start: 18,
end: 25,
source: "auto",
},
],
utensils: [{ utensil: { id: 20, key: "pan" }, start: 31, end: 40, source: "auto" }],
temperatures: [],
},
],
},
{ id: 3, description: "Servir chaud.", picture: null, order: 3, techSteps: [] },
],
};
/**
* A one-step recipe whose single technique match carries no attached
* metadata yet — for the issue #36 regression test below, which needs a
* clean correction popover and a description word ("oignons") that appears
* *only* in the step text, nowhere in the popover itself.
*/
const brownStepRecipe = {
id: 6,
name: "Soupe",
description: null,
picture: null,
portions: 4,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [
{
id: 1,
description: "Faire revenir les oignons.",
picture: null,
order: 1,
techSteps: [
{
techStep: { id: 3, key: "brown" },
start: 0,
end: 13,
source: "auto",
ingredients: [],
utensils: [],
temperatures: [],
},
],
},
],
};
/**
* 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() {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
}
describe("Recipe catalog", () => {
beforeEach(() => {
interceptAuth();
});
it("defaults to the Favoris tab and lists its recipes", () => {
cy.intercept("GET", /\/recipes\?/, (req) => {
expect(req.url).to.include("tab=favoris");
req.reply({ statusCode: 200, body: [ratatouille] });
}).as("listRecipes");
cy.visit("/recettes");
cy.wait("@listRecipes");
cy.contains("h1", "Recettes").should("be.visible");
cy.get(".recipe-tabs__tab.active").should("contain.text", "Favoris");
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
// The favorited row carries the ★ fav-mark.
cy.contains(".recipe-table__name", "Ratatouille")
.find(".recipe-table__fav-mark")
.should("exist");
cy.contains(".recipe-table__name", "Ratatouille")
.parents("tr")
.find(".diet-badge")
.should("contain.text", "Végétarien");
});
it("shows the empty state when a tab has no recipes", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.visit("/recettes");
cy.contains("Aucune recette pour le moment.").should("be.visible");
});
it("shows an error state when the catalog fails to load", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 500, body: { code: 5000, message: "boom" } });
cy.visit("/recettes");
cy.contains(".recipes-page__status--error", "Impossible de charger").should("be.visible");
});
it("switches tabs, re-fetching each one's own recipes", () => {
cy.intercept("GET", /\/recipes\?/, (req) => {
const tab = new URL(req.url).searchParams.get("tab");
const body = tab === "perso" ? [omelette] : [ratatouille];
req.reply({ statusCode: 200, body });
}).as("listRecipes");
cy.visit("/recettes");
cy.wait("@listRecipes");
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
// Not asserting the specific request URL here — React StrictMode (see
// main.tsx) double-invokes mount/update effects in dev, so this can
// legitimately fire twice; the rendered result converges either way
// (same reasoning as planning-page.cy.ts's week-navigation tests).
cy.contains(".recipe-tabs__tab", "Perso").click();
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
});
it("searches within the active tab, debounced", () => {
cy.intercept("GET", /\/recipes\?/, (req) => {
const search = new URL(req.url).searchParams.get("search");
req.reply({ statusCode: 200, body: search ? [omelette] : [ratatouille, omelette] });
}).as("listRecipes");
cy.visit("/recettes");
cy.wait("@listRecipes");
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
// Same "assert the rendered result, not the request count/URL" reasoning
// as the tab-switch test above — the 300ms debounce plus StrictMode's
// double-invoked effects make the exact number/order of requests an
// implementation detail, not something worth pinning down here.
cy.get(".recipes-page__search").type("Omel");
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
});
it("opens a recipe's detail alongside the table when its row is selected", () => {
// Desktop-only master-detail layout, same reasoning as
// planning-page.cy.ts's "Planning grid" tests — wider/taller than
// Cypress's default 1000×660, which doesn't leave the detail panel
// (photo + header + ingredients + steps) enough height to show
// everything without needing its own internal scroll (by design, see
// `.recipe-detail-panel` in recipes.scss) — this test asserts full
// visibility without scrolling, so it needs the room.
cy.viewport(1600, 900);
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe");
cy.visit("/recettes");
cy.contains(".recipe-table__name", "Omelette").click();
cy.wait("@getRecipe");
cy.url().should("include", "/recettes/2");
// The table stays mounted (master-detail, not a page navigation) —
// both rows are still visible next to the detail panel.
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
cy.get("tr.selected .recipe-table__name").should("contain.text", "Omelette");
cy.get(".recipe-detail-panel").within(() => {
cy.contains("h2", "Omelette").should("be.visible");
cy.contains("Une omelette toute simple.").should("be.visible");
cy.contains("Battre les œufs.").should("be.visible");
cy.contains("Cuire à la poêle.").should("be.visible");
});
});
// 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
// (recipes.scss) used a bare descendant `li` selector, matching *any*
// `<li>` nested inside a step — including ones several levels down
// inside `TechStepCorrectionPopover` (each attached ingredient/utensil/
// temperature row, and the technique picker's own `<li>` list). Opening
// a correction with attached metadata inflated the counter, throwing off
// every *later* step's own displayed number — exactly the "les étapes
// affichées sont numérotées n'importe comment" bug report this covers.
it("keeps every step's own number correct while a correction popover with attached metadata is open", () => {
cy.viewport(1600, 900);
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.intercept("GET", "**/recipes/5", { statusCode: 200, body: tarteDetail });
cy.intercept("GET", "**/reference/tech-steps", {
statusCode: 200,
body: [{ id: 2, key: "panFry" }],
});
cy.visit("/recettes/5");
cy.get(".recipe-detail-panel__steps > li").should("have.length", 3);
// Open the middle step's correction — pre-seeded with one ingredient
// and one utensil, so its popover renders two `.tech-step-correction-popover__row`
// `<li>`s of its own (plus the technique picker's own `<li>` list once
// "Changer" is clicked) sitting *inside* the second step's own `<li>`.
cy.contains(".step-tech-step", "Faire revenir").click();
cy.get(".tech-step-correction-popover__row").should("have.length", 2);
cy.contains(".tech-step-correction-popover__technique-change", "Changer").click();
cy.get(".catalog-search-picker__list li").should("have.length.greaterThan", 0);
// None of the popover's own nested `<li>`s join the step counter chain
// — `getComputedStyle(...).content` isn't reliable for reading a
// *resolved* counter() value across browsers (it can return the raw
// `counter(step) ". "` declaration instead), so this checks the actual
// `counter-increment` computed property instead: a real, directly
// testable signal for exactly what `> li` (recipes.scss) does and
// doesn't scope to.
cy.get(".tech-step-correction-popover__row").each(($row) => {
expect(window.getComputedStyle($row[0]).counterIncrement).to.equal("none");
});
cy.get(".catalog-search-picker__list li").each(($item) => {
expect(window.getComputedStyle($item[0]).counterIncrement).to.equal("none");
});
// The step `<li>`s themselves are still correctly in the chain.
cy.get(".recipe-detail-panel__steps > li").each(($step) => {
expect(window.getComputedStyle($step[0]).counterIncrement).to.equal("step 1");
});
});
// Regression (issue #36): while the correction popover's "add an
// ingredient/utensil/temperature" flow waits for a text selection, the
// mousedown that *starts* that drag lands on the step description — a DOM
// sibling of the popover — and used to count as an outside-click,
// closing the popover before mouseup; the selection then opened a fresh
// blank popover for the selected word as a brand-new technique. The
// description container is now exempt from the popover's outside-click.
it("keeps the same correction popover open while selecting a span for an ingredient", () => {
cy.viewport(1600, 900);
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.intercept("GET", "**/recipes/6", { statusCode: 200, body: brownStepRecipe });
cy.intercept("GET", "**/reference/tech-steps", {
statusCode: 200,
body: [{ id: 3, key: "brown" }],
});
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] });
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [] });
cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [] });
cy.visit("/recettes/6");
// Open the technique correction, then ask to attach an ingredient — the
// popover now shows the "select the span in the description" hint.
cy.contains(".step-tech-step", "Faire revenir").click();
cy.get(".tech-step-correction-popover").should("have.length", 1);
cy.get(".tech-step-correction-popover__selection").should("contain.text", "Faire revenir");
cy.contains(".tech-step-correction-popover__add-row", "Ajouter un ingrédient").click();
cy.get(".tech-step-correction-popover__hint").should("be.visible");
// Reproduce the real mousedown → drag → mouseup over the description.
// The mousedown is the event that used to close the popover.
cy.contains(".recipe-detail-panel__steps p", "Faire revenir").as("stepParagraph");
cy.get("@stepParagraph").trigger("mousedown");
cy.window().then((win) => {
const paragraph = win.document.querySelector(".recipe-detail-panel__steps p") as HTMLElement;
const span = [...paragraph.querySelectorAll<HTMLElement>("[data-offset]")].find((el) =>
el.textContent?.includes("oignons"),
);
const textNode = span?.firstChild as Text;
const local = textNode.textContent?.indexOf("oignons") ?? 0;
const range = win.document.createRange();
range.setStart(textNode, local);
range.setEnd(textNode, local + "oignons".length);
const selection = win.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
});
cy.get("@stepParagraph").trigger("mouseup");
// Still the *same* popover — its heading still quotes the technique span
// "Faire revenir", not "oignons" — now showing the ingredient sub-form
// for the freshly selected span, not a blank new-technique popover.
cy.get(".tech-step-correction-popover").should("have.length", 1);
cy.get(".tech-step-correction-popover__selection").should("contain.text", "Faire revenir");
cy.get(".tech-step-correction-popover__add-form").should("be.visible");
});
it("shows a not-found message for a selected id the API rejects", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.intercept("GET", "**/recipes/999", {
statusCode: 404,
// ErrorCode.RECIPE_NOT_FOUND (packages/shared/src/errors/error-codes.ts)
// — RecipeDetailPanel only renders the "not found" message for this
// exact code, anything else falls into its generic error state.
body: { code: 4045, message: "not found" },
});
cy.visit("/recettes/999");
cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible");
});
it("links the new-recipe button to the recipe form", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.visit("/recettes");
cy.contains(".recipes-page__new-button", "Nouvelle recette").should(
"have.attr",
"href",
"/recettes/nouvelle",
);
});
});
// Ticket #90 — `.recipe-table-wrap` had no horizontal-scroll guard at all
// (unlike the planning grid's own wrapper), so a narrow viewport just let
// the browser compress every column instead of scrolling. Below the
// tablet breakpoint (1024px) the master-detail split already collapses to
// one column (recipes.scss's own `bp.tablet-down` rule) and gives the
// table the full page width — still comfortably under `.recipe-table`'s
// own 36rem floor on a phone, which is what this covers.
describe("Recipe catalog (phone)", () => {
beforeEach(() => {
cy.setViewport("phone");
interceptAuth();
});
it("keeps the table on its own horizontal scroll, at full width, instead of squeezing its columns", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
cy.visit("/recettes");
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.get(".recipe-table-wrap").should(($el) => {
const s = getComputedStyle($el[0]);
expect(s.overflowX).to.be.oneOf(["auto", "scroll"]);
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
});
// ...and that overflow stays *inside* the wrapper — same global rule
// every other page is held to.
cy.document()
.its("documentElement.scrollWidth")
.should("be.lte", PHONE_VIEWPORT.width + 1);
});
});