// @vitest-environment happy-dom import { act } from "react"; import { beforeEach, describe, expect, it } from "vitest"; import { createSceneStage, type SceneStage } from "../../src/browser/scene.js"; import { rendererInputFor } from "../../src/browser/scene-request.js"; import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js"; import { createDisplayController, type DisplayChannelStateView, } from "../../src/display/controller.js"; import { createFakeHost } from "./support/fake-host.js"; import { createFixtureRenderer, createReadiness, type FixtureRendererCounters, } from "./support/fixture-renderer.js"; /** * A provider's live React renderer, on a Signage slide. * * The renderer here is test-only and is built with the platform's own `defineModuleContribution`, so * what these prove is the real contract: Signage hands it identity, timing, and an opaque payload, * mounts it through the host, waits for it to say it is ready, updates it in place, and takes it * down deterministically — and never learns anything about what it draws. */ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const SELECTION = { availableUntilMs: null, deadlineAtMs: 20_000, durationMs: 20_000, id: "sel-trivia", itemId: "question-1", itemRevision: "1", payload: { answer: "Bethlehem", question: "Where was Jesus born?", revealAfterMs: 2000 }, presentation: null, providerId: "fixture.trivia", renderer: { contributionId: "fixture.trivia.slide", contributionVersion: "1.0.0", kind: "contribution" as const, }, revision: 1, slotId: "slot-trivia", startedAtMs: 0, withdrawn: false, }; const CONTEXT = { audienceKey: "default", localNowMs: 1_000_000, paused: false, remainingMs: null, serverNowMs: 5000, }; async function settle(): Promise { for (let index = 0; index < 10; index += 1) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } } describe("a provider's contributed renderer", () => { let root: HTMLElement; let counters: FixtureRendererCounters; let stage: SceneStage; function mountFixture(readiness?: ReturnType) { const fixture = createFixtureRenderer({ counters, ...(readiness ? { readiness } : {}) }); return createSceneStage({ crossfadeMs: 0, mountContribution: async ({ container, input }) => (await fixture.definition.mount(container, { contributionId: fixture.definition.id, input: input as never, })) ?? {}, root, setTimer: (handler) => { handler(); return undefined; }, }); } beforeEach(() => { root = document.createElement("div"); document.body.append(root); counters = { mounts: 0, unmounts: 0, updates: 0 }; }); it("is not shown until it says it is ready", async () => { const readiness = createReadiness(); stage = mountFixture(readiness); const showing = stage.show({ contributionId: "fixture.trivia.slide", input: rendererInputFor(SELECTION, CONTEXT), key: "sel-trivia:default", kind: "contribution", }); await settle(); // Mounted, rendering, and still invisible: the stage waits for the renderer's own promise. expect(counters.mounts).toBe(1); const layers = [...root.querySelectorAll(".signage-layer")] as HTMLElement[]; expect(layers.map((layer) => layer.style.opacity)).toEqual(["0", "0"]); readiness.ready(); await act(async () => { await showing; }); expect(layers.filter((layer) => layer.style.opacity === "1")).toHaveLength(1); }); it("runs its own multi-stage presentation inside one Signage slide", async () => { stage = mountFixture(); await act(async () => { await stage.show({ contributionId: "fixture.trivia.slide", input: rendererInputFor(SELECTION, CONTEXT), key: "sel-trivia:default", kind: "contribution", }); }); await settle(); expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe( "Where was Jesus born?", ); // The answer has not been revealed yet: that is the renderer's own timing, inside one slot whose // deadline Signage set and the renderer cannot extend. expect(root.querySelector("[data-testid='fixture-trivia-answer']")).toBeNull(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 2100)); }); expect(root.querySelector("[data-testid='fixture-trivia-answer']")?.textContent).toBe( "Bethlehem", ); // Still one mount, one slide. Signage never learned that anything happened. expect(counters.mounts).toBe(1); expect(counters.updates).toBe(0); }); it("computes its countdown from the clock anchor, not the device's clock", async () => { stage = mountFixture(); await act(async () => { await stage.show({ contributionId: "fixture.trivia.slide", input: rendererInputFor(SELECTION, { ...CONTEXT, // A television whose clock is a year out. The anchor is what makes this right. localNowMs: Date.now(), serverNowMs: 5000, }), key: "sel-trivia:default", kind: "contribution", }); }); await settle(); // Deadline 20s, server now 5s: fifteen seconds left, whatever the device believes. expect(root.querySelector("[data-testid='fixture-trivia-remaining']")?.textContent).toBe("15s"); }); it("renders a different audience differently, from one logical selection", async () => { stage = mountFixture(); await act(async () => { await stage.show({ contributionId: "fixture.trivia.slide", input: rendererInputFor(SELECTION, { ...CONTEXT, audienceKey: "online" }), key: "sel-trivia:online", kind: "contribution", }); }); await settle(); expect(root.querySelector("[data-testid='fixture-trivia-audience']")?.textContent).toBe( "online", ); }); it("takes new content in place, without remounting or restarting its stage", async () => { stage = mountFixture(); await act(async () => { await stage.show({ contributionId: "fixture.trivia.slide", input: rendererInputFor(SELECTION, CONTEXT), key: "sel-trivia:default", kind: "contribution", }); }); await settle(); await act(async () => { await stage.show({ contributionId: "fixture.trivia.slide", input: rendererInputFor( { ...SELECTION, payload: { answer: "Bethlehem", question: "Cancelled", revealAfterMs: 2000 }, revision: 2, }, CONTEXT, ), key: "sel-trivia:default", kind: "contribution", }); }); await settle(); expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe( "Cancelled", ); expect(counters.mounts).toBe(1); expect(counters.updates).toBe(1); expect(counters.unmounts).toBe(0); }); it("mounts and unmounts repeatedly without leaking", async () => { stage = mountFixture(); for (let index = 0; index < 4; index += 1) { await act(async () => { await stage.show({ contributionId: "fixture.trivia.slide", input: rendererInputFor({ ...SELECTION, id: `sel-${index}` }, CONTEXT), key: `sel-${index}:default`, kind: "contribution", }); }); await settle(); } await act(async () => { await stage.destroy(); }); // Four mounts, four unmounts, and nothing left in the DOM. expect(counters.mounts).toBe(4); expect(counters.unmounts).toBe(4); expect(root.querySelector("[data-testid='fixture-trivia']")).toBeNull(); }); it("keeps working from cached state with no network at all", async () => { const store = createMemoryStore(); const media = createMemoryMediaCache(); const fixture = createFixtureRenderer({ counters }); const host = createFakeHost({ contributions: [ { capability: "signage.slide-renderer", id: "fixture.trivia.slide", version: "1.0.0" }, ], mount: async (options) => { const instance = await fixture.definition.mount(options.container, { contributionId: options.contributionId, input: options.input, }); return { contributionId: options.contributionId, mounted: true, unmount: async () => { await instance?.unmount?.(); }, update: async (input) => { await instance?.update?.(input); }, version: "1.0.0", }; }, permissions: [], routes: [], }); const state: DisplayChannelStateView = { channel: { groupId: "group-1", id: "chan-1", key: "default" }, crossfadeMs: 0, current: SELECTION, playback: { activeTimelineId: "timeline-1", blackout: false, currentSelectionId: SELECTION.id, currentSlotId: SELECTION.slotId, deadlineAtMs: 20_000, pendingTimelineId: null, remainingMs: 20_000, revision: 1, selectionRevision: 1, startedAtMs: 0, transport: "playing", }, selections: [SELECTION], serverNowMs: 5000, staticHold: null, timeline: { eligibleFromMs: null, eligibleUntilMs: null, id: "timeline-1", revision: 1, slots: [ { eligibleFromMs: null, eligibleUntilMs: null, enabled: true, estimatedDurationMs: 20_000, id: "slot-trivia", position: 0, }, ], }, }; // One successful online state, persisted. const online = createDisplayController({ context: host.context, decodeImage: () => Promise.resolve(), media, now: () => 1_000_000, root, setTimer: () => undefined, store, telemetry: false, }); await act(async () => { await online.start(); await online.apply(state); }); await settle(); await act(async () => { await online.stop(); }); // A fresh controller with the same local storage and no server at all. const offlineRoot = document.createElement("div"); document.body.append(offlineRoot); const offline = createDisplayController({ context: host.context, decodeImage: () => Promise.resolve(), media, now: () => 1_000_000, root: offlineRoot, setTimer: () => undefined, store, telemetry: false, }); await act(async () => { await offline.start(); }); await settle(); // The renderer is on screen, from its cached artifact and this display's cached payload — with // no request made and nothing invented. expect(offlineRoot.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe( "Where was Jesus born?", ); expect(host.requests).toEqual([]); expect(offline.diagnostics().offline).toBe(true); await act(async () => { await offline.stop(); }); }); });