mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user