472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js";
|
|
import {
|
|
createDisplayController,
|
|
type DisplayChannelStateView,
|
|
type DisplayController,
|
|
} from "../../src/display/controller.js";
|
|
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
|
|
|
|
/**
|
|
* The display, driven end to end against a host: register, fetch state, render, report.
|
|
*
|
|
* Every collaborator is injected, so what these prove is the display's own decisions — which frame
|
|
* it keeps when something fails, what it caches and under what key, what it tells the server, and
|
|
* what it never puts on a wall.
|
|
*/
|
|
|
|
const NATIVE_SCENE = {
|
|
backgroundColor: "#000000",
|
|
backgroundImage: false,
|
|
backgroundMode: "none" as const,
|
|
blurPixels: 0,
|
|
darkenOpacity: 0,
|
|
fit: "fill" as const,
|
|
shadow: false,
|
|
};
|
|
|
|
function nativeSelection(id: string, itemRevision = "rev-1") {
|
|
return {
|
|
availableUntilMs: null as number | null,
|
|
deadlineAtMs: 5000,
|
|
durationMs: 5000,
|
|
id,
|
|
itemId: `image-${id}`,
|
|
itemRevision,
|
|
media: {
|
|
display: {
|
|
expiresAtMs: 900_000,
|
|
height: 1080,
|
|
url: `https://cdn.invalid/${id}?sig=a`,
|
|
width: 1920,
|
|
},
|
|
thumbnail: {
|
|
expiresAtMs: 900_000,
|
|
height: 270,
|
|
url: `https://cdn.invalid/${id}-t?sig=a`,
|
|
width: 480,
|
|
},
|
|
},
|
|
payload: { kind: "native-image" },
|
|
presentation: null,
|
|
providerId: "signage.native-images",
|
|
renderer: { kind: "native-image" as const },
|
|
revision: 1,
|
|
scene: NATIVE_SCENE,
|
|
slotId: "slot-1",
|
|
stale: false,
|
|
startedAtMs: 0,
|
|
withdrawn: false,
|
|
};
|
|
}
|
|
|
|
function contributionSelection(id: string, revision = 1) {
|
|
return {
|
|
availableUntilMs: null,
|
|
deadlineAtMs: 5000,
|
|
durationMs: 5000,
|
|
id,
|
|
itemId: "advent",
|
|
itemRevision: String(revision),
|
|
payload: { headline: revision === 1 ? "Sunday, 5pm" : "Cancelled" },
|
|
presentation: null,
|
|
providerId: "fixture.announcements",
|
|
renderer: {
|
|
contributionId: "fixture.announcements.slide",
|
|
contributionVersion: "1.0.0",
|
|
kind: "contribution" as const,
|
|
},
|
|
revision,
|
|
slotId: "slot-1",
|
|
stale: false,
|
|
startedAtMs: 0,
|
|
withdrawn: false,
|
|
};
|
|
}
|
|
|
|
function state(overrides: Partial<DisplayChannelStateView> = {}): DisplayChannelStateView {
|
|
// `??` would fall through on an explicit `null`, which is exactly the case these tests care about.
|
|
const current = "current" in overrides ? overrides.current : nativeSelection("sel-1");
|
|
return {
|
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
|
crossfadeMs: 0,
|
|
current,
|
|
playback: {
|
|
activeTimelineId: "timeline-1" as string | null,
|
|
blackout: false,
|
|
currentSelectionId: current?.id ?? null,
|
|
currentSlotId: current?.slotId ?? null,
|
|
deadlineAtMs: 5000 as number | null,
|
|
pendingTimelineId: null as string | null,
|
|
remainingMs: 5000 as number | null,
|
|
revision: 3,
|
|
selectionRevision: current?.revision ?? null,
|
|
startedAtMs: 0 as number | null,
|
|
transport: "playing",
|
|
...overrides.playback,
|
|
},
|
|
selections: overrides.selections ?? (current ? [current] : []),
|
|
serverNowMs: 1000,
|
|
staticHold: overrides.staticHold ?? null,
|
|
timeline: overrides.timeline ?? null,
|
|
...(overrides.channel ? { channel: overrides.channel } : {}),
|
|
};
|
|
}
|
|
|
|
const SESSION = {
|
|
channelId: "chan-1",
|
|
channelKey: "default",
|
|
groupId: "group-1",
|
|
heartbeatIntervalMs: 12_000,
|
|
viewerId: "11111111-1111-4111-8111-111111111111",
|
|
};
|
|
|
|
describe("display controller", () => {
|
|
let root: HTMLElement;
|
|
|
|
beforeEach(() => {
|
|
root = document.createElement("div");
|
|
document.body.append(root);
|
|
});
|
|
|
|
async function settle(): Promise<void> {
|
|
for (let index = 0; index < 10; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
|
|
function build(options: {
|
|
readonly host: ReturnType<typeof createFakeHost>;
|
|
readonly media?: ReturnType<typeof createMemoryMediaCache>;
|
|
readonly store?: ReturnType<typeof createMemoryStore>;
|
|
readonly telemetry?: boolean;
|
|
}): DisplayController {
|
|
return createDisplayController({
|
|
context: options.host.context,
|
|
decodeImage: () => Promise.resolve(),
|
|
media: options.media ?? createMemoryMediaCache(),
|
|
newViewerKey: () => "viewer-fixed-key-0001",
|
|
root,
|
|
// No real timers: the refresh schedule is exercised by calling `apply` directly.
|
|
setTimer: () => undefined,
|
|
store: options.store ?? createMemoryStore(),
|
|
...(options.telemetry === undefined ? {} : { telemetry: options.telemetry }),
|
|
});
|
|
}
|
|
|
|
it("registers with a persistent identity and keeps it across restarts", async () => {
|
|
const store = createMemoryStore();
|
|
const keys: unknown[] = [];
|
|
const host = createFakeHost({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{
|
|
method: "POST",
|
|
path: "viewers/session",
|
|
respond: ({ init }) => {
|
|
keys.push(JSON.parse(String(init?.body)));
|
|
return jsonResponse(SESSION);
|
|
},
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channels/chan-1/display-state",
|
|
respond: () => jsonResponse(state()),
|
|
},
|
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
|
],
|
|
});
|
|
|
|
const first = build({ host, store });
|
|
await first.start();
|
|
await settle();
|
|
await first.stop();
|
|
|
|
const second = build({ host, store });
|
|
await second.start();
|
|
await settle();
|
|
await second.stop();
|
|
|
|
// The same key both times: a television that reboots is the same screen, not a new one.
|
|
expect(keys).toEqual([
|
|
{ viewerKey: "viewer-fixed-key-0001" },
|
|
{ viewerKey: "viewer-fixed-key-0001" },
|
|
]);
|
|
});
|
|
|
|
it("renders the server's current selection and reports what it is showing", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
|
{
|
|
method: "GET",
|
|
path: "channels/chan-1/display-state",
|
|
respond: () => jsonResponse(state()),
|
|
},
|
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
|
],
|
|
});
|
|
const controller = build({ host });
|
|
|
|
await controller.start();
|
|
await settle();
|
|
|
|
const diagnostics = controller.diagnostics();
|
|
expect(diagnostics.renderedSelectionId).toBe("sel-1");
|
|
expect(diagnostics.slate).toBe(false);
|
|
expect(root.querySelectorAll(".signage-layer")).toHaveLength(2);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("caches image bytes under a stable key, so a freshly signed URL finds them again", async () => {
|
|
const media = createMemoryMediaCache();
|
|
const stored: string[] = [];
|
|
const wrapped = {
|
|
...media,
|
|
store: (key: string, url: string) => {
|
|
stored.push(key);
|
|
return media.store(key, url);
|
|
},
|
|
};
|
|
const host = createFakeHost({ permissions: [], routes: [] });
|
|
const controller = build({ host, media: wrapped, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state());
|
|
// The same bytes, a new signature — exactly what the next refresh carries.
|
|
const resigned = nativeSelection("sel-1");
|
|
Object.assign(resigned.media.display, { url: "https://cdn.invalid/sel-1?sig=b" });
|
|
await controller.apply(state({ current: resigned }));
|
|
await settle();
|
|
|
|
// One store, because the key is built from provider, item, and item revision — never from the
|
|
// URL, which changes every time.
|
|
expect(stored).toEqual(["signage-media/signage.native-images/image-sel-1/rev-1/display"]);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("updates a mounted renderer in place when only the payload changed", async () => {
|
|
const mounts: unknown[] = [];
|
|
const updates: unknown[] = [];
|
|
const host = createFakeHost({
|
|
contributions: [
|
|
{
|
|
capability: "signage.slide-renderer",
|
|
id: "fixture.announcements.slide",
|
|
version: "1.0.0",
|
|
},
|
|
],
|
|
mount: (options) => {
|
|
mounts.push(options.contributionId);
|
|
return {
|
|
contributionId: options.contributionId,
|
|
mounted: true,
|
|
unmount: () => Promise.resolve(),
|
|
update: (input) => {
|
|
updates.push(input);
|
|
return Promise.resolve();
|
|
},
|
|
version: "1.0.0",
|
|
};
|
|
},
|
|
permissions: [],
|
|
routes: [],
|
|
});
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state({ current: contributionSelection("sel-1", 1) }));
|
|
await controller.apply(state({ current: contributionSelection("sel-1", 2) }));
|
|
await settle();
|
|
|
|
// One mount, one update: a cancelled event changes the slide without restarting its slot.
|
|
expect(mounts).toEqual(["fixture.announcements.slide"]);
|
|
expect(updates).toHaveLength(1);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("keeps the last good frame and records a code when a renderer is not published", async () => {
|
|
const host = createFakeHost({ contributions: [], permissions: [], routes: [] });
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state());
|
|
const good = controller.diagnostics().renderedSelectionId;
|
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
|
await settle();
|
|
|
|
const diagnostics = controller.diagnostics();
|
|
// Nothing changed on screen, and the reason is a bounded code — never a message on a wall.
|
|
expect(diagnostics.renderedSelectionId).toBe(good);
|
|
expect(diagnostics.lastError?.code).toBe("SLIDE_UNRENDERABLE");
|
|
expect(diagnostics.unusableSelectionIds).toEqual(["sel-2"]);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("does not retry a selection it already knows it cannot render", async () => {
|
|
let mountAttempts = 0;
|
|
const host = createFakeHost({
|
|
contributions: [
|
|
{
|
|
capability: "signage.slide-renderer",
|
|
id: "fixture.announcements.slide",
|
|
version: "1.0.0",
|
|
},
|
|
],
|
|
mount: () => {
|
|
mountAttempts += 1;
|
|
return Promise.reject(new Error("renderer exploded"));
|
|
},
|
|
permissions: [],
|
|
routes: [],
|
|
});
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
|
await settle();
|
|
|
|
// One attempt. A broken item never becomes a tight retry loop on an unattended screen.
|
|
expect(mountAttempts).toBe(1);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("shows the slate only when it has nothing at all, and never over a good frame", async () => {
|
|
const host = createFakeHost({ permissions: [], routes: [] });
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state({ current: null, selections: [] }));
|
|
await settle();
|
|
expect(root.dataset.slate).toBe("true");
|
|
expect(controller.diagnostics().slate).toBe(true);
|
|
|
|
await controller.apply(state());
|
|
await settle();
|
|
expect(root.dataset.slate).toBe("false");
|
|
|
|
// Content disappears again — but a good frame is up, so the wall keeps it rather than flashing
|
|
// the slate at a congregation.
|
|
await controller.apply(state({ current: null, selections: [] }));
|
|
await settle();
|
|
expect(root.dataset.slate).toBe("false");
|
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
|
await controller.stop();
|
|
});
|
|
|
|
it("covers the output for a blackout while the frame underneath stays prepared", async () => {
|
|
const host = createFakeHost({ permissions: [], routes: [] });
|
|
const controller = build({ host, telemetry: false });
|
|
await controller.start();
|
|
|
|
await controller.apply(state());
|
|
await controller.apply(state({ playback: { blackout: true } as never }));
|
|
await settle();
|
|
|
|
expect(root.dataset.blackout).toBe("true");
|
|
// The hidden timeline is still what it was: ending the blackout shows this frame, not a slate.
|
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
|
await controller.stop();
|
|
});
|
|
|
|
it("reports a bounded, sanitized heartbeat and nothing else", async () => {
|
|
const beats: unknown[] = [];
|
|
const host = createFakeHost({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
|
{
|
|
method: "GET",
|
|
path: "channels/chan-1/display-state",
|
|
respond: () => jsonResponse(state()),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: /^viewers\/.*\/heartbeat$/u,
|
|
respond: ({ init }) => {
|
|
beats.push(JSON.parse(String(init?.body)));
|
|
return jsonResponse({ acknowledged: true, recorded: true });
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const controller = build({ host });
|
|
|
|
await controller.start();
|
|
await settle();
|
|
|
|
expect(beats.length).toBeGreaterThan(0);
|
|
const beat = beats[0] as Record<string, unknown>;
|
|
expect(Object.keys(beat).sort()).toEqual([
|
|
"cachedAssetCount",
|
|
"error",
|
|
"offlineReady",
|
|
"reportedContributionId",
|
|
"reportedPlaybackRevision",
|
|
"reportedProviderId",
|
|
"reportedSelectionId",
|
|
"reportedTimelineId",
|
|
]);
|
|
// Identity, counts, and a code. No URL, no payload, no exception, no credential.
|
|
const serialized = JSON.stringify(beat);
|
|
expect(serialized).not.toContain("https://");
|
|
expect(serialized).not.toContain("payload");
|
|
await controller.stop();
|
|
});
|
|
|
|
it("keeps the frame when the server cannot be reached, and says so quietly", async () => {
|
|
const host = createFakeHost({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
|
{
|
|
method: "GET",
|
|
path: "channels/chan-1/display-state",
|
|
respond: () => jsonResponse(state()),
|
|
},
|
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
|
],
|
|
});
|
|
const controller = build({ host });
|
|
await controller.start();
|
|
await settle();
|
|
const before = controller.diagnostics().renderedSelectionId;
|
|
|
|
// Every request now fails, exactly as a dropped uplink does.
|
|
Object.assign(host.context.api, {
|
|
request: () => Promise.reject(new Error("network is gone")),
|
|
});
|
|
await controller.apply(state());
|
|
await settle();
|
|
|
|
expect(controller.diagnostics().renderedSelectionId).toBe(before);
|
|
// Nothing about the failure is on screen: the slate is for having no content, not no network.
|
|
expect(root.dataset.slate).toBe("false");
|
|
await controller.stop();
|
|
});
|
|
|
|
it("registers no viewer and sends no heartbeat when telemetry is off", async () => {
|
|
const host = createFakeHost({
|
|
permissions: [],
|
|
routes: [
|
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
|
{ method: "POST", path: /^viewers\//u, respond: () => jsonResponse({}) },
|
|
],
|
|
});
|
|
const controller = build({ host, telemetry: false });
|
|
|
|
await controller.start();
|
|
await controller.apply(state());
|
|
await settle();
|
|
|
|
// The management preview renders exactly what a wall renders and appears in no roster.
|
|
expect(host.requests).toEqual([]);
|
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
|
await controller.stop();
|
|
});
|
|
});
|