import { Children, createElement, isValidElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { CardActions, NominationCard } from '../src/components/calltoplay/NominationCard.tsx'; import { type CallToPlayActions } from '../src/hooks/useCallToPlay.ts'; import { reduceCallToPlayEvents } from '../src/lib/callToPlay.ts'; import { type CallToPlayViewEvent, type Game, GameAvailability, InstallStatus, type Nomination, } from '../src/lib/types.ts'; function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } const assertEquals = (actual: T, expected: T, message: string): void => { if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`); }; const game: Game = { id: 'game-1', name: 'Test game', description: '', size: 0, downloaded: true, installed: true, availability: GameAvailability.Ready, install_status: InstallStatus.Installed, peer_count: 0, }; const unexpectedAction = (): never => { throw new Error('Rendering a card must not publish an action or launch a game'); }; const actions: CallToPlayActions = { createNomination: unexpectedAction, respond: unexpectedAction, rsvp: unexpectedAction, sendMessage: unexpectedAction, leave: unexpectedAction, cancel: unexpectedAction, startNow: unexpectedAction, addTime: unexpectedAction, }; const call = ( now: number, players = 1, scheduledFor: number | null = null, deadline = scheduledFor ?? now + 10 * 60_000, ): Nomination => { const events: CallToPlayViewEvent[] = [{ id: 'create', call_id: 'call-1', author_id: 'Alice', author_name: 'Alice', at: now - 60_000, action: { Create: { game_id: game.id, max_players: 8, scheduled_for: scheduledFor, deadline, }, }, }]; for (let index = 1; index < players; index += 1) { events.push({ id: `join-${index}`, call_id: 'call-1', author_id: `Player-${index}`, author_name: `Player ${index}`, at: now - 30_000 + index, action: scheduledFor === null ? { Respond: { ready_at: null } } : 'Rsvp', }); } const [nomination] = reduceCallToPlayEvents(events, now); assert(nomination, 'the call must be visible'); return nomination; }; const renderCard = ( nomination: Nomination, actorId: string | null = 'Alice', localGame: Game | null = game, ): string => renderToStaticMarkup(createElement(NominationCard, { nomination, game: localGame, actorId, actions, focused: false, totalPeerCount: 5, onLaunch: unexpectedAction, })); const buttonNamed = (markup: string, label: string): string | undefined => markup.match(/]*>.*?<\/button>/gs)?.find(button => button.replace(/<[^>]*>/g, '').trim() === label ); const assertStartAvailable = (markup: string, label = 'Start now'): void => { const button = buttonNamed(markup, label); assert(button !== undefined, `${label} must be rendered`); assert(!/\bdisabled\b/.test(button), `${label} must be enabled`); }; Deno.test('caller can start an eight-player call early with six ready players', () => { const nomination = call(Date.now(), 6); assert(nomination.state === 'open', 'six players must not fill an eight-player call'); assertStartAvailable(renderCard(nomination)); }); Deno.test('caller can start alone before the call deadline', () => { assertStartAvailable(renderCard(call(Date.now()))); }); Deno.test('caller can start a scheduled call before check-in opens', () => { const now = Date.now(); const markup = renderCard(call(now, 1, now + 60 * 60_000)); assertStartAvailable(markup); assert(markup.includes('check-in opens 15 min before start'), 'schedule guidance remains'); }); Deno.test('caller can start during check-in and can still mark themselves ready', () => { const now = Date.now(); const markup = renderCard(call(now, 1, now + 10 * 60_000)); assertStartAvailable(markup); assert(buttonNamed(markup, 'Ready now'), 'caller check-in controls remain'); }); Deno.test('full and elapsed calls keep start and extension controls', () => { const now = Date.now(); for (const nomination of [call(now, 8), call(now, 1, null, now - 1_000)]) { const markup = renderCard(nomination); assertStartAvailable(markup); assert(buttonNamed(markup, 'Add 5 more minutes'), 'extension remains available'); } }); Deno.test('participants and outsiders cannot start a call in any active phase', () => { const now = Date.now(); const nominations = [ call(now, 6), call(now, 6, now + 60 * 60_000), call(now, 6, now + 10 * 60_000), call(now, 8), call(now, 6, null, now - 1_000), ]; for (const nomination of nominations) { for (const actorId of ['Player-1', 'Outsider', null]) { const markup = renderCard(nomination, actorId); assert(!buttonNamed(markup, 'Start now'), 'only the caller can start'); assert(!buttonNamed(markup, 'Add 5 more minutes'), 'only the caller can extend'); } } }); Deno.test('running and cancelled calls cannot be started again', () => { const now = Date.now(); for (const state of ['running', 'cancelled'] as const) { const nomination = { ...call(now, 6), state, terminalAt: now }; for (const actorId of ['Alice', 'Player-1']) { const markup = renderCard(nomination, actorId); assert(!buttonNamed(markup, 'Start now'), 'terminal calls cannot restart'); assert(!buttonNamed(markup, 'Add 5 more minutes'), 'terminal calls cannot extend'); assert(!buttonNamed(markup, 'Cancel this call'), 'terminal calls cannot cancel'); } } }); Deno.test('caller can mark an underfilled call running when the game is unavailable locally', () => { assertStartAvailable(renderCard(call(Date.now(), 6), 'Alice', null), 'Mark as running'); }); type ClickHandler = () => void | Promise; const findClickHandler = (node: ReactNode, label: string): ClickHandler | undefined => { if (!isValidElement<{ children?: ReactNode; onClick?: ClickHandler }>(node)) return; if (node.type === 'button' && buttonNamed(renderToStaticMarkup(node), label)) { return node.props.onClick; } for (const child of Children.toArray(node.props.children)) { const handler = findClickHandler(child, label); if (handler) return handler; } }; // CardActions is controlled by NominationCard. Exercise its real button // callbacks and rerender with the resulting confirmation state. const startHarness = ( nomination: Nomination, publication: Promise = Promise.resolve(true), localGame: Game | null = game, ) => { let confirming = false; const published: string[] = []; const launched: Game[] = []; const view = () => CardActions({ nomination, game: localGame, actorId: 'Alice', actions: { ...actions, startNow: callId => { published.push(callId); return publication; }, }, now: Date.now(), onLaunch: launchedGame => launched.push(launchedGame), confirmStart: confirming, onStartConfirmationChange: value => { confirming = value; }, }); return { published, launched, markup: () => renderToStaticMarkup(view()), click: (label: string) => { const handler = findClickHandler(view(), label); assert(handler, `${label} must have a click handler`); return handler(); }, }; }; Deno.test('early start asks for confirmation without publishing or launching', async () => { const now = Date.now(); for (const nomination of [ call(now, 6), call(now), call(now, 1, now + 60 * 60_000), call(now, 1, now + 10 * 60_000), ]) { const harness = startHarness(nomination); await harness.click('Start now'); assert(harness.markup().includes('Start this call early?'), 'early-start question appears'); assert(buttonNamed(harness.markup(), 'Keep waiting'), 'waiting remains an explicit choice'); assert(harness.published.length === 0, 'first click must not publish Start'); assert(harness.launched.length === 0, 'first click must not launch'); } }); Deno.test('keeping a call waiting dismisses confirmation and requires it again next time', async () => { const harness = startHarness(call(Date.now(), 6)); await harness.click('Start now'); await harness.click('Keep waiting'); assert(!harness.markup().includes('Start this call early?'), 'confirmation closes'); assertStartAvailable(harness.markup()); await harness.click('Start now'); assert(harness.markup().includes('Start this call early?'), 'a later early start asks again'); assert(harness.published.length === 0, 'keeping the call waiting publishes nothing'); assert(harness.launched.length === 0, 'keeping the call waiting launches nothing'); }); Deno.test('confirming an early start launches only after core acceptance', async () => { let accept!: (accepted: boolean) => void; const publication = new Promise(resolve => { accept = resolve; }); const nomination = call(Date.now(), 6); const harness = startHarness(nomination, publication); await harness.click('Start now'); const confirmation = harness.click('Start now'); assert(harness.published.length === 1, 'confirmation publishes exactly one Start'); assert(harness.published[0] === nomination.id, 'Start targets this call'); assertEquals(harness.launched.length, 0, 'launch waits for core acceptance'); accept(true); await confirmation; assert(harness.launched.at(0) === game, 'accepted Start launches the local game'); assert(harness.launched.length === 1, 'the game launches exactly once'); }); Deno.test('rejected early starts never launch and require confirmation on retry', async () => { const harness = startHarness(call(Date.now(), 6), Promise.resolve(false)); await harness.click('Start now'); await harness.click('Start now'); assert(harness.published.length === 1, 'confirmation reaches the peer'); assert(harness.launched.length === 0, 'rejection cannot launch the game'); await harness.click('Start now'); assert(harness.markup().includes('Start this call early?'), 'retry requires confirmation'); assert(harness.published.length === 1, 'retry first click does not publish'); }); Deno.test('ready and elapsed calls still start with one click', async () => { const now = Date.now(); for (const nomination of [call(now, 8), call(now, 1, null, now - 1_000)]) { const harness = startHarness(nomination); await harness.click('Start now'); assert(harness.published.length === 1, 'normal start publishes on the first click'); assert(harness.launched.length === 1, 'accepted normal start launches'); assert(!harness.markup().includes('Start this call early?'), 'normal start needs no confirmation'); } }); Deno.test('confirmed early start without a local game only marks the call running', async () => { const harness = startHarness(call(Date.now(), 6), Promise.resolve(true), null); await harness.click('Mark as running'); assertEquals(harness.published.length, 0, 'marking early also requires confirmation'); await harness.click('Start now'); assert(harness.published.length === 1, 'confirmation publishes Start'); assert(harness.launched.length === 0, 'an unavailable game cannot launch locally'); });