Files
Signage/tests/browser/scene.test.ts
2026-08-28 16:41:54 -05:00

413 lines
13 KiB
TypeScript

// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import type { SceneRequest } from "../../src/browser/scene.js";
import { createSceneStage, type SceneStage } from "../../src/browser/scene.js";
import { rendererInputFor, sceneRequestFor } from "../../src/browser/scene-request.js";
/**
* The transition, exercised rather than asserted.
*
* Every collaborator is injected — the decode, the timer, the contribution mount — so what these
* prove is the sequence itself: readiness is awaited, the outgoing layer is never faded, a stale
* completion is discarded, and a failure leaves the last good frame exactly where it was.
*/
const SCENE = {
backgroundColor: "#000000",
backgroundImage: false,
backgroundMode: "none" as const,
blurPixels: 0,
darkenOpacity: 0,
fit: "fill" as const,
shadow: false,
};
function image(key: string, url = `https://cdn.invalid/${key}.webp`): SceneRequest {
return { alt: "", key, kind: "native-image", scene: SCENE, url };
}
describe("scene stage", () => {
let root: HTMLElement;
let decodes: { url: string; resolve: () => void; reject: (error: Error) => void }[];
let stage: SceneStage;
function layers(): HTMLElement[] {
return [...root.querySelectorAll(".signage-layer")] as HTMLElement[];
}
function opacities(): string[] {
return layers().map((layer) => layer.style.opacity);
}
async function settle(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function resolveDecodes(): void {
while (decodes.length > 0) {
decodes.shift()?.resolve();
}
}
beforeEach(() => {
root = document.createElement("div");
document.body.append(root);
decodes = [];
stage = createSceneStage({
crossfadeMs: 1000,
decodeImage: (element) =>
new Promise<void>((resolve, reject) => {
decodes.push({
reject,
resolve,
url: element.getAttribute("src") ?? "",
});
}),
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
});
it("creates exactly two layers, once, and never rebuilds them", async () => {
const before = layers();
expect(before).toHaveLength(2);
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const second = stage.show(image("b"));
await settle();
resolveDecodes();
await second;
// The same two elements. A renderer that rebuilt them per slide is the `v1` defect.
expect(layers()[0]).toBe(before[0]);
expect(layers()[1]).toBe(before[1]);
});
it("waits for a real decode before anything becomes visible", async () => {
const shown = stage.show(image("a"));
await settle();
// The image is in the DOM and the decode has not resolved: nothing is visible yet.
expect(decodes).toHaveLength(1);
expect(opacities()).toEqual(["0", "0"]);
resolveDecodes();
await shown;
expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1);
});
it("never fades the outgoing layer, so the black root cannot show through", async () => {
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const outgoing = layers().find((layer) => layer.style.opacity === "1") as HTMLElement;
const second = stage.show(image("b"));
await settle();
// The incoming layer is prepared; the outgoing one is still fully opaque, underneath.
expect(outgoing.style.opacity).toBe("1");
resolveDecodes();
await second;
// Only after the fade completes does the old layer go to zero — and it is now behind, and empty.
expect(outgoing.style.opacity).toBe("0");
expect(outgoing.style.zIndex).toBe("1");
});
it("keeps the last good frame when incoming content cannot be decoded", async () => {
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const goodKey = stage.currentKey();
const failing = stage.show(image("broken"));
await settle();
decodes.shift()?.reject(new Error("decode failed"));
const outcome = await failing;
expect(outcome).toEqual({ code: "SLIDE_DECODE_FAILED", kind: "failed" });
// The wall still shows what it showed. A broken slide costs one slide.
expect(stage.currentKey()).toBe(goodKey);
expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1);
});
it("discards completion from a superseded transition", async () => {
const first = stage.show(image("a"));
await settle();
resolveDecodes();
await first;
const slow = stage.show(image("b"));
await settle();
const fast = stage.show(image("c"));
await settle();
resolveDecodes();
const [slowOutcome, fastOutcome] = await Promise.all([slow, fast]);
// The newer transition owns the layers; the older one changes nothing.
expect(slowOutcome.kind).toBe("superseded");
expect(fastOutcome.kind).toBe("shown");
expect(stage.currentKey()).toBe("c");
});
it("updates a mounted renderer in place rather than remounting it", async () => {
const updates: unknown[] = [];
let mounts = 0;
const contributionStage = createSceneStage({
crossfadeMs: 0,
mountContribution: () => {
mounts += 1;
return Promise.resolve({
unmount: () => undefined,
update: (input: unknown) => {
updates.push(input);
},
});
},
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
const input = rendererInputFor(
{
availableUntilMs: null,
deadlineAtMs: 5000,
durationMs: 5000,
id: "sel-1",
itemId: "advent",
itemRevision: "1",
payload: { headline: "Sunday" },
presentation: null,
providerId: "fixture.announcements",
renderer: {
contributionId: "fixture.announcements.slide",
contributionVersion: "1.0.0",
kind: "contribution",
},
revision: 1,
slotId: "slot-1",
startedAtMs: 0,
withdrawn: false,
},
{ audienceKey: "default", localNowMs: 0, paused: false, remainingMs: null, serverNowMs: 0 },
);
const request: SceneRequest = {
contributionId: "fixture.announcements.slide",
input,
key: "sel-1:1:default:r",
kind: "contribution",
};
await contributionStage.show(request);
const updated = await contributionStage.show({
...request,
input: { ...input, payload: { headline: "Cancelled" } },
});
// Same key, new input: one mount, one update, no transition — which is what lets a long-running
// screen change content without restarting animations or reloading media.
expect(updated).toEqual({ kind: "updated" });
expect(mounts).toBe(1);
expect(updates).toHaveLength(1);
await contributionStage.destroy();
});
it("keeps the last good frame when a renderer refuses to mount", async () => {
const contributionStage = createSceneStage({
crossfadeMs: 0,
mountContribution: () => Promise.reject(new Error("renderer exploded")),
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
const outcome = await contributionStage.show({
contributionId: "fixture.announcements.slide",
input: {} as never,
key: "sel-9",
kind: "contribution",
});
expect(outcome).toEqual({ code: "RENDERER_MOUNT_FAILED", kind: "failed" });
expect(contributionStage.currentKey()).toBeUndefined();
await contributionStage.destroy();
});
it("unmounts a renderer deterministically when its layer is reused", async () => {
let unmounts = 0;
const contributionStage = createSceneStage({
crossfadeMs: 0,
mountContribution: () =>
Promise.resolve({
unmount: () => {
unmounts += 1;
},
}),
root,
// Fires immediately, so a crossfade completes without the test waiting real time. The
// ordering the transition depends on is unaffected.
setTimer: (handler) => {
handler();
return undefined;
},
});
await contributionStage.show({
contributionId: "a.slide",
input: {} as never,
key: "one",
kind: "contribution",
});
await contributionStage.show({
contributionId: "a.slide",
input: {} as never,
key: "two",
kind: "contribution",
});
await contributionStage.show({
contributionId: "a.slide",
input: {} as never,
key: "three",
kind: "contribution",
});
await contributionStage.destroy();
// Three mounts, three unmounts: repeated mount and unmount leaks nothing.
expect(unmounts).toBe(3);
});
it("reports blackout and slate as states of the root, so one slate implementation renders both", () => {
stage.setBlackout(true);
expect(root.dataset.blackout).toBe("true");
stage.setBlackout(false);
expect(root.dataset.blackout).toBe("false");
stage.setSlate(true);
expect(root.dataset.slate).toBe("true");
});
});
describe("scene requests", () => {
const base = {
availableUntilMs: null,
deadlineAtMs: 5000,
durationMs: 5000,
id: "sel-1",
itemId: "welcome",
itemRevision: "abc",
payload: { headline: "Welcome" },
presentation: null,
providerId: "fixture.announcements",
revision: 1,
slotId: "slot-1",
startedAtMs: 0,
withdrawn: false,
};
const context = {
audienceKey: "default",
localNowMs: 1000,
paused: false,
remainingMs: null,
serverNowMs: 9000,
};
it("gives a renderer both clocks, so a television with a wrong clock still counts correctly", () => {
const input = rendererInputFor(
{
...base,
renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" },
},
context,
);
expect(input.clock).toEqual({ localNowMs: 1000, serverNowMs: 9000 });
expect(input.timing).toMatchObject({ deadlineAtMs: 5000, paused: false, startedAtMs: 0 });
expect(input.contract).toBe("signage.slide-renderer");
// Everything the renderer is given, and nothing else: no API client, no principal, no
// credential, no URL.
expect(Object.keys(input).sort()).toEqual([
"audienceKey",
"clock",
"contract",
"generation",
"itemId",
"itemRevision",
"payload",
"presentation",
"providerId",
"timing",
"version",
]);
});
it("changes the key when the payload revision or pause state changes, and not when the clock does", () => {
const selection = {
...base,
renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" },
} as const;
const first = rendererInputFor(selection, context).generation;
const reAnchored = rendererInputFor(selection, {
...context,
localNowMs: 99_999,
serverNowMs: 99_999,
}).generation;
const revised = rendererInputFor({ ...selection, revision: 2 }, context).generation;
const paused = rendererInputFor(selection, { ...context, paused: true }).generation;
// A reconnect re-anchors the clock. Remounting a renderer for that would restart every
// animation on the wall for no reason.
expect(reAnchored).toBe(first);
expect(revised).not.toBe(first);
expect(paused).not.toBe(first);
});
it("declines to build a scene for a renderer the platform did not publish", () => {
const request = sceneRequestFor(
{
...base,
renderer: {
contributionId: "missing.slide",
contributionVersion: "1.0.0",
kind: "contribution",
},
},
context,
{ canMountContribution: () => false },
);
// Ordinary rather than exceptional: the contributing module is absent, disabled, or outside
// Signage's compatible range, and the caller keeps its last good frame.
expect(request).toBeUndefined();
});
it("declines to build a native scene with no delivery URL", () => {
const request = sceneRequestFor({ ...base, renderer: { kind: "native-image" } }, context, {
canMountContribution: () => true,
});
expect(request).toBeUndefined();
});
});