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 (

{question}

{revealed ?

{answer}

: undefined}

{input.audienceKey}

{`${remainingSeconds}s`}

); } /** 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; ready(): void; } export function createReadiness(): RendererReadiness { let settle: (() => void) | undefined; const whenReady = new Promise((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(); // 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(); }, }; }, }); return { counters, definition }; }