test(web): add layout/style regression suite for the app shell
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.
This commit is contained in:
parent
d41b65fff3
commit
f19eceba87
1 changed files with 267 additions and 0 deletions
267
apps/web/cypress/e2e/layout.cy.ts
Normal file
267
apps/web/cypress/e2e/layout.cy.ts
Normal file
|
|
@ -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)");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue