225 lines
8.4 KiB
TypeScript
225 lines
8.4 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
* Stage 2 shell tests — boot, routing, emergency affordance, unknown routes, a11y.
|
|
* Trace: IMPLEMENTATION-CONTRACT.md §7, §20, §32; ARCHITECTURE-DESIGN.md:207, 783
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { mount as boot, renderView } from "../../src/app/main.js";
|
|
import { ROUTES, normalizePath, routeForPath } from "../../src/ui/router/router.js";
|
|
import { createLayout } from "../../src/app/layout.js";
|
|
|
|
function setupAppHost(): HTMLElement {
|
|
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
|
|
const host = document.getElementById("app");
|
|
if (!host) throw new Error("host missing");
|
|
return host;
|
|
}
|
|
|
|
describe("Stage 2 shell — boot", () => {
|
|
beforeEach(() => {
|
|
history.replaceState(null, "", "/");
|
|
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
|
|
});
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("boots into home when root / requested (offline-safe deep link)", () => {
|
|
history.replaceState(null, "", "/");
|
|
const host = setupAppHost();
|
|
const { router } = boot();
|
|
expect(router.getPath()).toBe("/");
|
|
expect(router.getRouteId()).toBe("home");
|
|
expect(host.querySelector("h1")?.textContent).toBe("Lumen");
|
|
expect(document.title).toBe("Lumen");
|
|
});
|
|
|
|
it("home renders the four destination tiles", () => {
|
|
history.replaceState(null, "", "/");
|
|
setupAppHost();
|
|
boot();
|
|
const tiles = [...document.querySelectorAll<HTMLAnchorElement>(".home-tile")];
|
|
expect(tiles.map((a) => a.textContent)).toEqual([
|
|
"Emergency",
|
|
"Schedule",
|
|
"Map",
|
|
"Information",
|
|
]);
|
|
for (const t of tiles) {
|
|
expect(t.getAttribute("data-route")).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it("brand acts as home/back button and is highlighted on destination pages", () => {
|
|
history.replaceState(null, "", "/schedule");
|
|
setupAppHost();
|
|
boot();
|
|
const brand = document.querySelector<HTMLAnchorElement>(".app-header__brand");
|
|
expect(brand).toBeTruthy();
|
|
expect(brand?.getAttribute("data-route")).toBe("/");
|
|
expect(brand?.classList.contains("app-header__brand--back")).toBe(true);
|
|
expect(brand?.hasAttribute("aria-current")).toBe(true);
|
|
brand?.click();
|
|
expect(location.pathname).toBe("/");
|
|
expect(document.querySelector("h1")?.textContent).toBe("Lumen");
|
|
});
|
|
|
|
it("removes aria-busy after boot", () => {
|
|
const host = setupAppHost();
|
|
boot();
|
|
expect(host.hasAttribute("aria-busy")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Stage 2 shell — layout / a11y", () => {
|
|
it("provides skip link, banner, and main landmarks; no bottom nav bar", () => {
|
|
setupAppHost();
|
|
const { root, main } = createLayout();
|
|
const host = document.getElementById("app");
|
|
if (!host) throw new Error("host");
|
|
host.replaceChildren(root);
|
|
expect(document.querySelector(".skip-link")?.getAttribute("href")).toBe("#main-content");
|
|
expect(document.querySelector('header[role="banner"]')).toBeTruthy();
|
|
expect(main.id).toBe("main-content");
|
|
expect(main.getAttribute("tabindex")).toBe("-1");
|
|
// Bottom navigation bar removed by product decision (Oct 3):
|
|
// home tiles + brand-as-home cover navigation.
|
|
expect(document.querySelector(".side-nav")).toBeNull();
|
|
});
|
|
|
|
it("header status has live region", () => {
|
|
setupAppHost();
|
|
const { statusChip } = createLayout();
|
|
expect(statusChip.getAttribute("role")).toBe("status");
|
|
expect(statusChip.getAttribute("aria-live")).toBe("polite");
|
|
});
|
|
|
|
it("every view has exactly one h1 with matching id for its section", () => {
|
|
for (const r of ROUTES) {
|
|
const el = renderView(r.path);
|
|
const h1 = el.querySelector("h1");
|
|
expect(h1, r.path).toBeTruthy();
|
|
const id = h1 ? h1.id : "";
|
|
expect(el.getAttribute("aria-labelledby")).toBe(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Stage 2 shell — navigation / emergency affordance", () => {
|
|
beforeEach(() => {
|
|
history.replaceState(null, "", "/emergency");
|
|
document.body.innerHTML = '<div id="app"></div>';
|
|
});
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("renders home plus four primary destinations, emergency visually prioritized", () => {
|
|
history.replaceState(null, "", "/");
|
|
setupAppHost();
|
|
boot();
|
|
// Home screen tiles are the navigation surface.
|
|
const tiles = [...document.querySelectorAll<HTMLAnchorElement>(".home-tile")];
|
|
expect(tiles.map((a) => a.textContent)).toEqual([
|
|
"Emergency",
|
|
"Schedule",
|
|
"Map",
|
|
"Information",
|
|
]);
|
|
const emergency = tiles[0];
|
|
expect(emergency?.classList.contains("home-tile--emergency")).toBe(true);
|
|
});
|
|
|
|
it("all four destinations have 48px touch-target capable links and data-route", () => {
|
|
history.replaceState(null, "", "/");
|
|
setupAppHost();
|
|
boot();
|
|
for (const r of ROUTES) {
|
|
const a = document.querySelector<HTMLAnchorElement>(`a[data-route="${r.path}"]`);
|
|
expect(a, `link ${r.path}`).toBeTruthy();
|
|
expect(a?.getAttribute("href")).toBe(r.path);
|
|
}
|
|
});
|
|
|
|
it("brand acts as home control: aria-current on destination pages, off at home", () => {
|
|
history.replaceState(null, "", "/schedule");
|
|
document.body.innerHTML = '<div id="app"></div>';
|
|
setupAppHost();
|
|
boot();
|
|
const brand = document.querySelector<HTMLElement>(".app-header__brand");
|
|
expect(brand?.getAttribute("aria-current")).toBe("page");
|
|
history.replaceState(null, "", "/");
|
|
document.body.innerHTML = '<div id="app"></div>';
|
|
setupAppHost();
|
|
boot();
|
|
expect(
|
|
document.querySelector<HTMLElement>(".app-header__brand")?.hasAttribute("aria-current"),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("emergency is one tap from home; brand is one tap back from every destination", () => {
|
|
history.replaceState(null, "", "/");
|
|
document.body.innerHTML = '<div id="app"></div>';
|
|
setupAppHost();
|
|
const { router } = boot();
|
|
const emergencyLink = document.querySelector<HTMLAnchorElement>('a[data-route="/emergency"]');
|
|
expect(emergencyLink, "emergency tile on home").toBeTruthy();
|
|
emergencyLink?.click();
|
|
expect(router.getPath()).toBe("/emergency");
|
|
expect(document.querySelector("h1")?.textContent).toBe("Emergency");
|
|
|
|
for (const dest of ROUTES.map((r) => r.path)) {
|
|
history.replaceState(null, "", dest);
|
|
document.body.innerHTML = '<div id="app"></div>';
|
|
setupAppHost();
|
|
const b = boot();
|
|
const brand = document.querySelector<HTMLAnchorElement>(".app-header__brand");
|
|
expect(brand, `brand present at ${dest}`).toBeTruthy();
|
|
brand?.click();
|
|
expect(b.router.getPath()).toBe("/");
|
|
}
|
|
});
|
|
|
|
it("handles unknown routes with not-found view and keeps nav without aria-current", () => {
|
|
history.replaceState(null, "", "/unknown-path");
|
|
setupAppHost();
|
|
const { router } = boot();
|
|
expect(router.getRouteId()).toBe("not-found");
|
|
expect(document.querySelector("h1")?.textContent).toBe("Page not found");
|
|
expect(document.querySelector('.side-nav__link[aria-current="page"]')).toBeNull();
|
|
});
|
|
|
|
it("normalizes paths: trailing slash, query, hash, and root", () => {
|
|
expect(normalizePath("/schedule/")).toBe("/schedule");
|
|
expect(normalizePath("/map?foo=1")).toBe("/map");
|
|
expect(normalizePath("/festival#section")).toBe("/festival");
|
|
expect(normalizePath("/")).toBe("/");
|
|
expect(routeForPath("/")).toBe("home");
|
|
expect(routeForPath("/unknown")).toBe("not-found");
|
|
});
|
|
|
|
it("clicking a data-route link uses router.navigate (no reload) and updates history", () => {
|
|
history.replaceState(null, "", "/");
|
|
setupAppHost();
|
|
boot();
|
|
const scheduleLink = document.querySelector<HTMLAnchorElement>('a[data-route="/schedule"]');
|
|
if (!scheduleLink) throw new Error("schedule link");
|
|
scheduleLink.click();
|
|
expect(location.pathname).toBe("/schedule");
|
|
expect(document.querySelector("h1")?.textContent).toBe("Schedule");
|
|
});
|
|
});
|
|
|
|
describe("Stage 2 shell — responsive / shell invariants", () => {
|
|
it("index.html is a single cached entry — Vite will emit hashed assets (checked in build)", () => {
|
|
expect(document.body.innerHTML).not.toMatch(/stage-b|evt-|poi-/);
|
|
});
|
|
|
|
it("styles include reduced-motion guard", async () => {
|
|
const { readFileSync } = await import("node:fs");
|
|
const content = readFileSync("src/app/styles.css", "utf8");
|
|
expect(content).toMatch(/prefers-reduced-motion/);
|
|
expect(content).toMatch(/prefers-color-scheme:\s*dark/);
|
|
expect(content).toMatch(/--nav-h/);
|
|
});
|
|
});
|