batchCooking/apps/web/cypress/e2e/cooking-session-page.cy.ts
Nicolas 2b5ae6722a
Some checks failed
CI / build (push) Successful in 2m35s
CI / lint (push) Successful in 2m38s
CI / intent-service-test (push) Successful in 5m8s
CI / e2e (push) Successful in 9m17s
CI / test (push) Failing after 16m14s
CI / build-android (push) Has been skipped
fix(cooking-session): premier passage responsive dédié à l'écran de cuisine (#89)
cooking-session-page.scss n'avait encore aucune règle @media. Audit
dédié à cet écran (minuteur inexistant sur cette page — rien à faire
de ce côté, liste d'étapes, actions) :

- Bug d'overflow réel dans l'onglet Recettes : `.cooking-recipes__name`
  n'avait aucune borne dans sa ligne flex (contrairement au
  `.recipe-chip__name` équivalent côté planning) — un nom de recette
  long pouvait repousser le badge de portions et le chevron hors de la
  case (silencieusement coupés par le `overflow: hidden` de la carte
  parente). Corrigé avec le même traitement flex:1/min-width:0/
  ellipsis que son équivalent.

- Cibles tactiles sous le breakpoint phone : les cartes de tâches (`.
  cooking-task`, l'action principale et répétée de tout l'onglet Plan),
  les nœuds du stepper, les onglets, la ligne de l'onglet Recettes et
  les boutons Précédent/Suivant passent tous à --space-md pour dépasser
  confortablement les 48dp minimums d'Android (l'app est Android-only)
  même dans le pire cas (une tâche à une seule ligne, sans ustensiles).

- Piège rencontré : surcharger `.cooking-task` par un simple sélecteur
  de classe ne fonctionnait pas silencieusement — la règle globale
  partagée `label:has(> input[type="checkbox"])` (global.scss) a une
  spécificité CSS plus élevée qu'il n'y paraît (l'argument de :has()
  compte comme un élément + un attribut). Corrigé en réutilisant le
  même niveau d'imbrication que `.cooking-phase__tasks .cooking-task
  --done` juste au-dessus dans le même fichier, qui contournait déjà
  ce problème pour la même raison.

Couverture Cypress : nouveau describe "Cooking session page (phone)"
dans cooking-session-page.cy.ts (2 tests — padding effectivement
appliqué + troncature du nom de recette sans pousser le badge hors
champ). Suite complète : biome check, tsc -b --force, build, 146/146
e2e, 71/71 composants — tous verts.

Closes #89

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

494 lines
18 KiB
TypeScript

// 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
// `GET /cooking-session` behavior (including the optimizer) against a real
// database.
import { PHONE_VIEWPORT } from "../support/viewports";
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
// 2026-08-17 is a Monday — frozen so "this week" is deterministic.
const TODAY = new Date("2026-08-17T09:00:00Z");
/** Bare `IngredientView` — only `key` drives the page's label lookup. */
function ingredient(key: string) {
return {
id: 1,
key,
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
};
}
/** A 3-phase plan: a pooled prep task in mise-en-place, then two cooking phases, the last with a passive cook floated into its background. */
function planFixture() {
return {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
notes: [],
tasks: [
{
id: "prep:chop:onion",
kind: "merged-prep",
technique: { id: 1, key: "chop" },
description: null,
ingredients: [
{
ingredient: ingredient("onion"),
quantity: 5,
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
utensils: [{ id: 1, key: "knife" }],
temperatures: [],
sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
],
originalSteps: [],
},
],
},
{
index: 1,
kind: "cooking",
background: [],
notes: [],
tasks: [
{
id: "step:0:1",
kind: "step",
technique: { id: 5, key: "simmer" },
description: "Faire mijoter le bouillon",
ingredients: [],
utensils: [],
temperatures: [],
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
originalSteps: [],
},
],
},
{
index: 2,
kind: "cooking",
background: [
{
id: "bg:step:0:1",
technique: { id: 5, key: "simmer" },
description: "Faire mijoter le bouillon",
recipeId: 1,
recipeName: "Soupe à l'oignon",
},
],
notes: [],
tasks: [
{
id: "step:1:3",
kind: "step",
technique: { id: 4, key: "bake" },
description: "Enfourner la tarte",
ingredients: [],
utensils: [],
temperatures: [],
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }],
originalSteps: [],
},
],
},
],
};
}
/**
* A one-phase plan whose mise-en-place has a pooled `merged-setup` preheat
* (both recipes, same 180 °C) plus two un-merged preheats at 200 °C / 220 °C
* that trigger an `appliance-temperature-conflict` note.
*/
function setupPlanFixture() {
const soupe = { recipeId: 1, name: "Soupe", portions: 4 };
const gratin = { recipeId: 2, name: "Gratin", portions: 4 };
const roti = { recipeId: 3, name: "Rôti", portions: 4 };
return {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [soupe, gratin, roti],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
notes: [
{
kind: "appliance-temperature-conflict",
technique: { id: 9, key: "preheat" },
temperatures: [
{ celsius: 200, gasMark: null, qualitative: null },
{ celsius: 220, gasMark: null, qualitative: null },
],
},
],
tasks: [
{
id: "setup:preheat:c180",
kind: "merged-setup",
technique: { id: 9, key: "preheat" },
description: null,
ingredients: [],
utensils: [],
temperatures: [{ celsius: 180, gasMark: null, qualitative: null }],
sourceRecipes: [soupe, gratin],
originalSteps: [],
},
{
id: "step:1:10",
kind: "step",
technique: { id: 9, key: "preheat" },
description: "Préchauffer le four à 200°C",
ingredients: [],
utensils: [],
temperatures: [{ celsius: 200, gasMark: null, qualitative: null }],
sourceRecipes: [gratin],
originalSteps: [],
},
{
id: "step:2:20",
kind: "step",
technique: { id: 9, key: "preheat" },
description: "Préchauffer le four à 220°C",
ingredients: [],
utensils: [],
temperatures: [{ celsius: 220, gasMark: null, qualitative: null }],
sourceRecipes: [roti],
originalSteps: [],
},
],
},
],
};
}
/**
* Minimal `RecipeView` detail for one of `planFixture()`'s recipes — just
* enough (name/portions/one step) to prove the right recipe's own detail
* rendered inline, not a full catalog-style fixture.
*/
function recipeDetail(id: number, name: string, portions: number, stepDescription: string) {
return {
id,
name,
description: null,
picture: null,
portions,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [{ id: 1, description: stepDescription, picture: null, order: 1, techSteps: [] }],
};
}
describe("Cooking session page", () => {
beforeEach(() => {
cy.viewport(1400, 900);
cy.clock(TODAY, ["Date"]);
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("shows the empty message when nothing is planned that week", () => {
cy.intercept("GET", /\/cooking-session\?/, {
statusCode: 200,
body: {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [],
phases: [],
},
});
cy.visit("/cuisiner");
cy.contains("h1", "Cuisiner cette semaine").should("be.visible");
cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible");
});
it("opens on the Plan tab: a stepper of every phase, only the first phase's tasks shown", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as(
"getPlan",
);
cy.visit("/cuisiner?date=2026-08-17");
cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17");
// One stepper node per phase; the first is the current one.
cy.get(".cooking-stepper__node").should("have.length", 3);
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--current");
cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible");
cy.contains(".cooking-plan__count", "0 / 1 tâches").should("be.visible");
// Mise en place: the pooled prep task, flagged shared, naming both recipes.
cy.get(".cooking-task--merged-prep")
.should("contain.text", "Hacher")
.and("contain.text", "Oignon")
.and("contain.text", "Mutualisé");
// It sits in one mini section headed with both recipes' names.
cy.get(".cooking-phase__recipe-group-title").should(
"have.text",
"Soupe à l'oignon, Tarte à l'oignon",
);
// The next phase's step is not rendered yet.
cy.contains("Faire mijoter le bouillon").should("not.exist");
// Prev is disabled on the first phase.
cy.contains(".cooking-plan__nav-btn", "Étape précédente").should("be.disabled");
});
it("walks the phases with the next/prev buttons and by clicking a stepper node", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
// Forward to phase 2.
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible");
cy.contains(".cooking-phase__tasks", "Faire mijoter le bouillon").should("be.visible");
// Forward to phase 3 — its background band shows the soup still simmering.
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
cy.contains(".cooking-plan__position", "Étape 3 / 3").should("be.visible");
cy.contains(".cooking-phase__background", "Pendant ce temps")
.should("contain.text", "Faire mijoter le bouillon")
.and("contain.text", "Soupe à l'oignon");
cy.contains(".cooking-phase__tasks", "Enfourner la tarte").should("be.visible");
cy.contains(".cooking-plan__nav-btn", "Étape suivante").should("be.disabled");
// Back one step.
cy.contains(".cooking-plan__nav-btn", "Étape précédente").click();
cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible");
// Jump straight back to the first phase from the stepper.
cy.get(".cooking-stepper__node").first().click();
cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible");
cy.get(".cooking-task--merged-prep").should("be.visible");
});
it("ticks a task, marks its phase done in the stepper, and keeps the tick after a reload", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
cy.get(".cooking-task").first().find('input[type="checkbox"]').check({ force: true });
cy.get(".cooking-task").first().should("have.class", "cooking-task--done");
// The whole phase is now done — its stepper node reflects that.
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done");
cy.contains(".cooking-plan__count", "1 / 1 tâches").should("be.visible");
cy.reload();
cy.get(".cooking-task").first().should("have.class", "cooking-task--done");
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done");
});
it("lists the week's recipes on the Recettes tab, each showing its portions", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
cy.contains(".cooking-session-page__tab", "Recettes").click();
cy.get(".cooking-recipes__item").should("have.length", 2);
cy.contains(".cooking-recipes__item", "Soupe à l'oignon").should("be.visible");
cy.contains(".cooking-recipes__item", "Tarte à l'oignon").should("contain.text", "6 portions");
// The stepper isn't part of this tab.
cy.get(".cooking-stepper").should("not.exist");
});
it("expands a recipe's detail inline on the Recettes tab instead of navigating away", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.intercept("GET", "**/recipes/1", {
statusCode: 200,
body: recipeDetail(1, "Soupe à l'oignon", 4, "Émincer les oignons."),
}).as("getSoupe");
cy.intercept("GET", "**/recipes/2", {
statusCode: 200,
body: recipeDetail(2, "Tarte à l'oignon", 6, "Étaler la pâte."),
}).as("getTarte");
cy.visit("/cuisiner?date=2026-08-17");
cy.contains(".cooking-session-page__tab", "Recettes").click();
// Nothing unfolded yet, and no more link out to the catalog page —
// clicking a row is what unfolds it now.
cy.get(".cooking-recipes").find("a").should("not.exist");
cy.get(".cooking-recipes__detail").should("not.exist");
cy.contains(".cooking-recipes__row", "Soupe à l'oignon").click();
cy.wait("@getSoupe");
cy.location("pathname").should("eq", "/cuisiner"); // no navigation happened
cy.get(".cooking-recipes__detail .recipe-detail-panel").within(() => {
cy.contains("h2", "Soupe à l'oignon").should("be.visible");
cy.contains("Émincer les oignons.").should("be.visible");
});
// Only one recipe unfolds at a time — opening Tarte's closes Soupe's.
cy.contains(".cooking-recipes__row", "Tarte à l'oignon").click();
cy.wait("@getTarte");
cy.get(".cooking-recipes__detail").should("have.length", 1);
cy.get(".cooking-recipes__detail .recipe-detail-panel")
.should("contain.text", "Étaler la pâte.")
.and("not.contain.text", "Émincer");
// Clicking the open one again collapses it back.
cy.contains(".cooking-recipes__row", "Tarte à l'oignon").click();
cy.get(".cooking-recipes__detail").should("not.exist");
});
it("renders a merged-setup preheat with its temperature and the oven-conflict note", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() });
cy.visit("/cuisiner?date=2026-08-17");
// The pooled preheat shows the technique label + its pooled temperature,
// flagged shared like a merged-prep task.
cy.get(".cooking-task--merged-setup")
.should("contain.text", "180 °C")
.and("contain.text", "Mutualisé");
// The phase warns the oven is needed at two more temperatures, in order.
cy.get(".cooking-phase__note")
.should("contain.text", "2 températures")
.and("contain.text", "200 °C puis 220 °C");
// The two un-merged preheats are still listed as their own tasks.
cy.contains(".cooking-task", "Préchauffer le four à 200°C").should("be.visible");
cy.contains(".cooking-task", "Préchauffer le four à 220°C").should("be.visible");
cy.contains(".cooking-plan__count", "0 / 3 tâches").should("be.visible");
});
it("splits the phase into mini sections by recipe — pooled task first, then each solo preheat under its own recipe", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() });
cy.visit("/cuisiner?date=2026-08-17");
// Three distinct recipe combinations in this phase's tasks: the pooled
// preheat (Soupe + Gratin), then each un-merged preheat solo.
const titles = () => cy.get(".cooking-phase__recipe-group-title");
titles().should("have.length", 3);
titles().eq(0).should("have.text", "Soupe, Gratin");
titles().eq(1).should("have.text", "Gratin");
titles().eq(2).should("have.text", "Rôti");
// Each section holds only its own task.
cy.get(".cooking-phase__recipe-group")
.eq(0)
.find(".cooking-task--merged-setup")
.should("exist");
cy.get(".cooking-phase__recipe-group")
.eq(1)
.should("contain.text", "Préchauffer le four à 200°C")
.and("not.contain.text", "220°C");
cy.get(".cooking-phase__recipe-group")
.eq(2)
.should("contain.text", "Préchauffer le four à 220°C")
.and("not.contain.text", "200°C");
});
it("shows an error state when the request fails", () => {
cy.intercept("GET", /\/cooking-session\?/, {
statusCode: 500,
body: { code: 5000, message: "boom" },
});
cy.visit("/cuisiner");
cy.contains("Impossible de charger").should("be.visible");
});
});
// Ticket #89 — this page's first dedicated responsive pass (it previously
// had no `@media` rule at all, see cooking-session-page.scss's own header
// comment). No layout is restructured on phone (the flex-column shell and
// the two horizontally-scrolling rows already degrade safely) — this covers
// the two things that actually needed fixing: comfortable touch targets on
// the screen's own controls, and a real flex overflow bug in the Recettes
// tab (a long recipe name had no upper bound and could crowd its portions
// badge/chevron out of the row).
describe("Cooking session page (phone)", () => {
beforeEach(() => {
cy.setViewport("phone");
cy.clock(TODAY, ["Date"]);
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("gives the task cards and stepper nodes a taller, more tappable padding than on desktop", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
cy.get(".cooking-task")
.first()
.should(($el) => {
// --space-md (16px) on phone vs. the shared checkbox-label rule's
// own --space-xs (4px) app-wide default — see cooking-session-page
// .scss's phone block.
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
});
cy.get(".cooking-stepper__node")
.first()
.should(($el) => {
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
});
// The document itself never scrolls sideways, same global rule every
// other page is held to.
cy.document()
.its("documentElement.scrollWidth")
.should("be.lte", PHONE_VIEWPORT.width + 1);
});
it("truncates a long recipe name in the Recettes tab instead of pushing its portions badge out of view", () => {
const longName =
"Un très long nom de recette qui ne devrait surtout pas pousser le badge de portions hors de l'écran";
cy.intercept("GET", /\/cooking-session\?/, {
statusCode: 200,
body: {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [{ recipeId: 1, name: longName, portions: 4 }],
phases: [],
},
});
cy.visit("/cuisiner?date=2026-08-17");
cy.contains(".cooking-session-page__tab", "Recettes").click();
cy.get(".cooking-recipes__name").should(($el) => {
const s = getComputedStyle($el[0]);
expect(s.textOverflow).to.equal("ellipsis");
expect(s.whiteSpace).to.equal("nowrap");
// The name's own box is clipped narrower than its full text — proof
// the truncation is actually engaged, not just declared.
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
});
// ...and the badge survives, still on screen, not clipped away by the
// card's own `overflow: hidden`.
cy.contains(".cooking-recipes__portions", "4 portions").should("be.visible");
});
});