295 lines
10 KiB
TypeScript
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",
|
|
});
|
|
}
|