feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user