From f19eceba878584142924fd2a242420b7a63e5158 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 19 Aug 2026 14:22:12 +0200 Subject: [PATCH] test(web): add layout/style regression suite for the app shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Troisième catégorie du découpage des tests (parcours via Cucumber, composants génériques via Component Testing, et maintenant layout pur — indépendant de tout parcours utilisateur). Cypress classique, pas de Gherkin : ce fichier teste la structure/l'apparence du shell (AppLayout) lui-même, pas le contenu d'une page donnée. Couvre spécifiquement les 4 axes demandés : - Positionnement : la sidebar garde une largeur fixe (240px déplié, 68px replié) plaquée au coin haut-gauche, sur n'importe quelle page. - Scroll : régression directe pour #21 — `.app-layout` reste borné exactement à la hauteur du viewport (overflow: hidden), et une page plus haute que le viewport scrolle uniquement dans `.app-content` (via un spacer synthétique de 3000px injecté après le mount, pour rester indépendant du contenu réel d'une page donnée) sans jamais déplacer la sidebar ni scroller le document lui-même. - Largeur des pages : autre régression directe pour #21 — le planning et le catalogue de recettes remplissent toute la largeur disponible de `.app-content`, tandis que la page "Liste de courses" et les pages de paramètres restent centrées avec un espace égal de chaque côté (le bug original : collées à gauche avec un grand vide à droite). - Breakpoint responsive (< 640px) : la sidebar bascule en barre horizontale pleine largeur, masque le bouton collapse/la version, et garde chaque lien de nav pleinement lisible (icône + label, avec scroll horizontal) plutôt que de les écraser en pastilles de ~16px sans texte — un mode de régression explicitement documenté en commentaire dans AppLayout.scss mais jusqu'ici non testé. - Thème de couleur : va au-delà de l'attribut `data-theme` déjà couvert par user-preferences.cy.ts — vérifie les vraies valeurs de couleur calculées (`getComputedStyle`) sur la sidebar, le lien de nav actif et le fond de page, en clair et en sombre, confirmant que la cascade CSS des tokens (_theme.scss) atteint réellement le rendu, pas seulement que le JS pose le bon attribut. Non exécutable en local (limitation GPU/sandbox Electron documentée dans le README, pré-existante) — à vérifier en CI. --- apps/web/cypress/e2e/layout.cy.ts | 267 ++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 apps/web/cypress/e2e/layout.cy.ts diff --git a/apps/web/cypress/e2e/layout.cy.ts b/apps/web/cypress/e2e/layout.cy.ts new file mode 100644 index 0000000..953f197 --- /dev/null +++ b/apps/web/cypress/e2e/layout.cy.ts @@ -0,0 +1,267 @@ +// 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 and recipe catalog 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"); + }); + + it("centers the Liste de courses stub and settings pages, with equal space on both sides", () => { + cy.visit("/liste-de-courses"); + assertCenteredColumn(".coming-soon-page", 640); // max-width: 40rem + + cy.visit("/parametres/compte"); + assertCenteredColumn(".settings-page", 896); // max-width: 56rem + }); + + /** 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); + }); + }); + } + + /** Capped at `maxWidthPx` (not stretched full-bleed) and horizontally centered — equal left/right gap within `.app-content`. */ + function assertCenteredColumn(selector: string, maxWidthPx: number) { + cy.get(".app-content").then(($content) => { + const contentRect = $content[0].getBoundingClientRect(); + + cy.get(selector).should(($page) => { + const pageRect = $page[0].getBoundingClientRect(); + expect(pageRect.width).to.be.closeTo(maxWidthPx, 2); + + const leftGap = pageRect.left - contentRect.left; + const rightGap = contentRect.right - pageRect.right; + expect(leftGap).to.be.closeTo(rightGap, 2); + }); + }); + } +}); + +describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => { + beforeEach(() => { + interceptAuth(); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); + }); + + it("switches to a full-width horizontal bar, hides the collapse toggle and version tag, and keeps every nav link legible", () => { + cy.viewport(375, 812); + cy.visit("/"); + + cy.get(".app-sidebar").should(($sidebar) => { + const rect = $sidebar[0].getBoundingClientRect(); + expect(rect.width).to.be.closeTo(375, 1); + // A short horizontal bar, not the tall vertical rail — well under the + // desktop rail's own content-driven height. + expect(rect.height).to.be.lessThan(120); + }); + + // Nothing to collapse into on a bar with no rail to shrink. + cy.get(".app-sidebar__collapse-toggle").should("not.be.visible"); + cy.get(".app-sidebar__version").should("not.be.visible"); + + // The main nav must stay fully legible and tappable — icon *and* label + // — falling back to horizontal scroll instead of ever being crushed + // down to unlabeled slivers (see AppLayout.scss's own comment on this + // exact failure mode). + cy.get(".app-sidebar__nav").should(($nav) => { + expect(getComputedStyle($nav[0]).overflowX).to.equal("auto"); + }); + cy.contains(".app-sidebar__nav a", "Planning").find("span.label").should("be.visible"); + cy.contains(".app-sidebar__nav a", "Planning").should(($link) => { + expect($link[0].getBoundingClientRect().width).to.be.greaterThan(40); + }); + + // Contrast: the settings/account toggles' labels *do* collapse to + // icon-only here — there's no room for both a full nav row and full + // text labels on every piece of chrome at once. + cy.contains("button", "Paramètres").find("span.label").should("not.be.visible"); + }); +}); + +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)"); + }); + }); +});