Files
Signage/tests/browser/contributed-renderer.test.tsx
T
2026-08-28 16:41:54 -05:00

367 lines
11 KiB
TypeScript

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