import type { SignageJsonValue, SignageProviderDiscoveryRequest, SignageProviderItemDescriptor, SignageProviderRefreshRequest, SignageProviderRegistration, SignageProviderResolution, SignageProviderResolveRequest, SignageProviderSourceDescriptor, } from "../../src/server/providers/contract.js"; /** * A **test-only** content provider, standing in for the real modules that do not exist yet. * * It is not an installed module, it has no manifest, and nothing about it is reachable from a * running Signage: it lives under `tests/`, and the only way it enters the system is a test calling * the same `register` a real provider module would call from its own `start` hook. * * The point is to prove the seam with something that behaves like the three real providers will: * an announcements-shaped provider that cycles continuously, and trivia- and scripture-shaped * providers that refuse to repeat until their pool is exhausted. **Signage implements none of those * policies** — that is the whole demonstration. */ export interface FixtureItem { readonly id: string; readonly label: string; /** Payload for the default audience. */ readonly payload: Record; /** Payload for one specific audience, when this item has one. */ readonly audiencePayloads?: Readonly>>; readonly availableUntil?: string | null; readonly durationMs?: number; } export type FixtureRepeatPolicy = /** Announcements: cycle the active set forever, in order, wrapping when it runs out. */ | "cycle" /** Trivia and Scripture: never repeat until every currently available item has appeared. */ | "no-repeat-until-exhausted"; export interface FixtureProviderOptions { readonly providerId: string; readonly sourceId: string; readonly sourceLabel: string; readonly items: readonly FixtureItem[]; readonly policy: FixtureRepeatPolicy; readonly defaultDurationMs?: number; readonly contractVersion?: string; readonly rendererVersion?: string; } export interface FixtureProvider { readonly registration: SignageProviderRegistration; /** Every item id this provider has handed out, in order, across every scope. */ readonly served: string[]; /** Every `resolveNext` request it received, so a test can prove what Signage supplied. */ readonly resolveRequests: SignageProviderResolveRequest[]; readonly refreshRequests: SignageProviderRefreshRequest[]; /** Replace the available set at run time — an announcement expiring, a question pool refilling. */ setItems(items: readonly FixtureItem[]): void; /** Make the next `n` calls fail the way a briefly unavailable provider does. */ failNext(count: number): void; /** Make every call hang, so the deadline is what ends it. */ hang(hanging: boolean): void; /** Declare one item explicitly gone. */ withdraw(itemId: string): void; /** Change an item's payload without changing which item it is — a cancellation, a countdown. */ reviseItem(itemId: string, payload: Record): void; } export function createFixtureProvider(options: FixtureProviderOptions): FixtureProvider { let items = [...options.items]; const revisions = new Map(); const withdrawn = new Set(); /** Per selection scope: which items have already been served in the current pass. This is the * provider's own state, exactly where the contract says it belongs — Signage stores none of it. */ const seen = new Map>(); const cursors = new Map(); const served: string[] = []; const resolveRequests: SignageProviderResolveRequest[] = []; const refreshRequests: SignageProviderRefreshRequest[] = []; let failures = 0; let hanging = false; function guard(): void { if (failures > 0) { failures -= 1; throw new Error("fixture provider is briefly unavailable"); } } function available(at: string): readonly FixtureItem[] { const instant = Date.parse(at); return items.filter((item) => { if (withdrawn.has(item.id)) { return false; } if (typeof item.availableUntil !== "string") { return true; } return instant < Date.parse(item.availableUntil); }); } function revisionOf(item: FixtureItem): string { return String(revisions.get(item.id) ?? 1); } function resolutionFor(item: FixtureItem): SignageProviderResolution { const variants = [ { audienceKey: null, payload: item.payload }, ...Object.entries(item.audiencePayloads ?? {}).map(([audienceKey, payload]) => ({ audienceKey, payload, })), ]; return { availableUntil: item.availableUntil ?? null, itemId: item.id, itemRevision: revisionOf(item), variants, ...(item.durationMs === undefined ? {} : { durationMs: item.durationMs }), }; } function pickNext(scope: string, at: string): FixtureItem | null { const pool = available(at); if (pool.length === 0) { return null; } if (options.policy === "cycle") { const index = cursors.get(scope) ?? 0; const item = pool[index % pool.length] as FixtureItem; cursors.set(scope, index + 1); return item; } // No repeat until exhausted: only when every currently available item has appeared does the // pass reset. An item added mid-pass joins the current pass rather than waiting for the next. let usedInPass = seen.get(scope); if (!usedInPass) { usedInPass = new Set(); seen.set(scope, usedInPass); } let remaining = pool.filter((item) => !usedInPass.has(item.id)); if (remaining.length === 0) { usedInPass.clear(); remaining = [...pool]; } const item = remaining[0] as FixtureItem; usedInPass.add(item.id); return item; } const registration: SignageProviderRegistration = { contractVersion: options.contractVersion ?? "1.0.0", defaultDurationMs: options.defaultDurationMs ?? 5000, listItems(request: SignageProviderDiscoveryRequest): readonly SignageProviderItemDescriptor[] { guard(); return available(request.at).map((item) => ({ id: item.id, label: item.label, ...(item.availableUntil === undefined ? {} : { availableUntil: item.availableUntil }), })); }, listSources(): readonly SignageProviderSourceDescriptor[] { guard(); return [{ id: options.sourceId, label: options.sourceLabel }]; }, providerId: options.providerId, providerVersion: "1.0.0", async refresh( request: SignageProviderRefreshRequest, ): Promise { refreshRequests.push(request); if (hanging) { await new Promise(() => undefined); } guard(); if (withdrawn.has(request.itemId)) { // Explicitly gone — distinct from "nothing right now", and Signage removes it at the next // safe boundary rather than mid-slide. return { itemId: request.itemId, itemRevision: revisionOf({ id: request.itemId } as FixtureItem), variants: [{ audienceKey: null, payload: { withdrawn: true } }], withdrawn: true, }; } const item = available(request.at).find((candidate) => candidate.id === request.itemId); return item ? resolutionFor(item) : null; }, renderer: { contributionId: `${options.providerId}.slide`, contributionVersion: options.rendererVersion ?? "1.0.0", }, async resolveNext( request: SignageProviderResolveRequest, ): Promise { resolveRequests.push(request); if (hanging) { await new Promise(() => undefined); } guard(); if (request.sourceId !== options.sourceId) { return null; } const item = pickNext(request.selectionScopeId, request.at); if (!item) { return null; } served.push(item.id); return resolutionFor(item); }, }; return { failNext(count: number): void { failures = count; }, hang(value: boolean): void { hanging = value; }, refreshRequests, registration, resolveRequests, reviseItem(itemId: string, payload: Record): void { items = items.map((item) => (item.id === itemId ? { ...item, payload } : item)); revisions.set(itemId, (revisions.get(itemId) ?? 1) + 1); }, served, setItems(next: readonly FixtureItem[]): void { items = [...next]; }, withdraw(itemId: string): void { withdrawn.add(itemId); }, }; } /** The three provider shapes the real modules will have, so a test can name them the way an operator * would. Nothing about their policies lives in Signage. */ export function announcementsFixture(): FixtureProvider { return createFixtureProvider({ items: [ { id: "welcome", label: "Welcome", payload: { headline: "Welcome" } }, { audiencePayloads: { online: { headline: "Watching from home" } }, id: "greeting", label: "Greeting", payload: { headline: "Good morning" }, }, { id: "giving", label: "Giving", payload: { headline: "Giving" } }, { id: "kids", label: "Kids check-in", payload: { headline: "Kids check-in" } }, ], policy: "cycle", providerId: "fixture.announcements", sourceId: "active-slides", sourceLabel: "Active announcements", }); } export function triviaFixture(): FixtureProvider { return createFixtureProvider({ items: [ { id: "q1", label: "Question 1", payload: { question: "one" } }, { id: "q2", label: "Question 2", payload: { question: "two" } }, { id: "q3", label: "Question 3", payload: { question: "three" } }, ], policy: "no-repeat-until-exhausted", providerId: "fixture.trivia", sourceId: "open-questions", sourceLabel: "Open questions", }); } export function scriptureFixture(): FixtureProvider { return createFixtureProvider({ items: [ { id: "psalm-23", label: "Psalm 23", payload: { reference: "Psalm 23" } }, { id: "john-3", label: "John 3", payload: { reference: "John 3" } }, ], policy: "no-repeat-until-exhausted", providerId: "fixture.scripture", sourceId: "reading-plan", sourceLabel: "Reading plan", }); }