626 lines
23 KiB
TypeScript
626 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;
|
|
|
|
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(() => {
|
|
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(<SignageDashboard context={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(<SignageDashboard context={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(<SignageDashboard context={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(<SignageDashboard context={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(<SignageDashboard context={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(<SignageDashboard context={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(<SignageDashboard context={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(<SignageDashboard context={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(<SignageDashboard context={host.context} />);
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
|
|
);
|
|
|
|
expect(container.textContent).toContain("Online");
|
|
expect(container.textContent).toContain("12 cached");
|
|
// 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(<SignageDashboard context={host.context} />);
|
|
await click(
|
|
[...container.querySelectorAll("[role='tab']")].find(
|
|
(candidate) => candidate.textContent === "Displays",
|
|
),
|
|
);
|
|
|
|
expect(container.textContent).toContain("/display/signage");
|
|
expect(container.textContent).toContain("Provider audience key");
|
|
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(<SignageDashboard context={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(<SignageDashboard context={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);
|
|
});
|
|
});
|