144 lines
7.8 KiB
TypeScript
144 lines
7.8 KiB
TypeScript
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]);
|
|
});
|
|
});
|