test(01-10): add red responsive navigation contracts

- pin grouped and heading-aligned Settings and Admin sidebars
- add mobile Insights geometry and deterministic release evidence captures
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 14:22:54 +02:00
1 parent 6f84629482
commit 65ca1aae42
4 files changed
+297 -3

No files matched your search

@@ -0,0 +1,151 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import { settleBeforeMeasure } from "./utils/settle";
test.describe("v1.34.1 navigation geometry", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const viewport of [
{ name: "desktop", width: 1440, height: 900 },
{ name: "short desktop", width: 1280, height: 600 },
{ name: "tablet", width: 900, height: 700 },
]) {
for (const route of ["/settings/account", "/admin/system-status"]) {
test(`${route} aligns and pins its sidebar at ${viewport.name}`, async ({
page,
}) => {
await page.setViewportSize(viewport);
await page.goto(route, { waitUntil: "domcontentloaded" });
const heading = page.getByRole("heading", { level: 1 });
const sidebar = page.locator("aside").first();
await settleBeforeMeasure(page, heading);
await expect(sidebar).toBeVisible();
const before = await page.evaluate(() => {
const main = document.getElementById("main-content");
const heading = main?.querySelector("h1");
const aside = main?.querySelector("aside");
const wrapper = main?.firstElementChild;
if (!main || !heading || !aside || !wrapper) return null;
return {
headingTop: heading.getBoundingClientRect().top,
asideTop: aside.getBoundingClientRect().top,
wrapperLeft: wrapper.getBoundingClientRect().left,
mainWidth: main.clientWidth,
documentScrollable:
document.documentElement.scrollHeight >
document.documentElement.clientHeight + 1,
};
});
expect(before).not.toBeNull();
if (!before) return;
expect(
Math.abs(before.asideTop - before.headingTop),
).toBeLessThanOrEqual(2);
expect(before.documentScrollable).toBe(false);
await page.locator("#main-content").evaluate((main) => {
main.scrollTop = Math.min(320, main.scrollHeight - main.clientHeight);
});
await page.waitForFunction(() => {
const main = document.getElementById("main-content");
return (
!main ||
main.scrollTop > 0 ||
main.scrollHeight <= main.clientHeight
);
});
const after = await page.evaluate(() => {
const main = document.getElementById("main-content");
const aside = main?.querySelector("aside");
const wrapper = main?.firstElementChild;
if (!main || !aside || !wrapper) return null;
return {
asideTop: aside.getBoundingClientRect().top,
wrapperLeft: wrapper.getBoundingClientRect().left,
mainWidth: main.clientWidth,
};
});
expect(after).not.toBeNull();
if (!after) return;
expect(Math.abs(after.asideTop - before.asideTop)).toBeLessThanOrEqual(
2,
);
expect(after.wrapperLeft).toBe(before.wrapperLeft);
expect(after.mainWidth).toBe(before.mainWidth);
});
}
}
});
test.describe("v1.34.1 mobile Insights pill row", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("has no unexplained gap above the row and preserves focus, safe bounds, and 44px targets", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockPopulatedInsights(page);
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const firstPill = page
.locator(
'[data-slot="insights-tab-strip-pill"], [data-slot="insights-tab-strip-group"]',
)
.first();
await settleBeforeMeasure(page, firstPill);
const geometry = await page.evaluate(() => {
const main = document.getElementById("main-content");
const strip = document.querySelector<HTMLElement>(
'[data-slot="insights-tab-strip"]',
);
const pill = document.querySelector<HTMLElement>(
'[data-slot="insights-tab-strip-pill"], [data-slot="insights-tab-strip-group"]',
);
if (!main || !strip || !pill) return null;
const mainRect = main.getBoundingClientRect();
const stripRect = strip.getBoundingClientRect();
const pillRect = pill.getBoundingClientRect();
return {
gapAbove: stripRect.top - mainRect.top,
stripLeft: stripRect.left,
stripRight: stripRect.right,
mainLeft: mainRect.left,
mainRight: mainRect.right,
pillHeight: pillRect.height,
mainOverflows: main.scrollWidth > main.clientWidth + 1,
documentOverflows:
document.documentElement.scrollWidth >
document.documentElement.clientWidth + 1,
};
});
expect(geometry).not.toBeNull();
if (!geometry) return;
expect(geometry.gapAbove).toBeLessThanOrEqual(4);
expect(geometry.stripLeft).toBeGreaterThanOrEqual(geometry.mainLeft);
expect(geometry.stripRight).toBeLessThanOrEqual(geometry.mainRight);
expect(geometry.pillHeight).toBeGreaterThanOrEqual(44);
expect(geometry.mainOverflows).toBe(false);
expect(geometry.documentOverflows).toBe(false);
await firstPill.focus();
await expect(firstPill).toBeFocused();
expect(
await firstPill.evaluate((element) => {
const style = getComputedStyle(element);
return (
element.matches(":focus-visible") &&
(style.outlineStyle !== "none" || style.boxShadow !== "none")
);
}),
).toBe(true);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { Page, TestInfo } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
LONG_HEADLINE_BRIEFING,
mockDashboardSnapshot,
} from "./utils/mock-dashboard-snapshot";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import { settleBeforeMeasure } from "./utils/settle";
const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341");
async function settleAndCapture(
page: Page,
testInfo: TestInfo,
filename: string,
): Promise<void> {
await settleBeforeMeasure(page, page.locator("#main-content"));
await page.emulateMedia({ reducedMotion: "reduce" });
await page.addStyleTag({
content:
"*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
});
await page.evaluate(() => document.fonts?.ready);
const path = join(EVIDENCE_DIR, filename);
await page.screenshot({ path, animations: "disabled" });
await testInfo.attach(filename, { path, contentType: "image/png" });
}
test.describe("v1.34.1 deterministic UI evidence", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"one canonical capture per named evidence file",
);
});
test("captures the five release-review surfaces", async ({
page,
}, testInfo) => {
await mkdir(EVIDENCE_DIR, { recursive: true });
await page.setViewportSize({ width: 1440, height: 900 });
await mockDashboardSnapshot(page, {
briefing: LONG_HEADLINE_BRIEFING,
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator("#main-content")).toBeVisible();
await settleAndCapture(page, testInfo, "dashboard-desktop.png");
await page.setViewportSize({ width: 390, height: 844 });
await mockPopulatedInsights(page);
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="insights-tab-strip"]'),
).toBeVisible();
await settleAndCapture(page, testInfo, "insights-mobile.png");
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await settleAndCapture(page, testInfo, "anamnesis-desktop.png");
await page.setViewportSize({ width: 1280, height: 600 });
await page.goto("/settings/account", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await settleAndCapture(page, testInfo, "settings-short-desktop.png");
await page.setViewportSize({ width: 900, height: 800 });
await page.goto("/admin/system-status", {
waitUntil: "domcontentloaded",
});
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await settleAndCapture(page, testInfo, "admin-tablet.png");
});
});
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* `<AdminShell>` mirrors `<SettingsShell>`: a sticky desktop sidebar
@@ -34,9 +36,6 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: mockUserRef.value }),
}));
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import { ADMIN_SECTION_SLUGS } from "../section-slugs";
import { ADMIN_SECTIONS, AdminShell } from "../admin-shell";
@@ -59,6 +58,30 @@ function renderShell(props: {
}
describe("<AdminShell>", () => {
it("groups operational domains with non-interactive desktop headings and quiet mobile separators", () => {
const html = renderShell({ active: "system-status" });
const groupHeaders =
html.match(/data-slot="admin-nav-group"[^>]*data-group="[^"]+"/g) ?? [];
const separators =
html.match(/data-slot="admin-nav-group-separator"/g) ?? [];
expect(groupHeaders.length).toBeGreaterThanOrEqual(4);
expect(new Set(groupHeaders).size).toBe(groupHeaders.length);
expect(separators).toHaveLength(groupHeaders.length - 1);
});
it("starts the sticky desktop sidebar on the heading row", () => {
const source = readFileSync(
join(process.cwd(), "src", "components", "admin", "admin-shell.tsx"),
"utf8",
);
expect(source).toMatch(
/<aside[\s\S]*?md:sticky[\s\S]*?md:col-start-1[\s\S]*?md:row-start-1/,
);
expect(source).not.toMatch(/<aside[\s\S]*?md:row-start-2/);
});
it("renders every section link in both the mobile strip and the desktop sidebar", () => {
const html = renderShell({ active: "system-status" });
for (const section of ADMIN_SECTIONS) {
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
// `<SettingsShell>` reads the active route from `usePathname()`. We stub
// next/navigation so the SSR test render works without an App-Router
@@ -153,6 +155,43 @@ describe("SETTINGS_SECTION_SLUGS", () => {
});
describe("<SettingsShell>", () => {
it("keeps meaningful non-interactive desktop groups while the mobile strip uses quiet separators", () => {
const html = renderShell({ active: "account" });
for (const label of [
"Account",
"Your data",
"Display",
"Health profile",
"AI",
"Connectivity",
"System",
]) {
expect(html).toContain(`>${label}</li>`);
}
// Seven groups produce six quiet separators in the mobile strip.
expect(
html.match(/aria-hidden="true"/g)?.length ?? 0,
).toBeGreaterThanOrEqual(6);
});
it("starts the sticky desktop sidebar on the heading row", () => {
const source = readFileSync(
join(
process.cwd(),
"src",
"components",
"settings",
"settings-shell.tsx",
),
"utf8",
);
expect(source).toMatch(
/<aside[\s\S]*?md:sticky[\s\S]*?md:col-start-1[\s\S]*?md:row-start-1/,
);
expect(source).not.toMatch(/<aside[\s\S]*?md:row-start-2/);
});
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
const html = renderShell({ active: "account" });
// Every slug — including `about`, which returned to the shell nav