feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
ModuleContributionDescriptor,
|
||||
ModuleUiApiClient,
|
||||
ModuleUiContributions,
|
||||
ModuleUiHostContext,
|
||||
MountedModuleContribution,
|
||||
MountModuleContributionOptions,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
|
||||
/**
|
||||
* A host, as a module's artifact actually experiences one.
|
||||
*
|
||||
* Requests are matched on the **relative** path a module asks for, which is what proves the artifact
|
||||
* never builds `/api/v1/modules/signage/...`: a route table keyed on Ekklesia's private prefix would
|
||||
* simply never match.
|
||||
*/
|
||||
export interface FakeHostRoute {
|
||||
readonly method: string;
|
||||
readonly path: string | RegExp;
|
||||
readonly respond: (request: {
|
||||
readonly path: string;
|
||||
readonly init: RequestInit | undefined;
|
||||
}) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
export interface FakeHost {
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly requests: { method: string; path: string }[];
|
||||
/** Contributions this host published against Signage's renderer capability. */
|
||||
readonly mounted: { contributionId: string; input: unknown }[];
|
||||
}
|
||||
|
||||
export function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
export function errorResponse(code: string, message: string, status = 400): Response {
|
||||
return jsonResponse({ code, message }, status);
|
||||
}
|
||||
|
||||
export interface FakeHostOptions {
|
||||
readonly permissions: readonly string[];
|
||||
readonly routes: readonly FakeHostRoute[];
|
||||
readonly contributions?: readonly ModuleContributionDescriptor[];
|
||||
readonly principal?: ModuleUiHostContext["principal"];
|
||||
readonly mount?: (
|
||||
options: MountModuleContributionOptions,
|
||||
) => Promise<MountedModuleContribution> | MountedModuleContribution;
|
||||
}
|
||||
|
||||
export function createFakeHost(options: FakeHostOptions): FakeHost {
|
||||
const requests: { method: string; path: string }[] = [];
|
||||
const mounted: { contributionId: string; input: unknown }[] = [];
|
||||
|
||||
const api: ModuleUiApiClient = {
|
||||
async request(path, init) {
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const normalized = path.replace(/^\//u, "");
|
||||
requests.push({ method, path: normalized });
|
||||
for (const route of options.routes) {
|
||||
const matches =
|
||||
typeof route.path === "string"
|
||||
? route.path === normalized.split("?")[0]
|
||||
: route.path.test(normalized);
|
||||
if (route.method === method && matches) {
|
||||
return route.respond({ init, path: normalized });
|
||||
}
|
||||
}
|
||||
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
|
||||
},
|
||||
};
|
||||
|
||||
const contributions: ModuleUiContributions = {
|
||||
list: (capabilityId) =>
|
||||
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
|
||||
async mount(mountOptions) {
|
||||
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input });
|
||||
if (options.mount) {
|
||||
return options.mount(mountOptions);
|
||||
}
|
||||
return {
|
||||
contributionId: mountOptions.contributionId,
|
||||
mounted: true,
|
||||
unmount: () => Promise.resolve(),
|
||||
update: () => Promise.resolve(),
|
||||
version: "1.0.0",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
context: {
|
||||
api,
|
||||
contributions,
|
||||
moduleId: "signage",
|
||||
permissions: { has: (permission) => options.permissions.includes(permission) },
|
||||
principal: options.principal ?? { kind: "anonymous" },
|
||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||
},
|
||||
mounted,
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
/** The settings payload every screen loads first. */
|
||||
export const FAKE_SETTINGS = Object.freeze({
|
||||
crossfadeMs: 1000,
|
||||
moduleVersion: "0.1.0",
|
||||
nativeImageDurationMs: 7000,
|
||||
nativePresentation: {
|
||||
backgroundColor: "#000000",
|
||||
backgroundDarkened: false,
|
||||
backgroundMode: "blurred",
|
||||
blurPixels: 20,
|
||||
},
|
||||
rendererInputVersion: "1.0.0",
|
||||
timezone: "America/Chicago",
|
||||
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 },
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { SignageSlideRendererInput } from "../../../src/shared/renderer-input.js";
|
||||
|
||||
/**
|
||||
* A **test-only** browser renderer, standing in for the contribution a real content module will
|
||||
* publish.
|
||||
*
|
||||
* It is not an installed module and has no manifest: it lives under `tests/`, and the only way it
|
||||
* reaches a screen is a test handing it to the stage the way the platform's contribution loader
|
||||
* would. It is built with the platform's own `defineModuleContribution`, so what it proves is the
|
||||
* actual contract rather than a convenient approximation.
|
||||
*
|
||||
* It deliberately does everything a demanding real renderer would:
|
||||
*
|
||||
* - resolves `mount` only when it is genuinely ready, so the stage's fade waits for it;
|
||||
* - presents **internal multiple stages** — a question, then its answer — inside one Signage slide,
|
||||
* which Signage neither knows about nor times;
|
||||
* - renders time-based text from the input's clock anchor rather than the device's clock;
|
||||
* - renders differently per audience;
|
||||
* - updates in place, without remounting;
|
||||
* - unmounts deterministically, releasing its timer and its React root.
|
||||
*
|
||||
* It receives no API client, no realtime, no principal, no storage, and no credential — because the
|
||||
* contract gives a renderer none of those. Everything it draws comes from the consumer's input.
|
||||
*/
|
||||
|
||||
export interface TriviaPayload {
|
||||
readonly question?: unknown;
|
||||
readonly answer?: unknown;
|
||||
readonly revealAfterMs?: unknown;
|
||||
}
|
||||
|
||||
function TriviaSlide({ input }: { readonly input: SignageSlideRendererInput }): ReactElement {
|
||||
const payload = input.payload as TriviaPayload;
|
||||
const question = typeof payload.question === "string" ? payload.question : "…";
|
||||
const answer = typeof payload.answer === "string" ? payload.answer : "";
|
||||
const revealAfterMs = typeof payload.revealAfterMs === "number" ? payload.revealAfterMs : 2000;
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
// The authoritative now, computed from both clocks. A television's own clock is routinely wrong;
|
||||
// this is what makes a countdown on two screens agree.
|
||||
const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs);
|
||||
const remainingSeconds = Math.max(0, Math.ceil((input.timing.deadlineAtMs - serverNow) / 1000));
|
||||
|
||||
// `generation` is the dependency that matters and the linter cannot see why: it changes when the
|
||||
// slide's content changes, which is exactly when this stage should restart — and it does *not*
|
||||
// change when the clock is re-anchored on reconnect, which is exactly when it should not.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the generation token is the point.
|
||||
useEffect(() => {
|
||||
if (input.timing.paused) {
|
||||
return;
|
||||
}
|
||||
// Internal multi-stage behaviour: Signage sees one slide with one deadline, and this decides
|
||||
// what happens *within* it. It cannot extend that deadline and does not try.
|
||||
setRevealed(false);
|
||||
const handle = setTimeout(() => setRevealed(true), revealAfterMs);
|
||||
return () => clearTimeout(handle);
|
||||
}, [input.generation, input.timing.paused, revealAfterMs]);
|
||||
|
||||
return (
|
||||
<section className="fixture-trivia" data-testid="fixture-trivia">
|
||||
<h1 data-testid="fixture-trivia-question">{question}</h1>
|
||||
{revealed ? <p data-testid="fixture-trivia-answer">{answer}</p> : undefined}
|
||||
<p data-testid="fixture-trivia-audience">{input.audienceKey}</p>
|
||||
<p data-testid="fixture-trivia-remaining">{`${remainingSeconds}s`}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lets a test decide exactly when the renderer becomes ready, so "the stage waits for readiness"
|
||||
* is a property that can be observed rather than assumed. */
|
||||
export interface RendererReadiness {
|
||||
readonly whenReady: Promise<void>;
|
||||
ready(): void;
|
||||
}
|
||||
|
||||
export function createReadiness(): RendererReadiness {
|
||||
let settle: (() => void) | undefined;
|
||||
const whenReady = new Promise<void>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return {
|
||||
ready(): void {
|
||||
settle?.();
|
||||
},
|
||||
whenReady,
|
||||
};
|
||||
}
|
||||
|
||||
export interface FixtureRendererCounters {
|
||||
mounts: number;
|
||||
updates: number;
|
||||
unmounts: number;
|
||||
}
|
||||
|
||||
export function createFixtureRenderer(options: {
|
||||
readonly readiness?: RendererReadiness;
|
||||
readonly counters?: FixtureRendererCounters;
|
||||
}) {
|
||||
const counters = options.counters ?? { mounts: 0, unmounts: 0, updates: 0 };
|
||||
|
||||
const definition = defineModuleContribution({
|
||||
id: "fixture.trivia.slide",
|
||||
version: "1.0.0",
|
||||
async mount(container, context) {
|
||||
counters.mounts += 1;
|
||||
const root: Root = createRoot(container as Element);
|
||||
root.render(<TriviaSlide input={context.input as unknown as SignageSlideRendererInput} />);
|
||||
// Asynchronous by contract: a renderer that has to decode media, measure a layout, or wait for
|
||||
// a font resolves only when it is honestly ready, and the stage's fade waits for it.
|
||||
if (options.readiness) {
|
||||
await options.readiness.whenReady;
|
||||
}
|
||||
return {
|
||||
unmount() {
|
||||
counters.unmounts += 1;
|
||||
root.unmount();
|
||||
},
|
||||
update(input) {
|
||||
counters.updates += 1;
|
||||
root.render(<TriviaSlide input={input as unknown as SignageSlideRendererInput} />);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return { counters, definition };
|
||||
}
|
||||
Reference in New Issue
Block a user