// @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 { 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((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(); }); });