// 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");
});
// Issue #110 — a slot with many recipes used to stretch its `
`, and
// with it the whole row and the table, past `.planning-grid-wrapper`
// into a vertical scroll (the other cells of that row just gaining dead
// space). The chip list now scrolls inside its own cell instead.
it("keeps a heavy slot's recipes scrolling inside the cell, not stretching the grid", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: Array.from({ length: 14 }, (_, i) => ({
id: i + 1,
weekDay: "lundi",
meal: "dejeuner",
portions: 2,
recipe: { id: i + 1, name: `Recette ${i + 1}` },
})),
},
});
cy.visit("/");
cy.get(".recipe-chip").should("have.length", 14);
// The busy cell's own recipe list is now the scroller: it's capped and
// scrolls its own overflow instead of stretching the ` | ` (and with
// it the whole row, then the table past `.planning-grid-wrapper` into a
// tall vertical scroll).
cy.get(".meal-cell__recipes").should(($lists) => {
const list = [...$lists].find(
(el) => el.querySelectorAll(".recipe-chip").length === 14,
) as HTMLElement;
expect(list, "the 14-recipe cell's list").to.exist;
expect(getComputedStyle(list).overflowY).to.be.oneOf(["auto", "scroll"]);
expect(list.scrollHeight).to.be.greaterThan(list.clientHeight);
expect(list.clientHeight).to.be.lessThan(200);
});
cy.get(".planning-grid-wrapper").should(($w) => {
expect($w[0].scrollHeight).to.be.lessThan($w[0].clientHeight * 1.25);
});
});
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[]> = {
favoris: [summary(1, "Ratatouille")],
perso: [summary(2, "Omelette")],
foyer: [summary(3, "Gratin dauphinois")],
publique: [summary(4, "Soupe de potiron")],
};
const tabLabel: Record = {
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",
);
}
});
});
|