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