Files
Signage/tests/playback.test.ts
T
2026-08-28 16:41:54 -05:00

623 lines
26 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import {
createPlaybackService,
type PlaybackSnapshot,
type SignagePlaybackService,
} from "../src/server/playback/service.js";
import {
createProviderRegistry,
type SignageProviderRegistry,
} from "../src/server/providers/registry.js";
import type { StoredChannelGroupDetail, StoredTimelineDetail } from "../src/server/store/types.js";
import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js";
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
import {
announcementsFixture,
type FixtureProvider,
scriptureFixture,
triviaFixture,
} from "./support/fixture-provider.js";
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
const START = Date.parse("2026-08-30T15:00:00.000Z");
describe("authoritative playback", () => {
let store: MemorySignageStore;
let registry: SignageProviderRegistry;
let service: SignagePlaybackService;
let announcements: FixtureProvider;
let trivia: FixtureProvider;
let scripture: FixtureProvider;
let clock: number;
let counter: number;
let published: { snapshot: PlaybackSnapshot; reason: string }[];
/** Timers are captured rather than run: a test drives the clock, so nothing depends on real time
* and a reconciliation is never racing a test's assertions. */
let armed: { handler: () => void; delayMs: number } | undefined;
const newId = () => {
counter += 1;
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
};
beforeEach(() => {
clock = START;
counter = 0;
published = [];
armed = undefined;
store = createMemorySignageStore({ now: () => new Date(clock) });
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
announcements = announcementsFixture();
trivia = triviaFixture();
scripture = scriptureFixture();
registry.capability.register(announcements.registration);
registry.capability.register(trivia.registration);
registry.capability.register(scripture.registration);
service = createPlaybackService({
clearTimer: () => {
armed = undefined;
},
newId,
now: () => clock,
onSnapshot: (snapshot, reason) => published.push({ reason, snapshot }),
registry,
setTimer: (handler, delayMs) => {
armed = { delayMs, handler };
return armed;
},
settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 },
store,
});
});
/** Advances the clock and fires whatever timer was armed for that moment, the way a real one
* would. Nothing here reaches a real timer, so a long outage costs nothing but arithmetic. */
async function tick(ms: number): Promise<void> {
clock += ms;
const pending = armed;
armed = undefined;
if (pending) {
pending.handler();
// Let the reconciliation the timer started settle.
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function seedGroup(): Promise<StoredChannelGroupDetail> {
await service.start();
return service.ensureDefaultGroup();
}
/** The pattern from the product definition: three announcements, one trivia, two announcements,
* one scripture — the five announcement occurrences sharing one source. */
async function seedPattern(): Promise<StoredTimelineDetail> {
let detail = await createTimeline({ name: "Sunday morning", newId, store });
const timelineId = detail.timeline.id;
for (const [providerId, sourceId, label] of [
["fixture.announcements", "active-slides", "Announcements"],
["fixture.trivia", "open-questions", "Trivia"],
["fixture.scripture", "reading-plan", "Scripture"],
] as const) {
detail = await addProviderSource({ label, newId, providerId, sourceId, store, timelineId });
}
const [a, t, s] = detail.sources;
if (!a || !t || !s) {
throw new Error("Expected three sources.");
}
for (const source of [a, a, a, t, a, a, s]) {
detail = await addSlot({ newId, source: { sourceId: source.id }, store, timelineId });
}
return detail;
}
async function currentItem(groupId: string): Promise<string | null> {
const snapshot = await service.snapshot(groupId);
if (snapshot.currentSelectionId === null) {
return null;
}
const selection = await store.findSelection(snapshot.currentSelectionId);
return selection?.itemId ?? null;
}
it("creates exactly one default channel group with one default channel, and nothing else", async () => {
const group = await seedGroup();
expect(group.channels).toHaveLength(1);
expect(group.channels[0]).toMatchObject({ isDefault: true, key: "default" });
// No `in-person`, no `online`. Those are an administrator's decision, not an install-time one.
expect(group.channels.map((channel) => channel.key)).toEqual(["default"]);
// Calling again is idempotent: an installation has one default group, not one per call.
const again = await service.ensureDefaultGroup();
expect(again.group.id).toBe(group.group.id);
});
it("plays the operator's pattern, asking each provider once per slot occurrence", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const played: (string | null)[] = [await currentItem(group.group.id)];
for (let index = 0; index < 6; index += 1) {
await tick(5000);
played.push(await currentItem(group.group.id));
}
// Three announcements, one trivia, two announcements, one scripture — with the five
// announcement occurrences walking **one** rotation, because they share one selection scope.
expect(played).toEqual(["welcome", "greeting", "giving", "q1", "kids", "welcome", "psalm-23"]);
expect(announcements.served).toEqual(["welcome", "greeting", "giving", "kids", "welcome"]);
expect(trivia.served).toEqual(["q1"]);
expect(scripture.served).toEqual(["psalm-23"]);
});
it("resolves one logical selection for every channel in the group, and one variant each", async () => {
const group = await seedGroup();
await store.addChannel({
groupId: group.group.id,
id: newId(),
isDefault: false,
key: "online",
name: "Online",
});
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
// The second announcement is the one with an audience-specific variant.
await tick(5000);
const snapshot = await service.snapshot(group.group.id);
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
expect(selection?.itemId).toBe("greeting");
// Asked once — never once per channel — which is what keeps two channels on the same slide.
expect(
announcements.resolveRequests.filter((request) => request.audienceKeys.includes("online")),
).toHaveLength(announcements.resolveRequests.length);
expect(selection?.variants.map((variant) => variant.audienceKey).sort()).toEqual([
"default",
"online",
]);
expect(
selection?.variants.find((variant) => variant.audienceKey === "online")?.payload,
).toEqual({ headline: "Watching from home" });
// Both channels read the same start and the same deadline. There is one clock.
expect(snapshot.startedAtMs).not.toBeNull();
expect(snapshot.deadlineAtMs).toBe((snapshot.startedAtMs ?? 0) + 5000);
});
it("falls back to the default variant for an audience the provider said nothing about", async () => {
const group = await seedGroup();
await store.addChannel({
groupId: group.group.id,
id: newId(),
isDefault: false,
key: "in-person",
name: "In person",
});
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const snapshot = await service.snapshot(group.group.id);
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
// "welcome" has no audience-specific payload at all. That is the contract, not a gap.
expect(selection?.variants).toHaveLength(1);
expect(selection?.variants[0]).toMatchObject({ audienceKey: "default", isDefault: true });
});
it("chains each slot from the previous deadline, so a late timer costs nothing twice", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const first = await service.snapshot(group.group.id);
// The timer fires 900 ms late, as a busy process's timer does.
await tick(5900);
const second = await service.snapshot(group.group.id);
// The new slot starts at the old slot's deadline — not at "now" — so the lateness is absorbed
// once instead of stretching the loop by 900 ms on every slide for the rest of the day.
expect(second.startedAtMs).toBe(first.deadlineAtMs);
expect(second.deadlineAtMs).toBe((first.deadlineAtMs ?? 0) + 5000);
});
it("catches up a long outage with arithmetic, not by replaying every missed slide", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const callsBefore =
announcements.resolveRequests.length +
trivia.resolveRequests.length +
scripture.resolveRequests.length;
// A week offline. Seven slots at five seconds each is more than a hundred thousand slides.
clock += 7 * 24 * 60 * 60 * 1000;
await service.reconcile(group.group.id, "startup");
const callsAfter =
announcements.resolveRequests.length +
trivia.resolveRequests.length +
scripture.resolveRequests.length;
// Exactly one provider call: the slot actually landed on. A replay would have burned the
// providers' rotations and taken longer than the outage.
expect(callsAfter - callsBefore).toBe(1);
const snapshot = await service.snapshot(group.group.id);
expect(snapshot.currentSelectionId).not.toBeNull();
// And the slot it landed on is genuinely current rather than in the past.
expect(snapshot.deadlineAtMs).toBeGreaterThan(clock);
});
it("skips a slot whose provider has nothing right now, immediately and without an error", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
trivia.setItems([]);
await service.activate(group.group.id, timeline.timeline.id, "now");
await tick(5000);
await tick(5000);
// The fourth slot is Trivia, which has nothing. It is skipped in the same instant.
const snapshot = await service.snapshot(group.group.id);
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
await tick(5000);
const afterTrivia = await service.snapshot(group.group.id);
const next = await store.findSelection(afterTrivia.currentSelectionId ?? "");
expect(selection?.itemId).toBe("giving");
// Straight past Trivia to the next announcement. No gap, no black frame, no error.
expect(next?.itemId).toBe("kids");
expect(next?.startedAt?.getTime()).toBe(selection?.deadlineAt?.getTime());
expect(
registry.health().find((p) => p.providerId === "fixture.trivia")?.consecutiveFailures,
).toBe(0);
});
it("goes idle rather than inventing content when nothing at all is available", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
announcements.setItems([]);
trivia.setItems([]);
scripture.setItems([]);
await service.activate(group.group.id, timeline.timeline.id, "now");
const snapshot = await service.snapshot(group.group.id);
expect(snapshot.transport).toBe("idle");
expect(snapshot.currentSelectionId).toBeNull();
// The display shows its slate. Signage did not fabricate a slide to fill the gap.
});
it("freezes on pause and continues the same slide from its remainder on resume", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const item = await currentItem(group.group.id);
clock += 2000;
const paused = await service.pause(group.group.id);
expect(paused.transport).toBe("paused");
expect(paused.remainingMs).toBe(3000);
expect(paused.deadlineAtMs).toBeNull();
// Two minutes of a paused screen must not advance anything.
clock += 120_000;
expect(await currentItem(group.group.id)).toBe(item);
const resumed = await service.resume(group.group.id);
expect(resumed.transport).toBe("playing");
expect(await currentItem(group.group.id)).toBe(item);
// Three seconds left, and the reconstructed window is still a whole slot long.
expect(resumed.deadlineAtMs).toBe(clock + 3000);
expect((resumed.deadlineAtMs ?? 0) - (resumed.startedAtMs ?? 0)).toBe(5000);
});
it("holds a static image while preserving the cursor underneath, and continues on release", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const item = await currentItem(group.group.id);
const imageId = newId();
store.images.set(imageId, {
contentSha256: "a".repeat(64),
createdAt: new Date(clock),
displayName: "Hold",
encodedHeight: 1080,
encodedWidth: 1920,
expiresAt: null,
hasAlpha: false,
height: 1080,
id: imageId,
orientation: null,
originalByteSize: 1,
originalContentType: "image/png",
originalFilename: "hold.png",
originalObjectKey: "assets/hold/original.png",
renditions: [],
status: "ready",
width: 1920,
});
clock += 1000;
const held = await service.setStaticHold(group.group.id, imageId);
expect(held.transport).toBe("static");
expect(held.staticAssetId).toBe(imageId);
// The normal cursor is preserved, not discarded.
expect(held.remainingMs).toBe(4000);
expect(held.currentSelectionId).not.toBeNull();
clock += 60_000;
const released = await service.setStaticHold(group.group.id, null);
expect(released.transport).toBe("playing");
// The same slide continues with the remainder it had, rather than the loop restarting.
expect(await currentItem(group.group.id)).toBe(item);
expect(released.deadlineAtMs).toBe(clock + 4000);
});
it("keeps the hidden timeline running through a blackout", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const dark = await service.setBlackout(group.group.id, true);
expect(dark.blackout).toBe(true);
// Blackout covers output. It does not pause, reset, or stop resolving.
expect(dark.transport).toBe("playing");
expect(dark.deadlineAtMs).not.toBeNull();
await tick(5000);
const later = await service.snapshot(group.group.id);
expect(later.blackout).toBe(true);
expect(await currentItem(group.group.id)).toBe("greeting");
const lit = await service.setBlackout(group.group.id, false);
// Clearing it lands exactly where the hidden timeline had got to.
expect(lit.blackout).toBe(false);
expect(await currentItem(group.group.id)).toBe("greeting");
});
it("walks actual resolved playback with previous, including past a skipped slot", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
trivia.setItems([]);
await service.activate(group.group.id, timeline.timeline.id, "now");
await tick(5000);
await tick(5000);
await tick(5000);
expect(await currentItem(group.group.id)).toBe("kids");
const back = await service.previous(group.group.id);
// Trivia was never resolved, so it is not in history. "Previous" means the slide that was
// actually on screen, which is the third announcement.
expect(await currentItem(group.group.id)).toBe("giving");
expect(back.deadlineAtMs).toBe(clock + 5000);
await service.previous(group.group.id);
// And again, further back — not oscillating between two slides.
expect(await currentItem(group.group.id)).toBe("greeting");
});
it("ends the current slot immediately on next, and still applies eligibility and wraparound", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
clock += 1000;
const skipped = await service.next(group.group.id);
expect(await currentItem(group.group.id)).toBe("greeting");
expect(skipped.startedAtMs).toBe(clock);
expect(skipped.deadlineAtMs).toBe(clock + 5000);
});
it("keeps the last known good item and marks it stale when a provider fails", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
announcements.failNext(5);
await service.reload(group.group.id);
const after = await service.snapshot(group.group.id);
const selection = await store.findSelection(after.currentSelectionId ?? "");
// The slide stays up, and the deadline is untouched: a failure is not a reason to blank a wall.
expect(after.currentSelectionId).toBe(before.currentSelectionId);
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
expect(selection?.stale).toBe(true);
expect(selection?.itemId).toBe("welcome");
expect(await currentItem(group.group.id)).toBe("welcome");
});
it("updates the current slide in place when a provider invalidates it, without restarting the slot", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
const beforeSelection = await store.findSelection(before.currentSelectionId ?? "");
announcements.reviseItem("welcome", { headline: "Cancelled" });
clock += 1500;
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
const after = await service.snapshot(group.group.id);
const afterSelection = await store.findSelection(after.currentSelectionId ?? "");
// Same slide, same slot, same deadline — new payload and a new revision, which is exactly what
// updates a mounted React renderer in place.
expect(after.currentSelectionId).toBe(before.currentSelectionId);
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
expect(after.selectionRevision).toBe((before.selectionRevision ?? 0) + 1);
expect(afterSelection?.variants[0]?.payload).toEqual({ headline: "Cancelled" });
expect(beforeSelection?.variants[0]?.payload).toEqual({ headline: "Welcome" });
});
it("does not bump a revision when an invalidation changed nothing", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
const after = await service.snapshot(group.group.id);
// A client polling on revision must not be woken by nothing.
expect(after.selectionRevision).toBe(before.selectionRevision);
});
it("marks an explicitly withdrawn item and removes it at the next boundary, not mid-slide", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
announcements.withdraw("welcome");
clock += 1000;
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
const during = await service.snapshot(group.group.id);
const selection = await store.findSelection(during.currentSelectionId ?? "");
// Still on screen, still on its own deadline — marked, not cut.
expect(during.currentSelectionId).toBe(before.currentSelectionId);
expect(during.deadlineAtMs).toBe(before.deadlineAtMs);
expect(selection?.withdrawn).toBe(true);
await tick(4000);
// Gone at the boundary — and *which* item follows is the provider's decision, made from a pool
// that no longer contains the withdrawn one. Signage neither chose it nor knows why.
const next = await currentItem(group.group.id);
expect(next).not.toBe("welcome");
expect(["greeting", "giving", "kids"]).toContain(next);
});
it("activates immediately, or at the next slot boundary, exactly as asked", async () => {
const group = await seedGroup();
const first = await seedPattern();
const second = await createTimeline({ name: "Evening", newId, store });
const withSource = await addProviderSource({
label: "Scripture",
newId,
providerId: "fixture.scripture",
sourceId: "reading-plan",
store,
timelineId: second.timeline.id,
});
const source = withSource.sources[0];
if (!source) {
throw new Error("Expected a source.");
}
await addSlot({
newId,
source: { sourceId: source.id },
store,
timelineId: second.timeline.id,
});
await service.activate(group.group.id, first.timeline.id, "now");
expect(await currentItem(group.group.id)).toBe("welcome");
clock += 1000;
const queued = await service.activate(group.group.id, second.timeline.id, "next-boundary");
// Queued, and the current slide is not cut short.
expect(queued.pendingTimelineId).toBe(second.timeline.id);
expect(queued.activeTimelineId).toBe(first.timeline.id);
expect(await currentItem(group.group.id)).toBe("welcome");
await tick(4000);
const switched = await service.snapshot(group.group.id);
expect(switched.activeTimelineId).toBe(second.timeline.id);
expect(switched.pendingTimelineId).toBeNull();
expect(await currentItem(group.group.id)).toBe("psalm-23");
});
it("applies a scheduled activation at its own instant, not when the timer happened to fire", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
const at = new Date(clock + 60_000);
const scheduled = await service.activate(group.group.id, timeline.timeline.id, { at });
expect(scheduled.scheduledTimelineId).toBe(timeline.timeline.id);
expect(scheduled.activeTimelineId).toBeNull();
// The callback arrives two seconds late, as a real one does.
clock += 62_000;
const after = await service.reconcile(group.group.id, "timer");
expect(after.activeTimelineId).toBe(timeline.timeline.id);
expect(after.scheduledTimelineId).toBeNull();
expect(await currentItem(group.group.id)).toBe("welcome");
});
it("refuses a scheduled activation in the past rather than firing it instantly", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await expect(
service.activate(group.group.id, timeline.timeline.id, { at: new Date(clock - 1000) }),
).rejects.toMatchObject({ code: "SIGNAGE_INVALID_REQUEST" });
});
it("keeps history bounded and still lets previous work", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
for (let index = 0; index < 30; index += 1) {
await tick(5000);
}
const history = await store.listSelections(group.group.id, 100);
expect(history.length).toBeLessThanOrEqual(20);
const back = await service.previous(group.group.id);
expect(back.currentSelectionId).not.toBeNull();
});
it("recovers a restart onto the item that was already showing, not a new one", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
const callsBefore = announcements.resolveRequests.length;
// A restart two seconds into a five-second slide: reconciliation runs with the persisted state.
clock += 2000;
const after = await service.reconcile(group.group.id, "startup");
expect(after.currentSelectionId).toBe(before.currentSelectionId);
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
// And no provider was asked, so no rotation was consumed by a restart.
expect(announcements.resolveRequests.length).toBe(callsBefore);
});
it("publishes a compact snapshot, with no payload or timeline in it", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const latest = published.at(-1);
expect(latest?.reason).toBe("activated");
const serialized = JSON.stringify(latest?.snapshot);
// Compact enough for the platform's realtime ceiling many times over, and carrying identity and
// revisions rather than content: large state is fetched over authorized HTTP keyed by revision.
expect(serialized.length).toBeLessThan(1024);
expect(serialized).not.toContain("Welcome");
expect(serialized).not.toContain("headline");
});
it("serializes commands so two at once cannot interleave", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const [first, second] = await Promise.all([
service.next(group.group.id),
service.next(group.group.id),
]);
// Two "next" presses land on two different slides, in order — never on the same one, and never
// on a state neither command asked for.
expect(first.currentSelectionId).not.toBe(second.currentSelectionId);
expect(second.revision).toBeGreaterThan(first.revision);
});
});