batchCooking/apps/web/cypress/e2e/layout.cy.ts
Nicolas 0fd4928efc
All checks were successful
CI / lint (push) Successful in 51s
CI / build (push) Successful in 2m10s
CI / intent-service-test (push) Successful in 3m58s
CI / e2e (push) Successful in 8m26s
CI / test (push) Successful in 16m33s
CI / build-android (push) Has been skipped
feat(layout): socle UI mobile - nav, feuille et dialogs plein ecran
Fondation du chantier UI mobile & tablette (#91) : jusqu'ici, toute
adaptation mobile de ce repo etait du CSS pur sur le meme DOM (aucun
composant ne branchait son JSX selon la largeur/plateforme). Ce ticket
introduit le premier vrai branchement, et les primitifs que les 8
tickets suivants du chantier reutiliseront.

- useIsMobileViewport() (apps/web/src/lib/) : point de verite unique,
  base sur matchMedia, live (reagit a un resize/une rotation).
- _breakpoints.scss : $phone/$tablet Sass + mixins phone/phone-up/
  tablet-down, remplacant les nombres magiques 640/1024 repetes en dur
  dans 6 fichiers (AppLayout, landing, recipes, recipe-picker-dialog,
  planning-page, dialog, tours-page). Les deux occurrences de 860px
  (landing hero, tours-page) sont deliberement laissees telles quelles
  - un collapse de grille 2 colonnes qui n'a rien a voir avec le
  breakpoint tablette du catalogue recettes, les unifier aurait change
  un comportement visuel reel sans raison.
- useNativeDialogBehavior : mecanique (showModal/close event/clic
  exterieur) extraite de Dialog.tsx, partagee avec le nouveau
  BottomSheet.tsx (feuille mobile plein-largeur, meme fondation
  <dialog> natif que Dialog, juste ancree en bas).
- AppLayout : sur telephone, la sidebar n'est plus reflow-ee en CSS en
  barre horizontale (l'ancien traitement) - elle est remplacee par une
  vraie barre d'onglets basse (Planning/Recettes/Liste de courses +
  Plus), le "Plus" ouvrant une BottomSheet combinant Parametres et
  Compte (deux dropdowns flottants separes cote desktop -> un seul
  point d'entree mobile, IA plus simple, un seul emplacement d'onglet).
- dialog.scss : vrai plein-ecran (bord a bord, sans arrondi) sous le
  breakpoint phone au lieu d'une marge simplement reduite.

Verifie en local : pnpm --filter web exec tsc -b --force, biome check,
pnpm --filter web build, suite Cypress e2e complete (140/140) et
composants (63/63) - y compris layout.cy.ts, reecrit pour tester la
nouvelle barre basse (l'ancien test de la barre horizontale n'a plus
de sens, ce traitement n'existe plus).

Closes #91

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

368 lines
14 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 API
// behavior against a real database.
//
// Pure layout/style specs — plain Cypress, no Cucumber (see the 3-way test
// split: journeys via Gherkin, generic components via Component Testing,
// and this file's category: the app *shell*'s own structural/visual
// contract, independent of any particular page's content or user journey).
// Regression coverage for the two bugs fixed in #21: the sidebar scrolling
// away with a tall page, and pages not consistently using the available
// width (some stuck to the left edge with a lopsided gap, others correctly
// full-bleed).
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
function interceptAuth() {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
}
describe("App shell — sidebar is a fixed-width rail on every page", () => {
beforeEach(() => {
interceptAuth();
});
it("keeps the sidebar at its full expanded width (15rem = 240px), regardless of the page", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.visit("/");
cy.get(".app-sidebar")
.should(($sidebar) => {
expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1);
})
// Flush to the top-left corner of the viewport — nothing pushes it
// down or in, on any page.
.and(($sidebar) => {
const rect = $sidebar[0].getBoundingClientRect();
expect(rect.top).to.equal(0);
expect(rect.left).to.equal(0);
});
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
cy.visit("/recettes");
cy.get(".app-sidebar").should(($sidebar) => {
expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1);
});
});
it("shrinks to the icon-only rail width (4.25rem = 68px) once collapsed, and restores 240px when expanded again", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.visit("/");
cy.get(".app-sidebar").should(($sidebar) => {
expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1);
});
cy.get(".app-sidebar__collapse-toggle").click();
cy.get(".app-sidebar").should(($sidebar) => {
expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(68, 1);
});
cy.get(".app-sidebar__collapse-toggle").click();
cy.get(".app-sidebar").should(($sidebar) => {
expect($sidebar[0].getBoundingClientRect().width).to.be.closeTo(240, 1);
});
});
});
describe("App shell — viewport-locked height, independent scroll (#21 regression)", () => {
beforeEach(() => {
interceptAuth();
});
it("pins the whole shell to exactly the viewport height, never taller", () => {
cy.viewport(1200, 700);
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.visit("/");
cy.get(".app-layout").should(($layout) => {
expect($layout[0].getBoundingClientRect().height).to.be.closeTo(700, 1);
expect(getComputedStyle($layout[0]).overflow).to.equal("hidden");
});
// The document itself never grows past the viewport — this is the exact
// root cause of the original bug (a tall page scrolling the whole
// document, dragging the sidebar along with it).
cy.document().its("documentElement.scrollHeight").should("be.closeTo", 700, 1);
});
it("scrolls only the content area on a page taller than the viewport — the sidebar never moves and the document itself doesn't scroll", () => {
cy.viewport(1200, 700);
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.visit("/");
// A synthetic spacer, far taller than the viewport — deliberately
// independent of whatever the planning page's own content happens to
// be, since this test is about the shell's scroll contract, not this
// particular page's height.
cy.get(".app-content").then(($content) => {
const spacer = document.createElement("div");
spacer.style.height = "3000px";
spacer.setAttribute("data-cy", "scroll-spacer");
$content[0].appendChild(spacer);
});
cy.get(".app-sidebar").then(($sidebar) => {
const topBefore = $sidebar[0].getBoundingClientRect().top;
cy.get(".app-content").scrollTo("bottom");
cy.get(".app-sidebar").should(($again) => {
expect($again[0].getBoundingClientRect().top).to.equal(topBefore);
});
});
// The scroll genuinely happened inside `.app-content`...
cy.get(".app-content").invoke("scrollTop").should("be.greaterThan", 0);
// ...and not on the document/window itself.
cy.window().its("scrollY").should("equal", 0);
});
});
describe("Page width — full-bleed pages vs. centered reading columns (#21 regression)", () => {
beforeEach(() => {
interceptAuth();
cy.viewport(1600, 900);
});
it("stretches the planning page, recipe catalog, and settings pages across the full content width", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.visit("/");
assertFillsContentWidth(".planning-page");
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
cy.visit("/recettes");
assertFillsContentWidth(".recipes-page");
// Settings pages (Compte/Préférences/Foyer) used to be capped at
// max-width: 56rem, cramped in a centered column on wide viewports —
// that cap was dropped so they fill the width like every other page.
cy.visit("/parametres/compte");
assertFillsContentWidth(".settings-page");
cy.intercept("GET", /\/shopping-list\?/, {
statusCode: 200,
body: { startDate: "2026-08-17", finishDate: "2026-08-23", items: [] },
});
cy.visit("/liste-de-courses");
assertFillsContentWidth(".shopping-list-page");
});
/** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */
function assertFillsContentWidth(selector: string) {
cy.get(".app-content").then(($content) => {
const style = getComputedStyle($content[0]);
const available =
$content[0].getBoundingClientRect().width -
Number.parseFloat(style.paddingLeft) -
Number.parseFloat(style.paddingRight);
cy.get(selector).should(($page) => {
expect($page[0].getBoundingClientRect().width).to.be.closeTo(available, 3);
});
});
}
});
describe("Responsive breakpoint — the sidebar becomes a bottom tab bar under 640px", () => {
// Regression coverage for the mobile/tablet UI chantier's foundation
// ticket: below this point the desktop `<aside>` sidebar isn't reflowed
// by CSS anymore, it's replaced outright by a fixed bottom nav (a
// genuinely different component tree — see AppLayout.tsx's own doc
// comment on why). This block used to test the old CSS-squash treatment
// (sidebar → horizontal top bar); that treatment no longer exists.
beforeEach(() => {
interceptAuth();
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
});
it("renders the bottom nav instead of the sidebar, with every route tab legible and a working Plus tab", () => {
cy.viewport(375, 812);
cy.visit("/");
// The desktop sidebar isn't just hidden — it's not in the DOM at all.
cy.get(".app-sidebar").should("not.exist");
cy.get(".app-bottom-nav").should(($nav) => {
const rect = $nav[0].getBoundingClientRect();
expect(rect.width).to.be.closeTo(375, 1);
// A short bar flush against the bottom edge, not a tall rail.
expect(rect.height).to.be.lessThan(120);
expect(rect.bottom).to.be.closeTo(812, 1);
expect(getComputedStyle($nav[0]).position).to.equal("fixed");
});
// Every route tab keeps its icon *and* label legible — this bar never
// falls back to icon-only or horizontal scroll, unlike the old
// squashed sidebar treatment it replaces (only 4 short tabs, always
// fits).
cy.contains(".app-bottom-nav a", "Planning").find("span.label").should("be.visible");
cy.contains(".app-bottom-nav a", "Recettes").should("have.attr", "href", "/recettes");
cy.contains(".app-bottom-nav a", "Liste de courses").should(
"have.attr",
"href",
"/liste-de-courses",
);
// The "Plus" tab opens a bottom sheet with Paramètres + Compte, closed
// by default.
cy.get(".bottom-sheet").should("not.exist");
cy.contains(".app-bottom-nav button", "Plus").click();
cy.get(".bottom-sheet").should("be.visible");
cy.contains(".bottom-sheet", "Bonjour Alice").should("be.visible");
cy.contains(".bottom-sheet a", "Compte").should("have.attr", "href", "/parametres/compte");
cy.contains(".bottom-sheet a", "Préférences alimentaires").should(
"have.attr",
"href",
"/parametres/preferences",
);
cy.contains(".bottom-sheet button", "Se déconnecter").should("be.visible");
// The close button calls the native `<dialog>`'s own `.close()`, whose
// `close` event `useNativeDialogBehavior` forwards back to
// `MobileBottomNav`'s own state — same wiring Escape/tap-outside use,
// exercised here since it doesn't depend on this Cypress/Electron
// environment faithfully replicating the browser's own native
// Escape-closes-a-modal-dialog behavior (a browser built-in this app
// doesn't implement itself — see Dialog.tsx's own doc comment).
cy.get(".bottom-sheet__close").click();
cy.get(".bottom-sheet").should("not.exist");
});
it("keeps the desktop sidebar (not the bottom nav) above the phone breakpoint", () => {
cy.viewport(1000, 700);
cy.visit("/");
cy.get(".app-sidebar").should("exist");
cy.get(".app-bottom-nav").should("not.exist");
});
});
describe("Responsive breakpoint — page content under 640px (issue #39)", () => {
const PHONE: [number, number] = [375, 812];
beforeEach(() => {
interceptAuth();
});
it("tightens .app-content padding from 32px to 16px on a phone", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.viewport(1600, 900);
cy.visit("/");
cy.get(".app-content").should(($el) => {
const s = getComputedStyle($el[0]);
// Desktop: var(--space-xl) === 2rem === 32px, all four sides.
expect(s.paddingTop).to.equal("32px");
expect(s.paddingLeft).to.equal("32px");
});
cy.viewport(...PHONE);
cy.visit("/");
cy.get(".app-content").should(($el) => {
const s = getComputedStyle($el[0]);
// Phone: var(--space-md) === 1rem === 16px.
expect(s.paddingTop).to.equal("16px");
expect(s.paddingLeft).to.equal("16px");
});
});
it("keeps the 7-day planning grid on its own horizontal scroll rather than overflowing the page", () => {
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: "dejeuner",
portions: 4,
portionsRequested: 4,
discardedPortions: 0,
sourceItem: null,
recipe: { id: 1, name: "Curry de lentilles" },
},
],
},
});
cy.viewport(...PHONE);
cy.visit("/");
// The grid wrapper is the scroll container, and its content is genuinely
// wider than the phone viewport (7 columns never crushed).
cy.get(".planning-grid-wrapper").should(($el) => {
expect(getComputedStyle($el[0]).overflowX).to.be.oneOf(["auto", "scroll"]);
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth);
});
// ...and that overflow stays *inside* the wrapper — the document itself
// never scrolls sideways (global rule against a horizontally-scrolling
// body).
cy.document()
.its("documentElement.scrollWidth")
.should("be.lte", 375 + 1);
// The per-recipe remove "✕" is hover-gated on desktop; on touch it must
// be permanently visible or a planned recipe can't be removed at all.
cy.get(".recipe-chip__remove")
.first()
.should(($btn) => {
expect(Number(getComputedStyle($btn[0]).opacity)).to.be.greaterThan(0);
});
});
});
describe("Color theme — light/dark tokens actually reach the rendered chrome", () => {
beforeEach(() => {
interceptAuth();
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
});
it("renders the sidebar surface and the active nav link in the light palette by default", () => {
cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "LIGHT" } });
cy.visit("/");
cy.get("html").should("have.attr", "data-theme", "light");
// --color-surface: #ffffff
cy.get(".app-sidebar").should(($el) => {
expect(getComputedStyle($el[0]).backgroundColor).to.equal("rgb(255, 255, 255)");
});
// --color-primary: #2e6b4a, applied as the active nav link's background.
cy.contains(".app-sidebar__nav a", "Planning").should(($link) => {
expect(getComputedStyle($link[0]).backgroundColor).to.equal("rgb(46, 107, 74)");
});
});
it("switches every themed color to the dark palette when the user's preference is DARK", () => {
cy.intercept("GET", "**/preferences", { statusCode: 200, body: { theme: "DARK" } });
cy.visit("/");
cy.get("html").should("have.attr", "data-theme", "dark");
// --color-surface: #1c221e
cy.get(".app-sidebar").should(($el) => {
expect(getComputedStyle($el[0]).backgroundColor).to.equal("rgb(28, 34, 30)");
});
// --color-primary: #5fae7e
cy.contains(".app-sidebar__nav a", "Planning").should(($link) => {
expect(getComputedStyle($link[0]).backgroundColor).to.equal("rgb(95, 174, 126)");
});
// The page background token switches too, not just the sidebar.
cy.get("body").should(($body) => {
// --color-background: #14181a
expect(getComputedStyle($body[0]).backgroundColor).to.equal("rgb(20, 24, 26)");
});
});
});