CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
A second real-iPhone review of the Stage 1 Dashboard found three defects. This fixes them, and ADR 0065 records the decisions. - Settings -> Displays now sets up a screen in one place. A "Set up a new screen" panel shows the platform display page to open on the screen, a Copy link button, and Add display directly beneath it; the step-by-step version is folded into a <details>. The addresses come from a new GET /api/v1/devices/setup, which requires core.devices.manage and lists each running display module's /display/<module-id> page. Nothing names Signage. With no display module installed, the panel says so and offers no Add display. - iOS Safari zoomed into the pairing field because the shared .ek-control rendered at 14px. On phones and touch screens it now renders at max(1rem, 16px). The viewport is untouched, so zoom stays available, and a test guards that. The shared focus trap now returns focus with preventScroll, so closing a dialog no longer moves the page under the phone header. - While the phone drawer is open, the page behind it no longer scrolls. The root's overflow is hidden, a non-passive touchmove listener cancels drags outside the drawer's list and overscroll past its ends, and root overscroll is turned off. The list itself still scrolls. Closing restores the exact scroll position unless another page opened. Co-Authored-By: Claude Opus 5.5 <[email protected]>
784 lines
31 KiB
TypeScript
784 lines
31 KiB
TypeScript
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
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";
|
|
import type { DashboardSettings } from "../src/host-protocol.js";
|
|
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
}
|
|
|
|
/**
|
|
* The Settings shell (ADR 0061): its landing page, the platform sections and Discover, each
|
|
* module's settings, and which of them the server said this person may see.
|
|
*/
|
|
|
|
const apiBaseUrl = "https://ekklesia.example.test";
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
let consoleError: ReturnType<typeof vi.spyOn>;
|
|
|
|
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();
|
|
});
|
|
|
|
function entry(id: string, name: string) {
|
|
return {
|
|
module: {
|
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
|
contributions: [],
|
|
display: null,
|
|
id,
|
|
permissions: [],
|
|
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
|
ui: { url: `/api/v1/module-ui/${id}/0.1.0` },
|
|
version: "0.1.0",
|
|
},
|
|
name,
|
|
navigation: { icon: null, pages: [] },
|
|
};
|
|
}
|
|
|
|
const NAVIGATION = {
|
|
groups: [],
|
|
ungrouped: [entry("signage", "Signage"), entry("calendar", "Calendar")],
|
|
};
|
|
|
|
/** An administrator who may see everything: every platform section, two modules' settings, and a
|
|
* disabled module (`archive`) whose settings exist but are not navigable. Calendar declares no
|
|
* settings, so it is a known module with no settings page. */
|
|
const ADMINISTRATOR: DashboardSettings = {
|
|
modules: [
|
|
{ available: false, id: "archive", name: "Archive" },
|
|
{ available: true, id: "signage", name: "Signage" },
|
|
],
|
|
sections: ["general", "branding", "displays", "discover", "system"],
|
|
};
|
|
|
|
const DEVICES = [
|
|
{
|
|
createdAt: "2026-10-01T09:00:00.000Z",
|
|
id: "device-1",
|
|
lastSeenAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
name: "Foyer TV",
|
|
permissions: ["signage.display"],
|
|
revoked: false,
|
|
},
|
|
{
|
|
createdAt: "2026-08-01T09:00:00.000Z",
|
|
id: "device-2",
|
|
name: "Old hall screen",
|
|
permissions: ["signage.display"],
|
|
revoked: true,
|
|
},
|
|
];
|
|
|
|
const LIFECYCLE = [
|
|
{
|
|
configured: { enabled: true, explicit: false },
|
|
id: "signage",
|
|
restartRequired: false,
|
|
runtime: { state: "running" },
|
|
version: "0.1.5",
|
|
},
|
|
{
|
|
configured: { enabled: false, explicit: true },
|
|
id: "archive",
|
|
restartRequired: false,
|
|
runtime: { state: "disabled" },
|
|
version: "1.0.0",
|
|
},
|
|
];
|
|
|
|
/** Lets pending requests settle, so the overview's summaries have answered. */
|
|
async function settle(): Promise<void> {
|
|
await act(async () => {
|
|
for (let index = 0; index < 5; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
});
|
|
}
|
|
|
|
interface Rendered {
|
|
readonly history: MemoryHostHistory;
|
|
readonly calls: string[];
|
|
readonly titles: string[];
|
|
}
|
|
|
|
async function render(
|
|
path: string,
|
|
options: {
|
|
readonly settings?: DashboardSettings | "fails";
|
|
readonly compact?: boolean;
|
|
readonly collapsed?: boolean;
|
|
readonly history?: MemoryHostHistory;
|
|
readonly devices?: unknown[];
|
|
} = {},
|
|
): Promise<Rendered> {
|
|
const history = options.history ?? createMemoryHostHistory(path);
|
|
const calls: string[] = [];
|
|
const titles: string[] = [];
|
|
let settingsAttempts = 0;
|
|
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
calls.push(new URL(url).pathname);
|
|
if (url.endsWith("/api/v1/principal")) {
|
|
return Response.json({ kind: "authenticated", userId: "user-1" });
|
|
}
|
|
if (url.endsWith("/api/v1/dashboard/navigation")) {
|
|
return Response.json(NAVIGATION);
|
|
}
|
|
if (url.endsWith("/api/v1/dashboard/settings")) {
|
|
settingsAttempts += 1;
|
|
const settings = options.settings ?? ADMINISTRATOR;
|
|
return settings === "fails" && settingsAttempts === 1
|
|
? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 })
|
|
: Response.json(settings === "fails" ? ADMINISTRATOR : settings);
|
|
}
|
|
// The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse.
|
|
const visible = options.settings ?? ADMINISTRATOR;
|
|
const sections = visible === "fails" ? ADMINISTRATOR.sections : visible.sections;
|
|
if (url.endsWith("/api/v1/devices/setup")) {
|
|
return sections.includes("displays")
|
|
? Response.json({
|
|
displays: [{ moduleId: "signage", name: "Signage", url: "/display/signage" }],
|
|
})
|
|
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
|
}
|
|
if (url.endsWith("/api/v1/devices")) {
|
|
return sections.includes("displays")
|
|
? Response.json({ devices: options.devices ?? DEVICES })
|
|
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
|
}
|
|
if (url.endsWith("/api/v1/module-lifecycle")) {
|
|
return sections.includes("discover")
|
|
? Response.json({ modules: LIFECYCLE })
|
|
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
|
}
|
|
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
|
}) as unknown as typeof fetch;
|
|
await act(async () => {
|
|
root.render(
|
|
<HostApp
|
|
config={{ apiBaseUrl, fetch: fetchImplementation }}
|
|
history={history}
|
|
importModule={async () => ({
|
|
default: {
|
|
abi: MODULE_UI_ABI_VERSION,
|
|
id: "signage",
|
|
mount(element: Element, context: ModuleUiHostContext) {
|
|
element.textContent = `${context.moduleId} mounted`;
|
|
},
|
|
version: "0.1.0",
|
|
},
|
|
})}
|
|
setDocumentTitle={(title) => titles.push(title)}
|
|
sidebarPreference={createMemorySidebarPreference(
|
|
options.collapsed ? "collapsed" : undefined,
|
|
)}
|
|
viewport={createMemoryHostViewport(options.compact ?? false)}
|
|
/>,
|
|
);
|
|
});
|
|
return { calls, history, titles };
|
|
}
|
|
|
|
function query(testId: string): HTMLElement | null {
|
|
return container.querySelector<HTMLElement>(`[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<void> {
|
|
await act(async () => {
|
|
element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true }));
|
|
});
|
|
}
|
|
|
|
const current = () =>
|
|
[...container.querySelectorAll('nav [aria-current="page"]')].map(
|
|
(element) => (element as HTMLElement).dataset.testid,
|
|
);
|
|
|
|
describe("the Settings landing page", () => {
|
|
it("is a real page, not a redirect: an overview of the installation, not a copy of the sidebar", async () => {
|
|
const { history, titles, calls } = await render("/settings");
|
|
await settle();
|
|
|
|
expect(history.entries).toEqual(["/settings"]);
|
|
const home = required("settings-home");
|
|
expect(home.querySelector("h1")?.textContent).toBe("Settings");
|
|
// Each area is a summary of its state, read from what already exists (ADR 0064).
|
|
expect(required("settings-summary-displays-status").textContent).toBe("1 display paired");
|
|
expect(required("settings-summary-displays").textContent).toContain(
|
|
"Foyer TV was last seen within the last hour.",
|
|
);
|
|
expect(required("settings-summary-modules-status").textContent).toBe("2 modules installed");
|
|
expect(required("settings-summary-modules").textContent).toContain("1 turned off");
|
|
expect(required("settings-summary-branding-status").textContent).toBe(
|
|
"Using the default name and colours",
|
|
);
|
|
expect(calls).toContain("/api/v1/devices");
|
|
expect(calls).toContain("/api/v1/module-lifecycle");
|
|
// The one link that changes each area, rather than the sidebar's list again.
|
|
expect(
|
|
[...home.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual([
|
|
"/settings/displays",
|
|
"/settings/discover/installed",
|
|
"/settings/signage",
|
|
"/settings/general",
|
|
"/settings/branding",
|
|
"/settings/system",
|
|
]);
|
|
// Not one navigation item per section, the way the sidebar lists them.
|
|
expect(home.querySelector(".ek-shell-settings-link")).toBeNull();
|
|
// A disabled module's settings are not offered.
|
|
expect(query("settings-home-module-archive")).toBeNull();
|
|
expect(
|
|
[...container.querySelectorAll("main h2")].map((heading) => heading.textContent),
|
|
).toEqual(["Displays", "Modules", "General", "Branding", "System"]);
|
|
// Nothing on it takes focus by itself.
|
|
expect(document.activeElement?.tagName).not.toBe("INPUT");
|
|
expect(titles.at(-1)).toBe("Settings");
|
|
expect(current()).toEqual(["select-settings"]);
|
|
});
|
|
|
|
it("summarises only what this person may see, and asks for nothing else", async () => {
|
|
const { calls } = await render("/settings", {
|
|
settings: { modules: [{ available: true, id: "signage", name: "Signage" }], sections: [] },
|
|
});
|
|
await settle();
|
|
|
|
expect(query("settings-summary-displays")).toBeNull();
|
|
expect(required("settings-summary-modules-status").textContent).toBe("Module settings");
|
|
expect(calls).not.toContain("/api/v1/devices");
|
|
expect(calls).not.toContain("/api/v1/module-lifecycle");
|
|
});
|
|
|
|
it("says plainly when nothing is paired yet, and when a summary cannot be read", async () => {
|
|
await render("/settings", { devices: [] });
|
|
await settle();
|
|
expect(required("settings-summary-displays-status").textContent).toBe("No displays paired yet");
|
|
expect(required("settings-home-displays").textContent).toBe("Add a display");
|
|
});
|
|
|
|
it("says plainly when there is nothing for this person to change", async () => {
|
|
await render("/settings", { settings: { modules: [], sections: [] } });
|
|
|
|
expect(query("settings-home")?.textContent).toContain(
|
|
"There are no settings for you to change",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the platform sections", () => {
|
|
it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => {
|
|
for (const section of ["general", "branding", "system"] as const) {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
const { titles } = await render(`/settings/${section}`);
|
|
|
|
const page = required(`settings-section-${section}`);
|
|
expect(page.querySelector("h1")?.textContent, section).toBeTruthy();
|
|
expect(page.textContent, section).toContain("Not available yet");
|
|
// No fake controls.
|
|
expect(page.querySelector("input, select, textarea, form"), section).toBeNull();
|
|
const back = page.querySelector(".ek-page-header__back");
|
|
expect(back?.getAttribute("href"), section).toBe("/settings");
|
|
expect(back?.textContent, section).toContain("Back to Settings");
|
|
expect(current(), section).toEqual([`select-settings-${section}`]);
|
|
expect(titles.at(-1), section).toMatch(/ settings$/u);
|
|
}
|
|
});
|
|
|
|
it("opens Discover itself at /settings/discover, as the place modules are found", async () => {
|
|
const { history, titles } = await render("/settings/discover");
|
|
|
|
expect(history.entries).toEqual(["/settings/discover"]);
|
|
expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe(
|
|
"Discover modules",
|
|
);
|
|
expect(query("settings-discover-browse")?.textContent).toContain(
|
|
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
|
|
);
|
|
expect(titles.at(-1)).toBe("Discover modules");
|
|
// Discover is the current page; it is not a parent of some separate Browse page.
|
|
expect(current()).toEqual(["select-settings-discover"]);
|
|
expect(query("select-settings-discover")?.getAttribute("href")).toBe("/settings/discover");
|
|
expect(query("select-discover-browse")).toBeNull();
|
|
});
|
|
|
|
it("respells the earlier /settings/discover/browse in place, adding no history entry", async () => {
|
|
const { history } = await render("/settings/discover/browse?return=%2Fsignage");
|
|
|
|
expect(history.entries).toEqual(["/settings/discover?return=%2Fsignage"]);
|
|
expect(query("settings-discover-browse")).not.toBeNull();
|
|
expect(current()).toEqual(["select-settings-discover"]);
|
|
// Nothing links to the alias.
|
|
expect(container.querySelector('a[href^="/settings/discover/browse"]')).toBeNull();
|
|
});
|
|
|
|
it("has Installed and Updates — and no Browse — beneath Discover, each speaking of modules", async () => {
|
|
const { history } = await render("/settings/discover");
|
|
|
|
const children = required("discover-subnavigation");
|
|
const views = [...children.querySelectorAll("a")];
|
|
expect(views.map((view) => view.textContent)).toEqual(["Installed", "Updates"]);
|
|
expect(views.map((view) => view.getAttribute("href"))).toEqual([
|
|
"/settings/discover/installed",
|
|
"/settings/discover/updates",
|
|
]);
|
|
// Children: text only, marked as children, and listed beneath Discover by name.
|
|
expect(children.getAttribute("aria-label")).toBe("Discover");
|
|
expect(children.className).toContain("ek-shell-nav__children");
|
|
for (const view of views) {
|
|
expect(view.dataset.navLevel).toBe("child");
|
|
expect(view.querySelector("svg")).toBeNull();
|
|
}
|
|
expect(required("select-settings-discover-disclosure").dataset.open).toBe("true");
|
|
|
|
await click(required("select-discover-installed"));
|
|
expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe(
|
|
"Installed modules",
|
|
);
|
|
// The child is the current page and Discover is marked as its parent, never also current.
|
|
expect(current()).toEqual(["select-discover-installed"]);
|
|
expect(required("select-settings-discover").dataset.navAncestor).toBe("");
|
|
await click(required("select-discover-updates"));
|
|
expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe(
|
|
"Module updates",
|
|
);
|
|
await click(required("select-settings-discover"));
|
|
expect(current()).toEqual(["select-settings-discover"]);
|
|
expect(required("select-settings-discover").dataset.navAncestor).toBeUndefined();
|
|
expect(history.entries).toEqual([
|
|
"/settings/discover",
|
|
"/settings/discover/installed",
|
|
"/settings/discover/updates",
|
|
"/settings/discover",
|
|
]);
|
|
});
|
|
|
|
it("shows Discover closed, with no children, while another Settings page is open", async () => {
|
|
await render("/settings/general");
|
|
|
|
expect(query("discover-subnavigation")).toBeNull();
|
|
expect(required("select-settings-discover-disclosure").dataset.open).toBe("false");
|
|
});
|
|
|
|
it("shows only the parent Discover item in the collapsed rail, current for every Discover page", async () => {
|
|
for (const path of ["/settings/discover", "/settings/discover/installed"]) {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
await render(path, { collapsed: true });
|
|
|
|
expect(query("discover-subnavigation"), path).toBeNull();
|
|
expect(query("select-discover-installed"), path).toBeNull();
|
|
expect(current(), path).toEqual(["select-settings-discover"]);
|
|
// No disclosure chevron in the rail: it has no room for the children it would announce.
|
|
expect(query("select-settings-discover-disclosure"), path).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("says a section is unavailable to someone who may not see it, rather than showing it", async () => {
|
|
await render("/settings/discover/installed", {
|
|
settings: { modules: [], sections: ["displays"] },
|
|
});
|
|
|
|
expect(query("settings-no-access")).not.toBeNull();
|
|
expect(query("settings-discover-installed")).toBeNull();
|
|
expect(query("select-settings-discover")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("module settings", () => {
|
|
it("opens a running module's settings at /settings/<module-id>, as a placeholder", async () => {
|
|
const { titles } = await render("/settings/signage");
|
|
|
|
const page = required("module-settings");
|
|
expect(page.querySelector("h1")?.textContent).toBe("Signage");
|
|
expect(page.textContent).toContain("Not available yet");
|
|
expect(page.querySelector("input, select, textarea, form")).toBeNull();
|
|
expect(titles.at(-1)).toBe("Signage settings");
|
|
expect(current()).toEqual(["select-module-settings-signage"]);
|
|
});
|
|
|
|
it("says a disabled module's settings are turned off, rather than redirecting", async () => {
|
|
const { history } = await render("/settings/archive");
|
|
|
|
expect(query("settings-module-unavailable")?.textContent).toContain(
|
|
"This module is turned off",
|
|
);
|
|
expect(history.entries).toEqual(["/settings/archive"]);
|
|
expect(query("select-module-settings-archive")).toBeNull();
|
|
});
|
|
|
|
it("says a known module's settings aren't available when there are none for this person", async () => {
|
|
await render("/settings/calendar");
|
|
|
|
expect(query("settings-no-access")).not.toBeNull();
|
|
});
|
|
|
|
it("is not found for a name that is no module", async () => {
|
|
for (const path of ["/settings/definitely-not-a-module", "/settings/modules"]) {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
await render(path);
|
|
expect(query("host-not-found"), path).not.toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Settings in the navigation", () => {
|
|
it("is one item after the modules, expanding into Platform and Modules only inside Settings", async () => {
|
|
const { history } = await render("/signage");
|
|
|
|
const settingsLink = required("select-settings");
|
|
expect(settingsLink.getAttribute("href")).toBe("/settings");
|
|
expect(query("settings-subnavigation")).toBeNull();
|
|
const links = [...container.querySelectorAll("nav a")].map((link) => link.textContent);
|
|
expect(links).toEqual(["Overview", "Signage", "Calendar", "Settings"]);
|
|
|
|
await click(settingsLink);
|
|
|
|
expect(history.entries).toEqual(["/signage", "/settings"]);
|
|
const sub = required("settings-subnavigation");
|
|
expect([...sub.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([
|
|
"Platform",
|
|
"Modules",
|
|
]);
|
|
expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
|
|
"General",
|
|
"Branding",
|
|
"Displays",
|
|
"Discover",
|
|
"System",
|
|
"Signage",
|
|
]);
|
|
// The rest of the navigation stays, so leaving Settings is one click.
|
|
await click(required("select-signage"));
|
|
expect(query("settings-subnavigation")).toBeNull();
|
|
expect(history.entries).toEqual(["/signage", "/settings", "/signage"]);
|
|
});
|
|
|
|
it("shows only the sections and module settings this person may see", async () => {
|
|
await render("/settings", {
|
|
settings: {
|
|
modules: [{ available: true, id: "signage", name: "Signage" }],
|
|
sections: ["displays"],
|
|
},
|
|
});
|
|
|
|
const sub = required("settings-subnavigation");
|
|
expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
|
|
"Displays",
|
|
"Signage",
|
|
]);
|
|
});
|
|
|
|
it("is not offered at all to someone with nothing to change", async () => {
|
|
await render("/", { settings: { modules: [], sections: [] } });
|
|
|
|
expect(query("select-settings")).toBeNull();
|
|
});
|
|
|
|
it("is withheld when Settings cannot be loaded, whose pages say so and retry", async () => {
|
|
const { calls } = await render("/settings", { settings: "fails" });
|
|
|
|
expect(query("select-settings")).toBeNull();
|
|
expect(query("settings-load-failed")).not.toBeNull();
|
|
// The rest of the Dashboard still works.
|
|
expect(query("select-signage")).not.toBeNull();
|
|
|
|
const retry = [...required("settings-load-failed").querySelectorAll("button")].find(
|
|
(button) => button.textContent === "Retry",
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
retry.click();
|
|
});
|
|
expect(query("settings-home")).not.toBeNull();
|
|
expect(calls.filter((path) => path === "/api/v1/dashboard/settings")).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => {
|
|
const history = createMemoryHostHistory("/settings");
|
|
await render("/settings", { history });
|
|
await click(required("select-settings-branding"));
|
|
await click(required("select-module-settings-signage"));
|
|
|
|
await act(async () => {
|
|
history.go(-1);
|
|
});
|
|
expect(query("settings-section-branding")).not.toBeNull();
|
|
await act(async () => {
|
|
history.go(1);
|
|
});
|
|
expect(query("module-settings")).not.toBeNull();
|
|
|
|
// A refresh is the same address, rendered from scratch.
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
await render("/settings/signage", { history: createMemoryHostHistory(history.pathname()) });
|
|
expect(query("module-settings")).not.toBeNull();
|
|
expect(current()).toEqual(["select-module-settings-signage"]);
|
|
});
|
|
|
|
it("moves focus to the page when moving between two Settings pages", async () => {
|
|
await render("/settings/general");
|
|
const main = required("host-main-region");
|
|
|
|
await click(required("select-settings-branding"));
|
|
|
|
expect(document.activeElement).toBe(main);
|
|
expect(main.getAttribute("aria-label")).toBe("Branding settings");
|
|
});
|
|
|
|
it("uses the same hierarchy in the phone drawer, which closes once a page is chosen", async () => {
|
|
const { history } = await render("/settings", { compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
|
|
const drawer = required("navigation-drawer");
|
|
expect(
|
|
[...drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')].map(
|
|
(link) => link.textContent,
|
|
),
|
|
).toEqual(["General", "Branding", "Displays", "Discover", "System", "Signage"]);
|
|
for (const link of drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')) {
|
|
expect((link as HTMLElement).style.minHeight).toBe("var(--ekklesia-control-height-lg)");
|
|
}
|
|
|
|
await click(required("select-settings-system"));
|
|
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(history.entries).toEqual(["/settings", "/settings/system"]);
|
|
expect(query("settings-section-system")).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("the sidebar is navigation only (ADR 0064)", () => {
|
|
it("has no display-pairing block, expanded or in the phone drawer", async () => {
|
|
await render("/settings");
|
|
const sidebar = required("host-sidebar");
|
|
expect(query("host-devices")).toBeNull();
|
|
expect(sidebar.querySelector("input, form")).toBeNull();
|
|
expect(sidebar.textContent).not.toMatch(/display devices|pairing code/iu);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
await render("/settings", { compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
const drawer = required("navigation-drawer");
|
|
expect(drawer.querySelector("input, form")).toBeNull();
|
|
expect(drawer.textContent).not.toMatch(/display devices|pairing code/iu);
|
|
});
|
|
|
|
it("offers pairing under Settings → Displays instead, without focusing anything on arrival", async () => {
|
|
for (const path of ["/settings", "/settings/displays", "/signage", "/"]) {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
await render(path);
|
|
await settle();
|
|
expect(document.activeElement?.tagName, path).not.toBe("INPUT");
|
|
expect(container.querySelector("input"), path).toBeNull();
|
|
if (path === "/settings/displays") {
|
|
expect(required("settings-section-displays").querySelector("h1")?.textContent).toBe(
|
|
"Displays",
|
|
);
|
|
}
|
|
}
|
|
|
|
// Moving to Displays from the navigation moves focus to the page, never into a field.
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
await render("/settings");
|
|
await click(required("select-settings-displays"));
|
|
await settle();
|
|
expect(document.activeElement).toBe(required("host-main-region"));
|
|
expect(required("add-display").textContent).toContain("Add display");
|
|
});
|
|
});
|
|
|
|
describe("collapsed navigation geometry (ADR 0064)", () => {
|
|
it("leaves every item's layout to the stylesheet, so the rail can centre each glyph", async () => {
|
|
await render("/settings/general", { collapsed: true });
|
|
|
|
const sidebar = required("host-sidebar");
|
|
expect(sidebar.dataset.sidebar).toBe("collapsed");
|
|
const items = [...sidebar.querySelectorAll<HTMLElement>("nav a")];
|
|
expect(items.map((item) => item.dataset.testid)).toEqual([
|
|
"select-overview",
|
|
"select-signage",
|
|
"select-calendar",
|
|
"select-settings",
|
|
"select-settings-general",
|
|
"select-settings-branding",
|
|
"select-settings-displays",
|
|
"select-settings-discover",
|
|
"select-settings-system",
|
|
"select-module-settings-signage",
|
|
]);
|
|
for (const item of items) {
|
|
// One class carries the geometry; no inline layout can outrank the collapsed rule.
|
|
expect(item.classList.contains("ek-shell-nav-item"), item.dataset.testid).toBe(true);
|
|
expect(item.style.justifyContent, item.dataset.testid).toBe("");
|
|
expect(item.style.width, item.dataset.testid).toBe("");
|
|
expect(item.style.padding, item.dataset.testid).toBe("");
|
|
// Its name stays for assistive technology; only one glyph is drawn.
|
|
expect(item.querySelectorAll("svg"), item.dataset.testid).toHaveLength(1);
|
|
expect(item.querySelector(".ek-shell-visually-hidden")?.textContent).toBeTruthy();
|
|
}
|
|
const stylesheet = [...container.querySelectorAll("style")]
|
|
.map((style) => style.textContent)
|
|
.join("\n");
|
|
const collapsedRule =
|
|
/\.ek-shell-sidebar\[data-sidebar="collapsed"\] \.ek-shell-nav-item \{([^}]*)\}/u.exec(
|
|
stylesheet,
|
|
)?.[1];
|
|
expect(collapsedRule).toContain("justify-content: center");
|
|
expect(collapsedRule).toContain("margin-inline: auto");
|
|
expect(collapsedRule).toContain("padding: 0");
|
|
expect(collapsedRule).toContain("width: var(--ekklesia-control-height-md)");
|
|
expect(collapsedRule).toContain("height: var(--ekklesia-control-height-md)");
|
|
});
|
|
});
|
|
|
|
describe("the phone header (ADR 0064)", () => {
|
|
it("is a sticky header holding the menu trigger and the open page's name", async () => {
|
|
await render("/settings/displays", { compact: true });
|
|
|
|
const header = required("mobile-header");
|
|
expect(header.tagName).toBe("HEADER");
|
|
expect(header.className).toBe("ek-shell-mobile-bar");
|
|
expect(header.contains(required("mobile-navigation-trigger"))).toBe(true);
|
|
// A visual echo of the page title; the page's own h1 is what assistive technology reads.
|
|
const title = required("mobile-header-title");
|
|
expect(title.textContent).toBe("Displays settings");
|
|
expect(title.getAttribute("aria-hidden")).toBe("true");
|
|
const trigger = required("mobile-navigation-trigger");
|
|
expect(trigger.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
|
|
expect(trigger.style.minWidth).toBe("var(--ekklesia-control-height-lg)");
|
|
|
|
const stylesheet = [...container.querySelectorAll("style")]
|
|
.map((style) => style.textContent)
|
|
.join("\n");
|
|
const rule = /\.ek-shell-mobile-bar \{([^}]*)\}/u.exec(stylesheet)?.[1];
|
|
expect(rule).toContain("position: sticky");
|
|
expect(rule).toContain("top: 0");
|
|
expect(rule).toContain("env(safe-area-inset-top, 0px)");
|
|
});
|
|
|
|
it("follows the open page, and the drawer still opens from it and returns focus to it", async () => {
|
|
await render("/settings", { compact: true });
|
|
expect(required("mobile-header-title").textContent).toBe("Settings");
|
|
|
|
await click(required("mobile-navigation-trigger"));
|
|
const drawer = required("navigation-drawer");
|
|
expect(document.activeElement).toBe(required("navigation-drawer-close"));
|
|
await act(async () => {
|
|
drawer.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
|
});
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(document.activeElement).toBe(required("mobile-navigation-trigger"));
|
|
|
|
await click(required("mobile-navigation-trigger"));
|
|
await click(required("select-settings-discover"));
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(required("mobile-header-title").textContent).toBe("Discover modules");
|
|
expect(document.activeElement).toBe(required("host-main-region"));
|
|
});
|
|
});
|
|
|
|
describe("the phone drawer locks the page behind it (ADR 0065)", () => {
|
|
const rootOverflow = () => document.documentElement.style.getPropertyValue("overflow");
|
|
const rootOverscroll = () =>
|
|
document.documentElement.style.getPropertyValue("overscroll-behavior");
|
|
|
|
it("stops the page scrolling while open, and releases it on Escape", async () => {
|
|
await render("/settings", { compact: true });
|
|
expect(rootOverflow()).toBe("");
|
|
|
|
await click(required("mobile-navigation-trigger"));
|
|
expect(rootOverflow()).toBe("hidden");
|
|
expect(rootOverscroll()).toBe("none");
|
|
expect(document.body.style.getPropertyValue("overflow")).toBe("hidden");
|
|
// The drawer's own list is the one region that still scrolls.
|
|
expect(required("navigation-drawer").contains(required("navigation-drawer-scroll"))).toBe(true);
|
|
|
|
await act(async () => {
|
|
required("navigation-drawer").dispatchEvent(
|
|
new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }),
|
|
);
|
|
});
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(rootOverflow()).toBe("");
|
|
expect(rootOverscroll()).toBe("");
|
|
expect(document.activeElement).toBe(required("mobile-navigation-trigger"));
|
|
});
|
|
|
|
it("cancels a drag on the page behind it, and releases it when a page is chosen", async () => {
|
|
await render("/settings", { compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
|
|
const drag = new Event("touchmove", { bubbles: true, cancelable: true });
|
|
Object.defineProperty(drag, "touches", { value: [{ clientY: 300 }] });
|
|
required("host-main-region").dispatchEvent(drag);
|
|
expect(drag.defaultPrevented).toBe(true);
|
|
|
|
await click(required("select-settings-displays"));
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(rootOverflow()).toBe("");
|
|
const after = new Event("touchmove", { bubbles: true, cancelable: true });
|
|
Object.defineProperty(after, "touches", { value: [{ clientY: 300 }] });
|
|
required("host-main-region").dispatchEvent(after);
|
|
expect(after.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("never locks anything on a wide screen", async () => {
|
|
await render("/settings");
|
|
expect(query("mobile-navigation-trigger")).toBeNull();
|
|
expect(rootOverflow()).toBe("");
|
|
expect(document.body.style.getPropertyValue("overflow")).toBe("");
|
|
});
|
|
});
|