907 lines
23 KiB
TypeScript
907 lines
23 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() === "Open display setup",
|
|
);
|
|
expect(setupLink?.href).toBe(DISPLAY_SETUP_URL);
|
|
expect(setupLink?.target).toBe("_blank");
|
|
expect(setupLink?.rel).toContain("noopener");
|
|
await click(buttonNamed("Copy URL"));
|
|
expect(copiedText).toEqual([DISPLAY_SETUP_URL]);
|
|
expect(container.textContent).toContain("Display setup URL copied.");
|
|
expect(container.textContent).toContain("Channel groups and displays");
|
|
expect(container.textContent).toContain("Audience channels");
|
|
expect(container.textContent).toContain("Channel name");
|
|
expect(container.textContent).toContain("Audience key");
|
|
expect(container.textContent).toContain(
|
|
"Advanced: lowercase ID sent to content providers",
|
|
);
|
|
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);
|
|
});
|
|
});
|