import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { 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 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'); });