413 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|