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