Files
Signage/tests/support/fixture-provider.ts
T
2026-08-28 16:41:54 -05:00

295 lines
10 KiB
TypeScript

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<string, SignageJsonValue>;
/** Payload for one specific audience, when this item has one. */
readonly audiencePayloads?: Readonly<Record<string, Record<string, SignageJsonValue>>>;
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<string, SignageJsonValue>): void;
}
export function createFixtureProvider(options: FixtureProviderOptions): FixtureProvider {
let items = [...options.items];
const revisions = new Map<string, number>();
const withdrawn = new Set<string>();
/** 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<string, Set<string>>();
const cursors = new Map<string, number>();
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<string>();
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<SignageProviderResolution | null> {
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<SignageProviderResolution | null> {
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<string, SignageJsonValue>): 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",
});
}