import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; import { authenticatedStatePath } from "../global-setup"; import { expectAuthenticatedShell } from "./support/v2"; /** * V2-M6: visualizations, motion, matrices and dashboard. * * `ServerMap`/`ServiceTopology`/`ProjectSignature` and `DataTable` were already * implemented from the prior Codex session (see docs/v2/V2_ROADMAP.md's V2-M6 * entry). This spec adds the missing real proofs for this milestone's specific * acceptance criteria: reduced-motion renders a *genuine final state* (not just * "axe is clean", which src/visualizations/*.test.tsx and the pre-existing * v2-routing-and-fidelity.spec.ts reduced-motion test already cover at the * component/axe level), a real matrix route has no page-level horizontal overflow, * and dashboard widget order/visibility actually round-trips through Settings → a * real reload of the dashboard route (not just localStorage keys, already proven at * the unit level in PreferencesContext.test.tsx). */ test.use({ storageState: authenticatedStatePath }); test.describe("V2-M6 motion, matrices and dashboard personalization", () => { test("the server map renders its final, fully-opaque state under prefers-reduced-motion, not a mid-animation frame", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/"); await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible(); const serverMap = page.locator(".server-map"); await expect(serverMap).toBeVisible(); const opacities = await serverMap.locator("svg > g, svg > path").evaluateAll((elements) => elements.map((element) => window.getComputedStyle(element).opacity) ); expect(opacities.length).toBeGreaterThan(0); for (const opacity of opacities) { expect(Number(opacity)).toBeCloseTo(1, 2); } }); test("a project's signature illustration renders its final, fully-opaque state under prefers-reduced-motion", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); const projectName = `E2E-motion-project ${Date.now()}`; await page.goto("/projects"); await expectAuthenticatedShell(page); await page.getByRole("button", { name: "Nieuw project" }).click(); await page.getByLabel("Naam").fill(projectName); await page.getByRole("button", { name: "Project aanmaken" }).click(); await expect(page.getByRole("heading", { name: projectName })).toBeVisible(); const signature = page.locator(".project-signature"); // Below 900px the decorative signature is deliberately not rendered at all (OUX-6: // operational data comes before decoration on a narrow screen), so there is no // final animation state to assert there. Asserting it visible on every viewport // would be asserting the opposite of the product decision. const viewport = page.viewportSize(); test.skip( (viewport?.width ?? 0) < 900, "The project signature is intentionally hidden below 900px; nothing animates there.", ); await expect(signature).toBeVisible(); // Only the ".project-core" diamond marker animates opacity (0 → 1 via `animate`); // the decorative rings around it use a permanent, layered per-ring `opacity` // *attribute* (e.g. 0.72, 0.64, ...) as a static design choice, not an // animate-in artifact — asserting rings end at opacity 1 would be a real, wrong // assumption about this component (confirmed by reading ProjectSignature.tsx, // not guessed after a confusing first failure). const coreOpacity = await signature.locator("svg .project-core").evaluate( (element) => window.getComputedStyle(element).opacity ); expect(Number(coreOpacity)).toBeCloseTo(1, 2); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); test("the storage matrix causes no page-level horizontal overflow at a realistic viewport", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto("/infrastructure/storage"); await expect(page.getByRole("heading", { name: "Opslag en capaciteit" })).toBeVisible(); const overflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth ); expect(overflow).toBeLessThanOrEqual(1); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); test("hiding and reordering a dashboard widget in Settings actually changes the real dashboard, and survives a reload", async ({ page }) => { await page.goto("/settings"); await expectAuthenticatedShell(page); await expect(page.getByRole("heading", { name: "Dashboardwidgets" })).toBeVisible(); await expect(page.locator(".preference-sync-status")).toContainText("Voorkeuren gesynchroniseerd."); await page.getByRole("button", { name: "Widgets herstellen" }).click(); await expect.poll(async () => { const values = await (await page.request.get("/api/v1/me/preferences")).json(); return [values.dashboardWidgetOrder[0], values.hiddenDashboardWidgets.length]; }).toEqual(["attention", 0]); // Hide the "Projecten" widget entirely. await page.getByRole("checkbox", { name: "Projecten tonen" }).uncheck(); // "Operationele signalen" is the default first widget since OUX-4 (critical // signals before the topology illustration), so its "up" control is correctly // disabled. Reorder in the direction that is actually available: move // "Unraid-overzicht" above it. await expect(page.getByRole("button", { name: "Operationele signalen omhoog" })).toBeDisabled(); await page.getByRole("button", { name: "Unraid-overzicht omhoog" }).click(); // A full page navigation must wait for the server save, as indicated by the // settings UI, instead of interrupting the pending preference request. await expect.poll(async () => { const values = await (await page.request.get("/api/v1/me/preferences")).json(); return [values.dashboardWidgetOrder[0], values.hiddenDashboardWidgets.includes("projects")]; }).toEqual(["server", true]); await page.goto("/"); await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Projecten", exact: true })).toHaveCount(0); const headingOrder = await page.getByRole("main").getByRole("heading", { level: 2 }).allTextContents(); expect(headingOrder.indexOf("Unraid-overzicht")).toBeLessThan(headingOrder.indexOf("Operationele signalen")); // Reload the dashboard directly (not a fresh in-app navigation) to prove this is // real persisted state, not just in-memory React state surviving a client route // change. await page.reload(); await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Projecten", exact: true })).toHaveCount(0); const headingOrderAfterReload = await page.getByRole("main").getByRole("heading", { level: 2 }).allTextContents(); expect(headingOrderAfterReload.indexOf("Unraid-overzicht")).toBeLessThan( headingOrderAfterReload.indexOf("Operationele signalen") ); // Restore the default so this test does not leak state into any other test that // shares the same authenticated storage state / owner account. await page.goto("/settings"); await expect(page.locator(".preference-sync-status")).toContainText("Voorkeuren gesynchroniseerd."); await page.getByRole("button", { name: "Widgets herstellen" }).click(); await expect.poll(async () => { const values = await (await page.request.get("/api/v1/me/preferences")).json(); return [values.dashboardWidgetOrder[0], values.hiddenDashboardWidgets.length]; }).toEqual(["attention", 0]); }); });