Manage paired displays: themed select, reassignment, live list, no revoked clutter
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
Real-device review of the platform display flow found four defects in display management: - Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn single choice following the WAI-ARIA select-only combobox pattern, replacing the native select for "Show on this screen". It has a themed list, a chevron inside the control, keyboard support (arrows, Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only the list), pointer and touch support, and focus that stays on the control. It is an ek-control, so it keeps the 16px phone size. - Newly paired displays appear without a reload. Approval doesn't create the display; the screen does when it claims its credential a few seconds later. The page now shows a "Waiting for the screen to connect" placeholder and refetches every second until the new display appears (for up to a minute), and refreshes the list every 30 seconds. - What a display shows can change after pairing. In the single-target Stage 1 model, a display's current target is its one active display permission, so no migration is needed. PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted origin and a bounded body, revokes its other active permission rows (kept as history) and grants the chosen module's declared display permission, in one transaction on the same device. The screen notices within about 30 seconds: a paired device on a module page that is no longer set to show it goes to /display/setup instead of re-pairing, and /display/setup opens the new target. - Revoked displays leave the normal list at once. Their rows stay in the database as history. Each display card now shows its name, Online/Offline/Not connected yet, "Showing: ...", last seen and paired dates, and Change and Revoke. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
300d9eab51
commit
d9dafcb0f7
@@ -9,13 +9,18 @@ declare global {
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Displays (ADR 0064): the device surface that used to sit in the sidebar, moved into
|
||||
* Settings — the paired displays, a deliberate Add display flow, and revocation in a product dialog.
|
||||
* Settings → Displays (ADR 0064, ADR 0065): the active platform displays, setting up a new screen,
|
||||
* pairing it, changing what it shows, and revoking it.
|
||||
*/
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
||||
|
||||
/** A screen that asked from Signage's own display page. */
|
||||
const MODULE_CODE = "123456";
|
||||
/** A screen that asked from the platform's `/display/setup`, with no module. */
|
||||
const PLATFORM_CODE = "654321";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -38,21 +43,31 @@ function json(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastSeenAt?: string;
|
||||
permissions: string[];
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
interface ServerOptions {
|
||||
readonly devices?: unknown[];
|
||||
readonly devices?: Device[];
|
||||
readonly denied?: boolean;
|
||||
/** What `GET /api/v1/devices/display-modules` answers: the installed display modules (by
|
||||
* default Signage), none, or a failure. */
|
||||
/** What `GET /api/v1/devices/display-modules` answers. */
|
||||
readonly modules?: unknown[] | "fails";
|
||||
}
|
||||
|
||||
/** A screen that asked from Signage's own display page. */
|
||||
const MODULE_CODE = "123456";
|
||||
/** A screen that asked from the platform's `/display/setup`, with no module (ADR 0065). */
|
||||
const PLATFORM_CODE = "654321";
|
||||
const SIGNAGE = { moduleId: "signage", name: "Signage", permissionId: "signage.display" };
|
||||
const BULLETIN = { moduleId: "bulletin", name: "Bulletin", permissionId: "bulletin.display" };
|
||||
|
||||
/**
|
||||
* A fake API that behaves like the real one where it matters: an approved display does not exist
|
||||
* until its screen collects its credential, which here happens a couple of list requests later.
|
||||
*/
|
||||
function server(options: ServerOptions = {}) {
|
||||
let devices: unknown[] = options.devices ?? [
|
||||
const devices: Device[] = options.devices ?? [
|
||||
{
|
||||
createdAt: "2026-10-01T09:00:00.000Z",
|
||||
id: "device-1",
|
||||
@@ -62,26 +77,52 @@ function server(options: ServerOptions = {}) {
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
const requests: { path: string; body: unknown }[] = [];
|
||||
const modules =
|
||||
options.modules === "fails" ? [] : ((options.modules ?? [SIGNAGE]) as (typeof SIGNAGE)[]);
|
||||
let pending: { device: Device; listsUntilClaimed: number } | undefined;
|
||||
const requests: { path: string; method: string; body: unknown }[] = [];
|
||||
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
const method = init?.method ?? "GET";
|
||||
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
|
||||
requests.push({ body, path });
|
||||
requests.push({ body, method, path });
|
||||
if (path === "/api/v1/devices/display-modules") {
|
||||
if (options.modules === "fails") {
|
||||
return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503);
|
||||
}
|
||||
return json({
|
||||
modules: options.modules ?? [
|
||||
{ moduleId: "signage", name: "Signage", permissionId: "signage.display" },
|
||||
],
|
||||
});
|
||||
return options.modules === "fails"
|
||||
? json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503)
|
||||
: json({ modules });
|
||||
}
|
||||
if (path === "/api/v1/devices") {
|
||||
return options.denied ? json({ code: "AUTHORIZATION_DENIED" }, 403) : json({ devices });
|
||||
if (options.denied) {
|
||||
return json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
if (pending !== undefined) {
|
||||
pending.listsUntilClaimed -= 1;
|
||||
if (pending.listsUntilClaimed <= 0) {
|
||||
devices.push(pending.device);
|
||||
pending = undefined;
|
||||
}
|
||||
}
|
||||
return json({ devices });
|
||||
}
|
||||
if (path === "/api/v1/devices/device-1/revoke") {
|
||||
devices = devices.map((device) => ({ ...(device as object), revoked: true }));
|
||||
const display = /^\/api\/v1\/devices\/([^/]+)\/display$/u.exec(path);
|
||||
if (display && method === "PUT") {
|
||||
const device = devices.find((candidate) => candidate.id === display[1]);
|
||||
const moduleId = (body as { moduleId: string | null }).moduleId;
|
||||
const module = modules.find((candidate) => candidate.moduleId === moduleId);
|
||||
if (!device || device.revoked || (moduleId !== null && !module)) {
|
||||
return json({ code: "NOT_FOUND" }, 404);
|
||||
}
|
||||
device.permissions = module ? [module.permissionId] : [];
|
||||
return json({ moduleId });
|
||||
}
|
||||
const revoke = /^\/api\/v1\/devices\/([^/]+)\/revoke$/u.exec(path);
|
||||
if (revoke) {
|
||||
for (const device of devices) {
|
||||
if (device.id === revoke[1]) {
|
||||
device.revoked = true;
|
||||
device.permissions = [];
|
||||
}
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (path === "/api/v1/device-pairing/lookups") {
|
||||
@@ -100,24 +141,22 @@ function server(options: ServerOptions = {}) {
|
||||
if (path === "/api/v1/device-pairing/approvals") {
|
||||
const approval = body as { code: string; deviceName: string; moduleId?: string };
|
||||
const shows = approval.code === MODULE_CODE ? "signage" : approval.moduleId;
|
||||
devices = [
|
||||
...devices,
|
||||
{
|
||||
const module = modules.find((candidate) => candidate.moduleId === shows);
|
||||
pending = {
|
||||
device: {
|
||||
createdAt: "2026-10-15T12:00:00.000Z",
|
||||
id: "device-2",
|
||||
id: `device-${devices.length + 1}`,
|
||||
name: approval.deviceName,
|
||||
permissions: shows === "signage" ? ["signage.display"] : [],
|
||||
permissions: module ? [module.permissionId] : [],
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
return json({
|
||||
moduleId: shows ?? null,
|
||||
permissionId: shows === undefined ? null : `${shows}.display`,
|
||||
});
|
||||
listsUntilClaimed: 2,
|
||||
};
|
||||
return json({ moduleId: shows ?? null, permissionId: module?.permissionId ?? null });
|
||||
}
|
||||
return json({ code: "NOT_FOUND" }, 404);
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
return { fetch, requests };
|
||||
return { devices, fetch, requests };
|
||||
}
|
||||
|
||||
function query(testId: string): HTMLElement | null {
|
||||
@@ -138,6 +177,19 @@ async function settle(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits, in real time, for something the page does on its own (a revalidation). */
|
||||
async function eventually(condition: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error("timed out");
|
||||
}
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function click(testId: string): Promise<void> {
|
||||
await act(async () => {
|
||||
required(testId).click();
|
||||
@@ -154,15 +206,6 @@ async function type(testId: string, value: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function choose(testId: string, value: string): Promise<void> {
|
||||
const select = required(testId) as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
||||
setter?.call(select, value);
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(testId: string): Promise<void> {
|
||||
const form = (required(testId) as HTMLButtonElement).form;
|
||||
await act(async () => {
|
||||
@@ -171,6 +214,22 @@ async function submit(testId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens a SelectMenu and chooses an option by its label, as a pointer would. */
|
||||
async function choose(testId: string, label: string): Promise<void> {
|
||||
await click(testId);
|
||||
const listId = required(testId).getAttribute("aria-controls") ?? "";
|
||||
const option = [...(container.querySelector(`#${CSS.escape(listId)}`)?.children ?? [])].find(
|
||||
(candidate) => candidate.textContent === label,
|
||||
) as HTMLElement | undefined;
|
||||
if (!option) {
|
||||
throw new Error(`no option ${label}`);
|
||||
}
|
||||
await act(async () => {
|
||||
option.click();
|
||||
await settle();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPage(options: ServerOptions = {}) {
|
||||
const platform = server(options);
|
||||
await act(async () => {
|
||||
@@ -180,6 +239,8 @@ async function renderPage(options: ServerOptions = {}) {
|
||||
config={{ apiBaseUrl, fetch: platform.fetch }}
|
||||
moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)}
|
||||
now={() => NOW}
|
||||
pollMs={5}
|
||||
refreshMs={0}
|
||||
/>,
|
||||
);
|
||||
await settle();
|
||||
@@ -188,34 +249,61 @@ async function renderPage(options: ServerOptions = {}) {
|
||||
}
|
||||
|
||||
describe("Settings → Displays", () => {
|
||||
it("lists the paired displays with what is known about each, and takes no focus", async () => {
|
||||
it("lists each active display with its status, what it shows, and its actions", async () => {
|
||||
const before = document.activeElement;
|
||||
await renderPage();
|
||||
|
||||
const card = required("device-device-1");
|
||||
expect(card.textContent).toContain("Foyer TV");
|
||||
expect(card.textContent).toContain("Paired");
|
||||
expect(card.textContent).toContain("Online");
|
||||
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
||||
expect(card.textContent).toContain("Last seen within the last hour");
|
||||
expect(card.textContent).toContain("Not showing a module yet");
|
||||
expect(query("device-change-device-1")?.textContent).toBe("Change");
|
||||
expect(query("device-revoke-device-1")?.textContent).toBe("Revoke");
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Displays");
|
||||
// How to set up a new screen comes first, in one place: the platform's own setup page on the
|
||||
// API origin, then Add display right under it, with the step-by-step version folded away.
|
||||
// Setting up a new screen comes first, in one place, at the platform's own setup address.
|
||||
const setup = required("display-setup");
|
||||
expect(setup.textContent).toContain("Open this address on the new screen");
|
||||
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
||||
expect(setup.contains(required("add-display"))).toBe(true);
|
||||
const steps = required("display-setup-steps");
|
||||
expect(steps.tagName).toBe("DETAILS");
|
||||
expect((steps as HTMLDetailsElement).open).toBe(false);
|
||||
expect(steps.querySelector("summary")?.textContent).toBe("Step-by-step instructions");
|
||||
expect(steps.textContent).toContain("six-digit pairing code");
|
||||
// Not a second Add display up in the header.
|
||||
expect(container.querySelectorAll('[data-testid="add-display"]')).toHaveLength(1);
|
||||
expect((required("display-setup-steps") as HTMLDetailsElement).open).toBe(false);
|
||||
// No pairing form until someone asks for one, and nothing grabbed focus.
|
||||
expect(container.querySelector("input")).toBeNull();
|
||||
expect(document.activeElement).toBe(before);
|
||||
});
|
||||
|
||||
it("shows only active displays: revoked ones are history, not the normal list", async () => {
|
||||
await renderPage({
|
||||
devices: [
|
||||
{
|
||||
createdAt: "2026-10-01T09:00:00.000Z",
|
||||
id: "a",
|
||||
name: "Hall",
|
||||
permissions: [],
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
createdAt: "2026-09-01T09:00:00.000Z",
|
||||
id: "b",
|
||||
name: "Old test screen",
|
||||
permissions: [],
|
||||
revoked: true,
|
||||
},
|
||||
{
|
||||
createdAt: "2026-09-02T09:00:00.000Z",
|
||||
id: "c",
|
||||
name: "Another old one",
|
||||
permissions: [],
|
||||
revoked: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(query("device-a")).not.toBeNull();
|
||||
expect(query("device-b")).toBeNull();
|
||||
expect(query("device-c")).toBeNull();
|
||||
expect(container.textContent).not.toContain("Revoked");
|
||||
expect(required("device-a").textContent).toContain("Not connected yet");
|
||||
});
|
||||
|
||||
it("says when nothing is paired yet, and when the list is not this person's to see", async () => {
|
||||
await renderPage({ devices: [] });
|
||||
expect(required("device-list-empty").textContent).toContain("No displays are paired yet");
|
||||
@@ -227,40 +315,37 @@ describe("Settings → Displays", () => {
|
||||
expect(query("add-display")).toBeNull();
|
||||
});
|
||||
|
||||
it("pairs a display through a deliberate Add display flow, focusing each next step", async () => {
|
||||
it("shows a newly paired display without a page refresh, once its screen connects", async () => {
|
||||
const platform = await renderPage();
|
||||
|
||||
await click("add-display");
|
||||
const dialog = required("add-display-dialog");
|
||||
expect(dialog.getAttribute("role")).toBe("dialog");
|
||||
// Typing the code is now plainly the next thing to do, so the field has focus.
|
||||
expect(document.activeElement).toBe(required("device-pairing-code-input"));
|
||||
|
||||
await type("device-pairing-code-input", "999999");
|
||||
await submit("device-pairing-lookup");
|
||||
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
|
||||
|
||||
await type("device-pairing-code-input", MODULE_CODE);
|
||||
await submit("device-pairing-lookup");
|
||||
// A screen that asked from a module's own display already shows that module: no choice.
|
||||
expect(required("device-pairing-found").textContent).toContain("Signage");
|
||||
expect(query("device-pairing-shows")).toBeNull();
|
||||
expect(document.activeElement).toBe(required("device-pairing-name"));
|
||||
|
||||
await type("device-pairing-name", "Sanctuary left");
|
||||
await submit("device-pairing-approve");
|
||||
|
||||
// The dialog closes and the page says so at once; the screen has not connected yet.
|
||||
expect(query("add-display-dialog")).toBeNull();
|
||||
expect(
|
||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||
).toEqual({ code: MODULE_CODE, deviceName: "Sanctuary left" });
|
||||
// The new display appears without a page refresh, and the page says so.
|
||||
expect(required("device-device-2").textContent).toContain("Sanctuary left");
|
||||
expect(required("device-pairing-success").textContent).toContain(
|
||||
"Sanctuary left is paired and shows Signage.",
|
||||
);
|
||||
expect(required("device-device-2").textContent).toContain("Shows Signage");
|
||||
// Focus is back on the control that opened the flow.
|
||||
expect(required("device-awaiting").textContent).toContain("Waiting for the screen to connect");
|
||||
|
||||
// The page checks again on its own until the display exists — no reload.
|
||||
await eventually(() => query("device-device-2") !== null);
|
||||
expect(query("device-awaiting")).toBeNull();
|
||||
expect(required("device-device-2-showing").textContent).toBe("Showing: Signage");
|
||||
// Never twice, however many times it is fetched again.
|
||||
expect(container.querySelectorAll('[data-testid="device-device-2"]')).toHaveLength(1);
|
||||
expect(platform.requests.filter((r) => r.path === "/api/v1/devices").length).toBeGreaterThan(2);
|
||||
expect(document.activeElement).toBe(required("add-display"));
|
||||
});
|
||||
|
||||
@@ -295,15 +380,11 @@ describe("setting up a new screen (ADR 0065)", () => {
|
||||
expect(required("display-setup").textContent).toContain("couldn't copy it");
|
||||
});
|
||||
|
||||
it("offers the same setup and Add display with zero display modules installed", async () => {
|
||||
it("pairs a platform display with zero display modules installed", async () => {
|
||||
const platform = await renderPage({ modules: [] });
|
||||
|
||||
// The generic platform address, and nothing that names a module.
|
||||
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
||||
expect(required("display-setup").textContent).not.toContain("Signage");
|
||||
expect(query("add-display")).not.toBeNull();
|
||||
|
||||
// A screen from /display/setup pairs as a platform display with no module.
|
||||
await click("add-display");
|
||||
await type("device-pairing-code-input", PLATFORM_CODE);
|
||||
await submit("device-pairing-lookup");
|
||||
@@ -320,40 +401,35 @@ describe("setting up a new screen (ADR 0065)", () => {
|
||||
expect(
|
||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||
).toEqual({ code: PLATFORM_CODE, deviceName: "Foyer TV 2" });
|
||||
expect(required("device-pairing-success").textContent).toContain(
|
||||
"Foyer TV 2 is paired. It shows its name until a module is set to show on it.",
|
||||
);
|
||||
expect(required("device-device-2").textContent).toContain("Not showing a module yet");
|
||||
await eventually(() => query("device-device-2") !== null);
|
||||
expect(required("device-device-2-showing").textContent).toBe("Showing: Nothing yet");
|
||||
});
|
||||
|
||||
it("lets the administrator choose which installed module a new platform display shows", async () => {
|
||||
it("chooses what a new platform display shows with the themed select, not a native one", async () => {
|
||||
const platform = await renderPage();
|
||||
await click("add-display");
|
||||
await type("device-pairing-code-input", PLATFORM_CODE);
|
||||
await submit("device-pairing-lookup");
|
||||
|
||||
const shows = required("device-pairing-shows") as HTMLSelectElement;
|
||||
expect([...shows.options].map((option) => option.textContent)).toEqual([
|
||||
"Nothing yet",
|
||||
"Signage",
|
||||
]);
|
||||
expect(shows.value).toBe("");
|
||||
await choose("device-pairing-shows", "signage");
|
||||
const dialog = required("add-display-dialog");
|
||||
expect(dialog.querySelector("select")).toBeNull();
|
||||
const shows = required("device-pairing-shows");
|
||||
expect(shows.getAttribute("role")).toBe("combobox");
|
||||
expect(shows.textContent).toBe("Nothing yet");
|
||||
await choose("device-pairing-shows", "Signage");
|
||||
expect(shows.textContent).toBe("Signage");
|
||||
await type("device-pairing-name", "Lobby screen");
|
||||
await submit("device-pairing-approve");
|
||||
|
||||
expect(
|
||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||
).toEqual({ code: PLATFORM_CODE, deviceName: "Lobby screen", moduleId: "signage" });
|
||||
expect(required("device-pairing-success").textContent).toContain(
|
||||
"Lobby screen is paired and shows Signage.",
|
||||
);
|
||||
expect(required("device-device-2").textContent).toContain("Shows Signage");
|
||||
await eventually(() => query("device-device-2") !== null);
|
||||
expect(required("device-device-2-showing").textContent).toBe("Showing: Signage");
|
||||
});
|
||||
|
||||
it("still pairs, showing nothing yet, when the display modules cannot be read", async () => {
|
||||
const platform = await renderPage({ modules: "fails" });
|
||||
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
||||
await click("add-display");
|
||||
await type("device-pairing-code-input", PLATFORM_CODE);
|
||||
await submit("device-pairing-lookup");
|
||||
@@ -363,8 +439,66 @@ describe("setting up a new screen (ADR 0065)", () => {
|
||||
expect(
|
||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||
).toEqual({ code: PLATFORM_CODE, deviceName: "Hall" });
|
||||
// Without the module list the card does not guess what the display shows.
|
||||
expect(required("device-device-1").textContent).not.toContain("showing");
|
||||
// Without the module list the card neither guesses what it shows nor offers to change it.
|
||||
expect(query("device-device-1-showing")).toBeNull();
|
||||
expect(query("device-change-device-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("changing what a display shows (ADR 0065)", () => {
|
||||
it("moves a display from nothing to a module, between modules, and back, as the same display", async () => {
|
||||
const platform = await renderPage({ modules: [SIGNAGE, BULLETIN] });
|
||||
const puts = () =>
|
||||
platform.requests.filter((r) => r.method === "PUT").map((r) => [r.path, r.body]);
|
||||
|
||||
// Nothing yet → Signage.
|
||||
await click("device-change-device-1");
|
||||
const dialog = required("device-change-dialog");
|
||||
expect(dialog.textContent).toContain("Change what Foyer TV shows");
|
||||
expect(dialog.querySelector("select")).toBeNull();
|
||||
expect(required("device-change-shows").textContent).toBe("Nothing yet");
|
||||
// Nothing to save until something changes.
|
||||
expect((required("device-change-save") as HTMLButtonElement).disabled).toBe(true);
|
||||
await choose("device-change-shows", "Signage");
|
||||
await click("device-change-save");
|
||||
expect(query("device-change-dialog")).toBeNull();
|
||||
expect(required("device-device-1-showing").textContent).toBe("Showing: Signage");
|
||||
expect(required("device-change-success").textContent).toBe("Foyer TV now shows Signage.");
|
||||
expect(document.activeElement).toBe(required("device-change-device-1"));
|
||||
|
||||
// Signage → Bulletin.
|
||||
await click("device-change-device-1");
|
||||
expect(required("device-change-shows").textContent).toBe("Signage");
|
||||
await choose("device-change-shows", "Bulletin");
|
||||
await click("device-change-save");
|
||||
expect(required("device-device-1-showing").textContent).toBe("Showing: Bulletin");
|
||||
|
||||
// Bulletin → nothing.
|
||||
await click("device-change-device-1");
|
||||
await choose("device-change-shows", "Nothing yet");
|
||||
await click("device-change-save");
|
||||
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
||||
expect(required("device-change-success").textContent).toContain("Foyer TV now shows nothing");
|
||||
|
||||
expect(puts()).toEqual([
|
||||
["/api/v1/devices/device-1/display", { moduleId: "signage" }],
|
||||
["/api/v1/devices/device-1/display", { moduleId: "bulletin" }],
|
||||
["/api/v1/devices/device-1/display", { moduleId: null }],
|
||||
]);
|
||||
// Always the one display: no new identity, and the same name.
|
||||
expect(platform.devices.map((device) => device.id)).toEqual(["device-1"]);
|
||||
expect(container.querySelectorAll('[data-testid^="device-device-"]').length).toBeGreaterThan(0);
|
||||
expect(container.querySelectorAll('[data-testid="device-device-1"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closes on Cancel or Escape without changing anything", async () => {
|
||||
const platform = await renderPage();
|
||||
await click("device-change-device-1");
|
||||
await choose("device-change-shows", "Signage");
|
||||
await click("device-change-cancel");
|
||||
expect(query("device-change-dialog")).toBeNull();
|
||||
expect(platform.requests.some((r) => r.method === "PUT")).toBe(false);
|
||||
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -389,15 +523,21 @@ describe("display revocation", () => {
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes only after the destructive action is confirmed", async () => {
|
||||
it("removes a revoked display from the list at once, and says what happened", async () => {
|
||||
const platform = await renderPage();
|
||||
|
||||
await click("device-revoke-device-1");
|
||||
await click("device-revoke-confirm");
|
||||
await act(async () => settle());
|
||||
|
||||
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(required("device-device-1").textContent).toContain("Revoked");
|
||||
expect(query("device-revoke-device-1")).toBeNull();
|
||||
// Gone from the active list without a reload, though the server keeps it as history.
|
||||
expect(query("device-device-1")).toBeNull();
|
||||
expect(platform.devices[0]?.revoked).toBe(true);
|
||||
expect(required("device-list-empty")).not.toBeNull();
|
||||
expect(required("device-revoke-success").textContent).toContain("Foyer TV is revoked");
|
||||
// Its card is gone, so focus lands on what happened rather than on the page.
|
||||
expect(document.activeElement).toBe(required("device-outcome"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user