batchCooking/apps/web/cypress/e2e/planning-page.cy.ts
Nicolas 642b37ebec
All checks were successful
CI / lint (push) Successful in 1m39s
CI / build (push) Successful in 1m43s
CI / intent-service-test (push) Successful in 3m56s
CI / e2e (push) Successful in 8m37s
CI / test (push) Successful in 16m12s
CI / build-android (push) Has been skipped
fix(planning): affiche les recettes du picker sur mobile (#111)
Sur téléphone, le dialogue d'ajout d'une recette au planning devenait
plein écran mais `.recipe-picker-dialog` gardait un `height: 80vh` figé.
Dans cette colonne flex de hauteur bornée, le bloc `.recipe-picker__filters`
(rendu pour les 4 onglets non-source) consommait toute la place et
`.recipes-page__catalog` (`flex: 1`) s'effondrait à ~0 : la table des
recettes était dans le DOM mais dans un scrollport de hauteur nulle, donc
les onglets Favoris / Perso / Foyer / Publique paraissaient vides.

Sous le breakpoint phone : le dialogue passe vraiment plein écran (comme
tout autre `Dialog`), `.recipe-picker__filters` ne se laisse plus
compresser, et le catalogue prend sa hauteur de contenu — c'est
`.dialog-panel__body` qui scrolle.

Closes #111

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

375 lines
14 KiB
TypeScript
Raw 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 logout journey (also reachable from here, via the account menu) moved
// to auth.feature — same behavior, no need to cover it twice.
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 via `cy.clock` so "today"/"this week"
// assertions are deterministic instead of depending on the day the suite
// happens to run.
const TODAY = new Date("2026-08-17T09:00:00Z");
function freezeToday() {
cy.clock(TODAY, ["Date"]);
}
describe("Sidebar navigation", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.visit("/");
});
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
// toggle, not the main nav tested here — are covered by sidebar.cy.ts.
it("highlights the current section and navigates between pages", () => {
cy.contains("nav a", "Planning").should("have.class", "active");
cy.contains("nav a", "Recettes").click();
cy.url().should("include", "/recettes");
cy.contains("h1", "Recettes").should("be.visible");
cy.contains("nav a", "Recettes").should("have.class", "active");
cy.contains("nav a", "Planning").should("not.have.class", "active");
cy.contains("nav a", "Liste de courses").click();
cy.url().should("include", "/liste-de-courses");
cy.contains("h1", "Liste de courses").should("be.visible");
cy.contains("nav a", "Planning").click();
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
cy.contains("h1", "Planning de la semaine").should("be.visible");
});
});
describe("Planning grid", () => {
beforeEach(() => {
// Desktop-only design (see the plan/PR description) — wider than
// Cypress's default 1000×660 so all 7 day columns fit without the grid's
// horizontal scroll hiding the later ones from visibility assertions.
cy.viewport(1600, 900);
freezeToday();
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("shows an empty grid (every slot just offering '+') when the household has no planning yet", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning").its("request.url").should("include", "date=2026-08-17");
cy.contains("h1", "Planning de la semaine").should("be.visible");
// 5 meal rows × 7 days = 35 empty slots, each just a "+".
cy.get(".add-recipe-btn").should("have.length", 35);
cy.get(".recipe-chip").should("not.exist");
});
it("renders each recipe in its (day, meal) cell, and highlights today's column", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "mardi",
meal: "diner",
portions: 4,
recipe: { id: 1, name: "Ratatouille" },
},
{
id: 2,
weekDay: "mercredi",
meal: "dejeuner",
portions: 2,
recipe: { id: 2, name: "Curry de lentilles" },
},
],
},
});
cy.visit("/");
cy.contains("th", "Lundi").should("be.visible");
cy.contains("th", "Dimanche").should("be.visible");
cy.contains(".recipe-chip", "Ratatouille").should("be.visible");
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
// Today (17 août, Lundi) is marked — its column header carries `.today`.
cy.contains("th.today .day-date", "17").should("be.visible");
});
it("disables 'Commencer à cuisiner' on an empty week and enables + navigates it once a recipe is planned", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.visit("/");
cy.contains("button", "Commencer à cuisiner").should("be.disabled");
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "mardi",
meal: "diner",
portions: 4,
recipe: { id: 1, name: "Ratatouille" },
},
],
},
});
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("/");
cy.contains("button", "Commencer à cuisiner").should("not.be.disabled").click();
cy.url().should("include", "/cuisiner");
cy.url().should("include", "date=2026-08-17");
});
it("shows a loading state, then an error state when the request fails", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 500,
body: { code: 5000, message: "boom" },
});
cy.visit("/");
cy.contains("Impossible de charger le planning, réessayez plus tard").should("be.visible");
});
// Assertions below check the rendered week label/badge, not the intercepted
// request count — React StrictMode (see main.tsx) double-invokes effects in
// dev, so the `GET /planning` mount effect can fire twice per navigation;
// counting exact `cy.wait` calls against that would be flaky, but the
// rendered result is the same either way.
it("navigates to the next/previous week, re-fetching each time", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning").its("request.url").should("include", "date=2026-08-17");
cy.contains("Semaine du 17 au 23 août 2026").should("be.visible");
cy.contains("Cette semaine").should("be.visible");
cy.get(".week-nav__arrow").last().click();
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.contains("Cette semaine").should("not.exist");
cy.get(".week-nav__arrow").first().click();
cy.contains("Semaine du 17 au 23 août 2026").should("be.visible");
cy.get(".week-nav__arrow").first().click();
cy.contains("Semaine du 10 au 16 août 2026").should("be.visible");
});
it("jumps to an arbitrary week by picking a day in the calendar popover", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
cy.contains("button", "Semaine du").click();
cy.get(".calendar-popover").should("be.visible");
// Picking the 25th (still August, unambiguous in the visible grid)
// should jump to the week of the 24th–30th.
cy.get(".calendar-grid__day").contains(/^25$/).click();
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.get(".calendar-popover").should("not.exist");
});
it("opens the calendar inside a bottom sheet on a phone instead of the anchored popover", () => {
// Literal 390×844 rather than `cy.setViewport("phone")` (ticket #83) —
// that command doesn't exist on this ticket's own base yet; matches its
// PHONE_VIEWPORT value on purpose, worth switching to the real import
// once both have landed on `main`.
cy.viewport(390, 844);
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
cy.contains("button", "Semaine du").click();
cy.get(".calendar-popover").should("not.exist");
cy.get(".bottom-sheet").should("be.visible");
cy.contains(".bottom-sheet", "Choisir une semaine").should("be.visible");
cy.get(".bottom-sheet .calendar-grid__day").contains(/^25$/).click();
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.get(".bottom-sheet").should("not.exist");
});
});
// Ticket #88 — the 7-column grid gives way entirely to `PlanningDayView` on
// a phone (not the same table, scrolled): a switcher plus one day's meals
// stacked as cards. `.recipe-chip`/`.add-recipe-btn` are the same elements
// either layout renders (see `mealCellBody`, shared by `MealCell` and
// `MealCellRow` in PlanningPage.tsx), so this only covers what's actually
// different here — day selection, its clamped edges, and that the day
// view's own "+" still opens the real recipe picker.
describe("Planning day view (phone)", () => {
beforeEach(() => {
cy.setViewport("phone");
freezeToday();
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("defaults to today, shows only that day's recipes, and clamps at Monday", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "diner",
portions: 4,
recipe: { id: 1, name: "Ratatouille" },
},
{
id: 2,
weekDay: "mardi",
meal: "dejeuner",
portions: 2,
recipe: { id: 2, name: "Curry de lentilles" },
},
],
},
}).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
// The table is gone entirely on phone, not just visually hidden.
cy.get("table.planning-grid").should("not.exist");
cy.get(".planning-day-view").should("be.visible");
// Today (17 août, Lundi) is the default day — only its own recipe shows.
cy.contains(".planning-day-view__label", "Lundi").should("be.visible");
cy.contains(".planning-day-view__label", "17").should("be.visible");
cy.contains(".planning-day-view__label .today-badge", "Aujourd'hui").should("be.visible");
cy.contains(".recipe-chip", "Ratatouille").should("be.visible");
cy.contains(".recipe-chip", "Curry de lentilles").should("not.exist");
// Clamped, not wrapping: Monday is the week's first day.
cy.get(".planning-day-view__arrow").first().should("be.disabled");
cy.get(".planning-day-view__arrow").last().click();
cy.contains(".planning-day-view__label", "Mardi").should("be.visible");
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
cy.contains(".recipe-chip", "Ratatouille").should("not.exist");
cy.get(".planning-day-view__arrow").first().should("not.be.disabled");
});
it("clamps at Sunday going forward, and its own '+' opens the recipe picker", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
for (let i = 0; i < 6; i++) {
cy.get(".planning-day-view__arrow").last().click();
}
cy.contains(".planning-day-view__label", "Dimanche").should("be.visible");
cy.get(".planning-day-view__arrow").last().should("be.disabled");
cy.get(".add-recipe-btn").first().click();
cy.get(".recipe-picker-dialog").should("be.visible");
cy.contains(".recipe-picker-dialog", "Choisir une recette").should("be.visible");
});
});
// Issue #111 — on a phone the recipe picker is a full-height flex column;
// a regression in the mobile full-screen-dialog CSS let its tall filter
// block (search + ingredient/diet pickers, rendered only for the four
// non-source tabs) consume the column and collapse the recipe table below
// it into a zero-height scrollport. The table was in the DOM the whole
// time, so Favoris / Perso / Foyer / Publique all just looked empty.
describe("Recipe picker dialog (phone)", () => {
// Full `RecipeSummaryView` shape — `RecipeTable` reads `allergens`/`diets`
// (`.length`/`.map`) on every row, so `{ id, name }` alone crashes it.
function summary(id: number, name: string) {
return {
id,
name,
description: null,
picture: null,
portions: 4,
authorId: 1,
visibility: "PERSONAL" as const,
allergens: [],
diets: [],
isFavorite: false,
};
}
const recipesByTab: Record<string, ReturnType<typeof summary>[]> = {
favoris: [summary(1, "Ratatouille")],
perso: [summary(2, "Omelette")],
foyer: [summary(3, "Gratin dauphinois")],
publique: [summary(4, "Soupe de potiron")],
};
const tabLabel: Record<string, string> = {
favoris: "Favoris",
perso: "Perso",
foyer: "Foyer",
publique: "Publique",
};
beforeEach(() => {
cy.setViewport("phone");
freezeToday();
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
// The dialog's own reference-data / source fetches — all empty, so the
// filter block is only as tall as its always-present controls, which is
// already enough to trigger the collapse this covers.
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] });
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] });
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [] });
cy.intercept("GET", /\/recipes\?/, (req) => {
const tab = new URL(req.url).searchParams.get("tab") ?? "favoris";
req.reply({ statusCode: 200, body: recipesByTab[tab] ?? [] });
});
});
it("shows the recipe table on every non-source tab, not a collapsed empty area", () => {
cy.visit("/");
cy.get(".add-recipe-btn").first().click();
cy.get(".recipe-picker-dialog").should("be.visible");
for (const tab of Object.keys(recipesByTab)) {
cy.contains(".recipe-tabs__tab", tabLabel[tab]).click();
// `be.visible` fails on a zero-height / clipped element — the exact
// shape of the regression.
cy.get(".recipe-picker-dialog .recipe-table").should("be.visible");
cy.get(".recipe-picker-dialog .recipe-table tbody tr").should("have.length.greaterThan", 0);
cy.contains(".recipe-picker-dialog .recipe-table__name", recipesByTab[tab][0].name).should(
"be.visible",
);
}
});
});