import { createEkklesiaThemeController, DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, DESIGN_TOKEN_MOTION_ATTRIBUTE, } from "@ekklesia/design-tokens"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type AppearanceChoice, createMemoryAppearanceCache, } from "../src/browser/appearance-cache.js"; import { createAppearancePreference } from "../src/browser/appearance-preference.js"; import { HostApp } from "../src/browser/HostApp.js"; import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js"; import { createMemoryHostViewport } from "../src/browser/host-viewport.js"; import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js"; declare global { // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; } /** * The account area (ADR 0062): the account control's real identity, `/account`, * `/account/appearance`, and how a chosen appearance and motion take effect, persist, and are * reconciled with the account. */ const apiBaseUrl = "https://ekklesia.example.test"; const ADA = { email: "ada@example.test", name: "Ada Lovelace", preferences: { appearance: "dark", motion: "reduce" }, } as const; let container: HTMLDivElement; let root: Root; let consoleError: ReturnType; beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement("div"); document.body.append(container); root = createRoot(container); consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); }); afterEach(async () => { await act(async () => { root.unmount(); }); container.remove(); consoleError.mockRestore(); }); interface Deferred { readonly resolve: (response: Response) => void; } interface RenderOptions { readonly account?: | typeof ADA | { readonly email: string; readonly name: string; readonly preferences: AppearanceChoice } | "fails"; readonly cached?: AppearanceChoice; readonly compact?: boolean; readonly history?: MemoryHostHistory; /** Answers each preferences PATCH; by default the server merges the change, as the API does. */ readonly save?: (body: Record) => Response | "hold"; readonly principalAfterSave?: "authenticated" | "anonymous"; } async function render(path: string, options: RenderOptions = {}) { const history = options.history ?? createMemoryHostHistory(path); const calls: { method: string; path: string; body?: unknown }[] = []; const held: Deferred[] = []; const titles: string[] = []; const rootAttributes = new Map(); const meta = new Map(); const cache = createMemoryAppearanceCache(options.cached); const appearance = createAppearancePreference({ cache, colorSchemeMeta: { setAttribute: (name, value) => meta.set(name, value) }, theme: createEkklesiaThemeController({ appearance: options.cached?.appearance ?? "system", motion: options.cached?.motion ?? "system", target: { setAttribute: (name, value) => rootAttributes.set(name, value) }, }), }); let stored = options.account === "fails" ? undefined : { ...(options.account ?? ADA).preferences }; let signedIn = true; const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(String(input)); const method = init?.method ?? "GET"; const body = typeof init?.body === "string" ? (JSON.parse(init.body) as Record) : undefined; calls.push({ body, method, path: url.pathname }); if (url.pathname === "/api/v1/principal") { return Response.json( signedIn ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" }, ); } if (url.pathname === "/api/auth/sign-out") { signedIn = false; return Response.json({ success: true }); } if (url.pathname === "/api/v1/dashboard/navigation") { return Response.json({ groups: [], ungrouped: [] }); } if (url.pathname === "/api/v1/dashboard/settings") { return Response.json({ modules: [], sections: [] }); } if (url.pathname === "/api/v1/account" && method === "GET") { return options.account === "fails" ? Response.json({ code: "ACCOUNT_READ_FAILED" }, { status: 503 }) : Response.json({ ...(options.account ?? ADA), preferences: stored }); } if (url.pathname === "/api/v1/account/preferences" && method === "PATCH" && body) { const answer = options.save?.(body); if (answer === "hold") { return new Promise((resolve) => held.push({ resolve })); } if (answer) { if (answer.status === 403 && options.principalAfterSave === "anonymous") { signedIn = false; } return answer; } stored = { ...(stored ?? { appearance: "system", motion: "system" }), ...body, } as AppearanceChoice; return Response.json(stored); } return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); }) as unknown as typeof fetch; await act(async () => { root.render( titles.push(title)} sidebarPreference={createMemorySidebarPreference()} viewport={createMemoryHostViewport(options.compact ?? false)} />, ); }); return { appearance, cache, calls, held, history, meta, patches: () => calls.filter((call) => call.method === "PATCH"), rootAttribute: (name: string) => rootAttributes.get(name), titles, }; } function query(testId: string): HTMLElement | null { return container.querySelector(`[data-testid="${testId}"]`); } function required(testId: string): HTMLElement { const element = query(testId); if (!element) { throw new Error(`missing ${testId}`); } return element; } async function click(element: Element): Promise { await act(async () => { element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true })); }); } function radio(group: "appearance" | "motion", value: string): HTMLInputElement { const input = required(`appearance-${group}-${value}`).querySelector("input"); if (!(input instanceof HTMLInputElement)) { throw new Error(`missing ${group} ${value} radio`); } return input; } function checked(group: "appearance" | "motion"): string | undefined { return ( required(`appearance-${group}`).querySelector("input:checked")?.value ?? undefined ); } async function choose(group: "appearance" | "motion", value: string): Promise { await click(radio(group, value)); } describe("the account control", () => { it("names who is signed in: their name and email beside their initials", async () => { await render("/"); const trigger = required("host-account-menu"); expect(trigger.textContent).toContain("Ada Lovelace"); expect(trigger.textContent).toContain("ada@example.test"); expect(trigger.querySelector('[data-testid="account-avatar"]')?.textContent).toBe("AL"); // Initials are decoration; the name is what assistive technology hears. expect( trigger.querySelector('[data-testid="account-avatar"]')?.getAttribute("aria-hidden"), ).toBe("true"); // No image is ever loaded for an account, and the opaque user id is never shown. expect(container.querySelector("img")).toBeNull(); expect(container.textContent).not.toContain("user-1"); }); it("repeats who is signed in inside the menu, above its three destinations", async () => { await render("/"); await click(required("host-account-menu")); expect(required("host-account-identity").textContent).toBe("Ada Lovelaceada@example.test"); expect( [...required("host-account-menu-list").querySelectorAll('[role="menuitem"]')].map( (item) => item.textContent, ), ).toEqual(["Account", "Appearance", "Sign out"]); }); it("keeps the name in the accessible name of the collapsed control", async () => { await render("/"); await click(container.querySelector('[aria-label="Collapse navigation"]') as Element); const trigger = required("host-account-menu"); expect(trigger.querySelector(".ek-shell-visually-hidden")?.textContent).toBe("Ada Lovelace"); expect(trigger.querySelector('[data-testid="account-avatar"]')).not.toBeNull(); }); it("opens Account and Appearance in place, closing the menu", async () => { const { history } = await render("/"); await click(required("host-account-menu")); await click(required("host-appearance-link")); expect(history.entries).toEqual(["/", "/account/appearance"]); expect(query("account-appearance")).not.toBeNull(); expect(query("host-account-menu-list")).toBeNull(); await click(required("host-account-menu")); await click(required("host-account-link")); expect(history.pathname()).toBe("/account"); expect(query("account-home")).not.toBeNull(); }); it("names the area instead of the person when the account cannot be loaded", async () => { await render("/", { account: "fails" }); expect(required("host-account-menu").textContent).toBe("Your account"); expect(query("account-avatar")).toBeNull(); await click(required("host-account-menu")); expect(query("host-account-identity")).toBeNull(); // Every destination still works. expect(query("host-appearance-link")).not.toBeNull(); }); }); describe("/account", () => { it("shows who you are signed in as and links to your own settings — and no administration", async () => { const { titles } = await render("/account"); const page = required("account-home"); expect(page.querySelector("h1")?.textContent).toBe("Account"); expect(required("account-name").textContent).toBe("Ada Lovelace"); expect(required("account-email").textContent).toBe("ada@example.test"); expect(required("account-home-appearance").getAttribute("href")).toBe("/account/appearance"); expect(titles.at(-1)).toBe("Account"); // Nothing editable pretends to exist: no profile form. expect(page.querySelector("input, textarea, select")).toBeNull(); // Personal, not Settings: the Settings hierarchy does not open, and no nav item is current. expect(container.querySelector('nav [aria-current="page"]')).toBeNull(); }); it("shows only the email when the name is blank", async () => { await render("/account", { account: { email: "blank@example.test", name: " ", preferences: { appearance: "system", motion: "system" }, }, }); expect(query("account-name")).toBeNull(); expect(required("account-email").textContent).toBe("blank@example.test"); expect(required("host-account-menu").textContent).toContain("blank@example.test"); }); it("signs out, and the signed-out page follows the system with no cached choice", async () => { const { appearance, cache, calls } = await render("/account"); expect(appearance.current()).toEqual(ADA.preferences); await click(required("account-sign-out")); expect(query("host-login")).not.toBeNull(); expect(calls.some((call) => call.path === "/api/auth/sign-out")).toBe(true); expect(appearance.current()).toEqual({ appearance: "system", motion: "system" }); expect(cache.read()).toBeUndefined(); }); it("says when the account could not be loaded, with Retry, and the Dashboard still works", async () => { await render("/account", { account: "fails" }); expect(required("account-load-failed").textContent).toContain( "Your account could not be loaded", ); expect(query("host-sidebar")).not.toBeNull(); }); it("opens the same page after a refresh, and Back and Forward move between the account pages", async () => { const history = createMemoryHostHistory("/account"); await render("/account", { history }); await click(required("account-home-appearance")); expect(query("account-appearance")).not.toBeNull(); await act(async () => history.go(-1)); expect(query("account-home")).not.toBeNull(); await act(async () => history.go(1)); expect(query("account-appearance")).not.toBeNull(); await act(async () => { root.unmount(); }); root = createRoot(container); await render("/account/appearance"); expect(query("account-appearance")).not.toBeNull(); }); it("is not found beneath it for a page that does not exist", async () => { await render("/account/profile"); expect(query("host-not-found")).not.toBeNull(); }); }); describe("/account/appearance", () => { it("shows the account's choices as two native radio groups, with Back to Account", async () => { const { titles } = await render("/account/appearance"); const page = required("account-appearance"); expect(page.querySelector("h1")?.textContent).toBe("Appearance"); expect(titles.at(-1)).toBe("Appearance"); const back = page.querySelector(".ek-page-header__back"); expect(back?.getAttribute("href")).toBe("/account"); expect(back?.textContent).toContain("Back to Account"); const groups = [...page.querySelectorAll("fieldset")]; expect(groups.map((group) => group.querySelector("legend")?.textContent)).toEqual([ "Appearance", "Reduced motion", ]); for (const group of groups) { // A real radio group: one name, each option labelled, and described by its group's line. const inputs = [...group.querySelectorAll('input[type="radio"]')]; expect(inputs).toHaveLength(3); expect(new Set(inputs.map((input) => input.name)).size).toBe(1); for (const input of inputs) { expect(input.closest("label")?.textContent?.length).toBeGreaterThan(0); } expect(group.getAttribute("aria-describedby")).toBeTruthy(); } expect( [...page.querySelectorAll('input[name="appearance"]')].map( (input) => (input as HTMLInputElement).value, ), ).toEqual(["system", "light", "dark"]); expect( [...page.querySelectorAll('input[name="motion"]')].map( (input) => (input as HTMLInputElement).value, ), ).toEqual(["system", "reduce", "allow"]); expect(checked("appearance")).toBe("dark"); expect(checked("motion")).toBe("reduce"); }); it("gives every option a full-width touch target and marks the choice by more than colour", async () => { await render("/account/appearance", { compact: true }); for (const option of container.querySelectorAll(".ek-shell-choice")) { expect(option.style.minHeight).toBe("var(--ekklesia-control-height-lg)"); } expect(required("appearance-appearance-dark").dataset.checked).toBe("true"); expect(required("appearance-appearance-light").dataset.checked).toBeUndefined(); }); it.each([ ["appearance", "light"], ["appearance", "system"], ["motion", "allow"], ["motion", "system"], ] as const)("applies %s %s at once and saves it to the account", async (group, value) => { const { cache, patches, rootAttribute, history } = await render("/account/appearance"); await choose(group, value); const attribute = group === "appearance" ? DESIGN_TOKEN_APPEARANCE_ATTRIBUTE : DESIGN_TOKEN_MOTION_ATTRIBUTE; expect(rootAttribute(attribute)).toBe(value); expect(patches()).toEqual([ { body: { [group]: value }, method: "PATCH", path: "/api/v1/account/preferences" }, ]); expect(checked(group)).toBe(value); expect(cache.read()?.[group]).toBe(value); // No reload, no navigation: the same page, the same history. expect(history.entries).toEqual(["/account/appearance"]); expect(required("appearance-status").textContent).toBe("Saved to your account."); }); it("switches before the server has answered, so the change is immediate", async () => { const { held, rootAttribute } = await render("/account/appearance", { save: () => "hold" }); await choose("appearance", "light"); expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light"); expect(held).toHaveLength(1); await act(async () => { held[0]?.resolve(Response.json({ appearance: "light", motion: "reduce" })); }); expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light"); }); it("applies what the server answered, which is the source of truth", async () => { const { rootAttribute } = await render("/account/appearance", { save: () => Response.json({ appearance: "light", motion: "allow" }), }); await choose("appearance", "light"); // Motion changed elsewhere in the meantime; the account's answer wins. expect(rootAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow"); expect(checked("motion")).toBe("allow"); }); it("puts the choice back and says so when it could not be saved", async () => { const { cache, rootAttribute } = await render("/account/appearance", { save: () => Response.json({ code: "ACCOUNT_WRITE_FAILED" }, { status: 503 }), }); await choose("appearance", "light"); expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark"); expect(checked("appearance")).toBe("dark"); expect(cache.read()).toEqual(ADA.preferences); const notice = required("appearance-status").querySelector('[role="alert"]'); expect(notice?.textContent).toContain("Your choice wasn't saved"); }); it("checks the session when a save is refused, and signs the page out if it has ended", async () => { const { appearance } = await render("/account/appearance", { principalAfterSave: "anonymous", save: () => Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }), }); await choose("motion", "allow"); expect(query("host-login")).not.toBeNull(); expect(appearance.current()).toEqual({ appearance: "system", motion: "system" }); }); it("follows a change made in another tab, without saving it again", async () => { const { appearance, cache, patches } = await render("/account/appearance"); await act(async () => { cache.write({ appearance: "light", motion: "allow" }); appearance.syncFromCache(); }); expect(checked("appearance")).toBe("light"); expect(checked("motion")).toBe("allow"); expect(patches()).toEqual([]); }); it("is reachable on a phone from the drawer's account menu, which closes on choosing", async () => { await render("/", { compact: true }); await click(required("mobile-navigation-trigger")); await click(required("host-account-menu")); await click(required("host-appearance-link")); expect(query("account-appearance")).not.toBeNull(); expect(query("navigation-drawer")).toBeNull(); }); }); describe("reconciling with the account", () => { it("replaces this browser's cached choice with the account's on every load", async () => { const { appearance, cache, meta, rootAttribute } = await render("/", { cached: { appearance: "light", motion: "allow" }, }); expect(appearance.current()).toEqual(ADA.preferences); expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark"); expect(rootAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce"); expect(meta.get("content")).toBe("dark"); expect(cache.read()).toEqual(ADA.preferences); }); it("keeps the cached choice when the account cannot be loaded, rather than guessing", async () => { const { appearance } = await render("/", { account: "fails", cached: { appearance: "light", motion: "allow" }, }); expect(appearance.current()).toEqual({ appearance: "light", motion: "allow" }); }); it("follows the system on the signed-out page and forgets the previous person's choice", async () => { const { appearance, cache } = await render("/", { cached: { appearance: "dark", motion: "reduce" }, }); await click(required("host-account-menu")); await click(required("host-logout")); expect(query("host-login")).not.toBeNull(); expect(appearance.current()).toEqual({ appearance: "system", motion: "system" }); expect(cache.read()).toBeUndefined(); }); });