feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
// @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,
|
||||
};
|
||||
|
||||
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 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("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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user