Activating a timeline from Playback left Timelines showing it as inactive, because each tab read its own stale copy of the channel groups; operators read that as activation not sticking. Tabs also mounted every panel afresh, so each switch flashed a spinner and collapsed the page before regrowing. Panels now share one stale-while-revalidate resource cache, and Playback follows the management realtime channel so Now showing tracks the wall. Deleting an image, a timeline, a slot, a source, or an audience happened on a single click; each is now a two-step in-place confirmation (DESIGN 21.3). The audience key was presented as a field to fill in. Audiences are now named in plain words, explained (images look the same for every audience), and the content key is derived from the name with an Advanced override. The roster labels signed-in browsers and lets an offline entry be removed after confirmation, so test tabs and retired screens no longer accumulate. The preview keeps nothing in the Dashboard origin's storage, and the narrow-screen notice no longer names the platform. Co-Authored-By: Claude Opus 5.5 <[email protected]>
857 lines
26 KiB
TypeScript
857 lines
26 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
|
|
import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js";
|
|
|
|
/**
|
|
* The management UI's behaviour, mounted as React rather than inspected as source.
|
|
*
|
|
* The artifact-level proof lives in `module-ui.test.ts`; this exercises what an operator does — read
|
|
* a screen, press a control, be told why a save was refused — against a host that answers only the
|
|
* relative paths Signage actually asks for.
|
|
*/
|
|
|
|
// React needs this to run effects the way a browser does.
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
let container: HTMLElement;
|
|
let root: Root;
|
|
const DISPLAY_SETUP_URL = "https://api.example.test/display/signage";
|
|
const copiedText: string[] = [];
|
|
|
|
function dashboard(context: Parameters<typeof SignageDashboard>[0]["context"]): React.ReactElement {
|
|
return (
|
|
<SignageDashboard
|
|
context={context}
|
|
copyText={(value) => {
|
|
copiedText.push(value);
|
|
return Promise.resolve();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
async function render(element: React.ReactElement): Promise<void> {
|
|
await act(async () => {
|
|
root.render(element);
|
|
});
|
|
// Let the resource loads settle.
|
|
for (let index = 0; index < 6; index += 1) {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
}
|
|
|
|
async function click(element: Element | null | undefined): Promise<void> {
|
|
if (!element) {
|
|
throw new Error("Expected an element to click.");
|
|
}
|
|
await act(async () => {
|
|
(element as HTMLElement).click();
|
|
});
|
|
for (let index = 0; index < 6; index += 1) {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Types into a controlled React input the way a person does.
|
|
*
|
|
* Assigning `input.value` directly and dispatching an event is not enough: React tracks the value on
|
|
* the element and would treat the change as already handled. Going through the prototype's setter is
|
|
* what makes React see it.
|
|
*/
|
|
async function type(input: HTMLInputElement, value: string): Promise<void> {
|
|
const setter = Object.getOwnPropertyDescriptor(
|
|
globalThis.HTMLInputElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
await act(async () => {
|
|
setter?.call(input, value);
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
function buttonNamed(label: string): HTMLButtonElement | undefined {
|
|
return [...container.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === label,
|
|
) as HTMLButtonElement | undefined;
|
|
}
|
|
|
|
const GROUP = {
|
|
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
|
|
id: "group-1",
|
|
name: "Main",
|
|
playback: {
|
|
activeTimelineId: "timeline-1",
|
|
blackout: false,
|
|
currentSelectionId: "sel-1",
|
|
currentSlotId: "slot-1",
|
|
deadlineAtMs: 1_700_000_005_000,
|
|
groupId: "group-1",
|
|
pendingTimelineId: null,
|
|
remainingMs: 4000,
|
|
revision: 7,
|
|
scheduledActivationAtMs: null,
|
|
scheduledTimelineId: null,
|
|
selectionRevision: 1,
|
|
serverNowMs: 1_700_000_001_000,
|
|
startedAtMs: 1_700_000_000_000,
|
|
staticAssetId: null,
|
|
transport: "playing" as const,
|
|
},
|
|
revision: 1,
|
|
};
|
|
|
|
const PLAYBACK_DETAIL = {
|
|
current: {
|
|
deadlineAtMs: 1_700_000_005_000,
|
|
durationMs: 5000,
|
|
id: "sel-1",
|
|
itemId: "welcome",
|
|
itemRevision: "1",
|
|
providerId: "fixture.announcements",
|
|
rendererContributionId: "fixture.announcements.slide",
|
|
revision: 1,
|
|
stale: false,
|
|
startedAtMs: 1_700_000_000_000,
|
|
withdrawn: false,
|
|
},
|
|
history: [
|
|
{
|
|
id: "sel-0",
|
|
itemId: "giving",
|
|
providerId: "fixture.announcements",
|
|
sequence: 1,
|
|
startedAtMs: 1_699_999_990_000,
|
|
},
|
|
],
|
|
snapshot: GROUP.playback,
|
|
};
|
|
|
|
const TIMELINE = {
|
|
description: null,
|
|
eligibleFrom: null,
|
|
eligibleUntil: null,
|
|
id: "timeline-1",
|
|
name: "Sunday morning",
|
|
revision: 3,
|
|
updatedAt: "2026-08-30T15:00:00.000Z",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
copiedText.length = 0;
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
describe("Signage dashboard", () => {
|
|
it("shows live transport for the default channel group and drives it with one command each", async () => {
|
|
const commands: string[] = [];
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view", "signage.playback.control"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: /^playback\/group-1\//u,
|
|
respond: ({ path }) => {
|
|
commands.push(path);
|
|
return jsonResponse(GROUP.playback);
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
|
|
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain(
|
|
"welcome",
|
|
);
|
|
expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s");
|
|
|
|
await click(buttonNamed("Pause"));
|
|
await click(buttonNamed("Next"));
|
|
await click(buttonNamed("Blackout"));
|
|
|
|
expect(commands).toEqual([
|
|
"playback/group-1/pause",
|
|
"playback/group-1/next",
|
|
"playback/group-1/blackout",
|
|
]);
|
|
});
|
|
|
|
it("offers no transport controls to an operator who may only view", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
|
|
// The current slide is still legible; the buttons that would be refused are simply not offered.
|
|
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull();
|
|
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull();
|
|
});
|
|
|
|
it("shows which timeline remains active after the dashboard data is reloaded", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view", "signage.playback.control"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [TIMELINE] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "providers",
|
|
respond: () => jsonResponse({ providers: [] }),
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
expect(container.textContent).toContain("Active: Sunday morning");
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find(
|
|
(candidate) => candidate.textContent === "Timelines",
|
|
),
|
|
);
|
|
|
|
expect(container.textContent).toContain("Active in Main");
|
|
expect(buttonNamed("Active")?.disabled).toBe(true);
|
|
});
|
|
|
|
it("keeps a failed activation visible instead of pretending it succeeded", async () => {
|
|
const inactiveGroup = {
|
|
...GROUP,
|
|
playback: {
|
|
...GROUP.playback,
|
|
activeTimelineId: null,
|
|
currentSelectionId: null,
|
|
},
|
|
};
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view", "signage.playback.control"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [inactiveGroup] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [TIMELINE] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "providers",
|
|
respond: () => jsonResponse({ providers: [] }),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "playback/group-1/activate",
|
|
respond: () =>
|
|
errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409),
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find(
|
|
(candidate) => candidate.textContent === "Timelines",
|
|
),
|
|
);
|
|
await click(buttonNamed("Activate"));
|
|
|
|
expect(container.textContent).toContain("Sunday morning");
|
|
expect(container.querySelector("[role='alert']")?.textContent).toContain(
|
|
"The request conflicts with the current state.",
|
|
);
|
|
});
|
|
|
|
it("shows Signage's own sanitized reason when a save is refused", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view", "signage.timelines.manage"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "providers",
|
|
respond: () => jsonResponse({ providers: [] }),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "timelines",
|
|
respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400),
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find(
|
|
(tab) => tab.textContent === "Timelines",
|
|
),
|
|
);
|
|
|
|
const input = container.querySelector("input[type='text']") as HTMLInputElement;
|
|
await type(input, "Sunday morning");
|
|
await click(buttonNamed("Create"));
|
|
|
|
const alert = container.querySelector("[role='alert']");
|
|
expect(alert?.textContent).toBe("The request is not valid.");
|
|
// The server's own fixed message, and nothing else: no status code, no URL, no request detail.
|
|
expect(alert?.textContent).not.toMatch(/400|http|timelines/u);
|
|
});
|
|
|
|
it("says a timeline has no slots rather than showing an empty table", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view", "signage.timelines.manage"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "providers",
|
|
respond: () => jsonResponse({ providers: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "images",
|
|
respond: () => jsonResponse({ images: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () =>
|
|
jsonResponse({
|
|
timelines: [
|
|
{
|
|
description: null,
|
|
eligibleFrom: null,
|
|
eligibleUntil: null,
|
|
id: "timeline-1",
|
|
name: "Sunday morning",
|
|
revision: 3,
|
|
updatedAt: "2026-08-30T15:00:00.000Z",
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines/timeline-1",
|
|
respond: () =>
|
|
jsonResponse({
|
|
slots: [],
|
|
sources: [],
|
|
timeline: {
|
|
description: null,
|
|
eligibleFrom: null,
|
|
eligibleUntil: null,
|
|
id: "timeline-1",
|
|
name: "Sunday morning",
|
|
revision: 3,
|
|
updatedAt: "2026-08-30T15:00:00.000Z",
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find(
|
|
(tab) => tab.textContent === "Timelines",
|
|
),
|
|
);
|
|
await click(buttonNamed("Sunday morning"));
|
|
|
|
expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull();
|
|
expect(container.textContent).toContain("This timeline has no slots");
|
|
});
|
|
|
|
it("reports a provider whose renderer the platform did not publish", async () => {
|
|
const host = createFakeHost({
|
|
// The platform published nothing against Signage's renderer capability — the contributing
|
|
// module is absent, disabled, or outside the compatible range.
|
|
contributions: [],
|
|
permissions: ["signage.view"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "providers",
|
|
respond: () =>
|
|
jsonResponse({
|
|
providers: [
|
|
{
|
|
consecutiveFailures: 0,
|
|
contractVersion: "1.0.0",
|
|
defaultDurationMs: 8000,
|
|
lastFailureAt: null,
|
|
lastFailureCode: null,
|
|
lastSuccessAt: "2026-08-30T15:00:00.000Z",
|
|
providerId: "fixture.announcements",
|
|
providerVersion: "1.0.0",
|
|
registeredAt: "2026-08-30T14:00:00.000Z",
|
|
renderer: {
|
|
contributionId: "fixture.announcements.slide",
|
|
contributionVersion: "1.0.0",
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find(
|
|
(tab) => tab.textContent === "Providers",
|
|
),
|
|
);
|
|
|
|
expect(container.textContent).toContain("Renderer not published");
|
|
// Which is a fact only the browser holds: the server cannot know what the host published.
|
|
});
|
|
|
|
it("says Signage works without providers rather than looking broken", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "providers",
|
|
respond: () => jsonResponse({ providers: [] }),
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find(
|
|
(tab) => tab.textContent === "Providers",
|
|
),
|
|
);
|
|
|
|
expect(container.textContent).toContain("No content providers registered");
|
|
expect(container.textContent).toContain("Signage works on its own with native images");
|
|
});
|
|
|
|
it("shows the display roster with online state and offline readiness", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view", "signage.displays.manage"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "viewers",
|
|
respond: () =>
|
|
jsonResponse({
|
|
viewers: [
|
|
{
|
|
cachedAssetCount: 12,
|
|
channelId: "chan-1",
|
|
deviceId: null,
|
|
id: "viewer-1",
|
|
lastError: {
|
|
atMs: Date.now(),
|
|
code: "SLIDE_DECODE_FAILED",
|
|
detail: null,
|
|
},
|
|
lastSeenAtMs: Date.now() - 4000,
|
|
name: "Lobby north",
|
|
offlineReady: true,
|
|
online: true,
|
|
reportedContributionId: null,
|
|
reportedProviderId: "fixture.announcements",
|
|
reportedSelectionId: "sel-1",
|
|
reportedTimelineId: "timeline-1",
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
|
|
);
|
|
|
|
expect(container.textContent).toContain("Online");
|
|
expect(container.textContent).toContain("12 cached");
|
|
const display = container.querySelector("[data-testid='signage-display-viewer-1']");
|
|
expect(display?.closest(".signage-channel")?.textContent).toContain("Default");
|
|
// A sanitized code, never a stack or a message a browser happened to have.
|
|
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
|
|
});
|
|
|
|
it("explains display registration and deletes a channel group only after confirmation", async () => {
|
|
const secondGroup = {
|
|
...GROUP,
|
|
channels: [{ id: "chan-2", isDefault: true, key: "default", name: "Default" }],
|
|
id: "group-2",
|
|
name: "Foyer",
|
|
playback: { ...GROUP.playback, groupId: "group-2" },
|
|
};
|
|
let currentGroups = [GROUP, secondGroup];
|
|
const deleted: string[] = [];
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view", "signage.displays.manage"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: currentGroups }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "viewers",
|
|
respond: () => jsonResponse({ viewers: [] }),
|
|
},
|
|
{
|
|
method: "DELETE",
|
|
path: "channel-groups/group-1",
|
|
respond: ({ path }) => {
|
|
deleted.push(path);
|
|
currentGroups = [secondGroup];
|
|
return new Response(null, { status: 204 });
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find(
|
|
(candidate) => candidate.textContent === "Displays",
|
|
),
|
|
);
|
|
|
|
const setupLink = [...container.querySelectorAll("a")].find(
|
|
(candidate) => candidate.textContent?.trim() === "Preview here",
|
|
);
|
|
expect(setupLink?.href).toBe(DISPLAY_SETUP_URL);
|
|
expect(setupLink?.target).toBe("_blank");
|
|
expect(setupLink?.rel).toContain("noopener");
|
|
await click(buttonNamed("Copy setup address"));
|
|
expect(copiedText).toEqual([DISPLAY_SETUP_URL]);
|
|
expect(container.textContent).toContain("Display setup address copied.");
|
|
expect(container.textContent).toContain("Channel groups and displays");
|
|
// Plain language for the audience concept, and no internal key on the page by default.
|
|
expect(container.textContent).toContain("Audiences");
|
|
expect(container.textContent).toContain("Images look the same for every audience.");
|
|
expect(container.textContent).toContain("Audience name");
|
|
expect(container.textContent).not.toContain("Audience key");
|
|
expect(container.querySelector("details.signage-advanced")?.hasAttribute("open")).toBe(false);
|
|
await click(buttonNamed("Delete group"));
|
|
expect(deleted).toEqual([]);
|
|
expect(container.textContent).toContain("Displays assigned here will move");
|
|
await click(buttonNamed("Confirm delete"));
|
|
expect(deleted).toEqual(["channel-groups/group-1"]);
|
|
});
|
|
|
|
it("uploads one request per file and reports each one's own outcome", async () => {
|
|
const uploaded: string[] = [];
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view", "signage.assets.manage"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "images",
|
|
respond: () => jsonResponse({ images: [] }),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: /^images\?/u,
|
|
respond: ({ path }) => {
|
|
uploaded.push(path);
|
|
return path.includes("broken")
|
|
? errorResponse(
|
|
"SIGNAGE_IMAGE_MALFORMED",
|
|
"The uploaded file is not a decodable image.",
|
|
422,
|
|
)
|
|
: jsonResponse({ deduplicated: false, image: {} }, 201);
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
|
|
);
|
|
|
|
const input = container.querySelector(
|
|
"[data-testid='signage-upload-input']",
|
|
) as HTMLInputElement;
|
|
// happy-dom's `FileList` is read-only, so the queue is driven through the same change handler a
|
|
// real picker fires, with file-shaped values.
|
|
Object.defineProperty(input, "files", {
|
|
configurable: true,
|
|
value: [
|
|
{ name: "welcome.png", size: 1024, type: "image/png" },
|
|
{ name: "broken.png", size: 1024, type: "image/png" },
|
|
],
|
|
});
|
|
await act(async () => {
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
for (let index = 0; index < 10; index += 1) {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
// Two files, two requests — each with its own name in the query string and the bytes as the body.
|
|
expect(uploaded).toHaveLength(2);
|
|
expect(uploaded.every((path) => path.startsWith("images?"))).toBe(true);
|
|
const uploads = container.querySelector("[data-testid='signage-uploads']");
|
|
expect(uploads?.textContent).toContain("done");
|
|
expect(uploads?.textContent).toContain("failed");
|
|
expect(uploads?.textContent).toContain("The uploaded file is not a decodable image.");
|
|
});
|
|
|
|
it("stays on screen when one panel's data cannot be loaded", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.view"],
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "settings",
|
|
respond: () => jsonResponse(FAKE_SETTINGS),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channel-groups",
|
|
respond: () => jsonResponse({ groups: [GROUP] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "timelines",
|
|
respond: () => jsonResponse({ timelines: [] }),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "playback/group-1",
|
|
respond: () =>
|
|
errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500),
|
|
},
|
|
],
|
|
});
|
|
|
|
await render(dashboard(host.context));
|
|
|
|
expect(container.textContent).toContain("Signage could not load this");
|
|
expect(container.textContent).toContain("The operation could not be completed.");
|
|
// The rest of the dashboard is still usable: one failing panel is not a failed screen.
|
|
expect(container.querySelectorAll("[role='tab']").length).toBe(5);
|
|
});
|
|
});
|