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 { 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 { 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 { 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 { 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); }); });