367 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|