import { CALL_TO_PLAY_CONNECTING_MESSAGE, CHECKIN_LEAD_MS, EXPIRED_RETENTION_MS, TERMINAL_RETENTION_MS, activeCallCount, callToPlayPublishErrorMessage, extendDeadline, phaseOf, bumpTime, normalizeTimeInput, readyCountOf, reduceCallToPlayEvents, replaceCallToPlayView, sortNominations, statusOf, } from '../src/lib/callToPlay.ts'; import { type CallToPlayAction, type CallToPlayViewEvent } from '../src/lib/types.ts'; const NOW = 1_000_000; function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } const assertEquals = (actual: T, expected: T, message: string) => { if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`); }; const event = ( id: string, actorId: string, action: CallToPlayAction, at = NOW, actorName = actorId, ): CallToPlayViewEvent => ({ id, call_id: 'call-1', author_id: actorId, author_name: actorName, at, action, }); const create = ( scheduledFor: number | null = null, deadline = NOW + 30 * 60_000, ): CallToPlayViewEvent => event('create', 'Alice', { Create: { game_id: 'game-1', max_players: 3, scheduled_for: scheduledFor, deadline, }, }); Deno.test('play-now call starts with its creator ready', () => { const [nomination] = reduceCallToPlayEvents([create()], NOW); assert(nomination, 'call should exist'); assertEquals(nomination.creator, 'Alice', 'creator'); assertEquals(nomination.participants.Alice.status, 'ready', 'creator status'); assertEquals(phaseOf(nomination, NOW), 'now', 'phase'); assertEquals(statusOf(nomination, NOW), 'call', 'status outside starting-soon window'); }); Deno.test('scheduled RSVP becomes check-in and pending response becomes ready over time', () => { const scheduledFor = NOW + 60 * 60_000; const events = [ create(scheduledFor, scheduledFor), event('rsvp', 'Bob', 'Rsvp', NOW + 1), event('respond', 'Bob', { Respond: { ready_at: scheduledFor - 5 * 60_000 } }, NOW + 2), ]; const [far] = reduceCallToPlayEvents(events, NOW); assertEquals(phaseOf(far, NOW), 'scheduled', 'far-out phase'); assertEquals(far.participants.Bob.status, 'pending', 'buffered response state'); const checkinNow = scheduledFor - CHECKIN_LEAD_MS; const [checkin] = reduceCallToPlayEvents(events, checkinNow); assertEquals(phaseOf(checkin, checkinNow), 'checkin', 'check-in phase'); assertEquals(readyCountOf(checkin, checkinNow), 0, 'nobody ready at check-in opening'); const [ready] = reduceCallToPlayEvents(events, scheduledFor - 4 * 60_000); assertEquals(readyCountOf(ready, scheduledFor - 4 * 60_000), 1, 'elapsed buffer is ready'); }); Deno.test('call resolves when the roster fills or its deadline elapses', () => { const full = [ create(), event('bob', 'Bob', { Respond: { ready_at: null } }, NOW + 1), event('carol', 'Carol', { Respond: { ready_at: null } }, NOW + 2), ]; assertEquals(reduceCallToPlayEvents(full, NOW + 2)[0].state, 'done', 'full roster'); assertEquals( reduceCallToPlayEvents([create()], NOW + 31 * 60_000)[0].state, 'done', 'elapsed deadline', ); }); Deno.test('elapsed calls show as expired briefly and then disappear', () => { const deadline = NOW + 30 * 60_000; const events = [create(null, deadline)]; const [expired] = reduceCallToPlayEvents(events, deadline + 1); assert(expired, 'freshly expired call remains visible'); assertEquals(statusOf(expired, deadline + 1), 'expired', 'elapsed status'); assertEquals( reduceCallToPlayEvents(events, deadline + EXPIRED_RETENTION_MS + 1).length, 0, 'expired call retention', ); }); Deno.test('creator-only controls cannot be forged by another participant', () => { const forged = [ create(), event('cancel', 'Mallory', 'Cancel', NOW + 1), event('start', 'Mallory', 'Start', NOW + 2), event('extend', 'Mallory', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 3), ]; const [nomination] = reduceCallToPlayEvents(forged, NOW + 4); assert(nomination, 'forged cancel should not remove call'); assertEquals(nomination.state, 'open', 'forged start ignored'); assertEquals(nomination.deadline, NOW + 30 * 60_000, 'forged extension ignored'); }); Deno.test('stable peer ids keep duplicate display names distinct', () => { const createByCommander = event('create', 'peer-a', { Create: { game_id: 'game-1', max_players: 3, scheduled_for: null, deadline: NOW + 30 * 60_000, }, }, NOW, 'Commander'); const events = [ createByCommander, event('join', 'peer-b', { Respond: { ready_at: null } }, NOW + 1, 'Commander'), event('forged-cancel', 'peer-b', 'Cancel', NOW + 2, 'Commander'), ]; const [nomination] = reduceCallToPlayEvents(events, NOW + 3); assert(nomination, 'same-name participant must not cancel the call'); assertEquals(nomination.creatorId, 'peer-a', 'creator identity'); assertEquals(Object.keys(nomination.participants).length, 2, 'distinct peer participants'); }); Deno.test('creator can extend, start, and cancel a call', () => { const extended = reduceCallToPlayEvents([ create(), event('extend', 'Alice', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 1), ], NOW + 40 * 60_000)[0]; assertEquals(extended.state, 'open', 'extension reopens an elapsed call'); const started = reduceCallToPlayEvents([ create(), event('start', 'Alice', 'Start', NOW + 1), ], NOW + 2)[0]; assertEquals(started.state, 'running', 'creator start'); assertEquals(started.terminalAt, NOW + 1, 'running timestamp'); const [cancelled] = reduceCallToPlayEvents([ create(), event('cancel', 'Alice', 'Cancel', NOW + 1), ], NOW + 2); assertEquals(cancelled.state, 'cancelled', 'creator cancel'); assertEquals(cancelled.terminalAt, NOW + 1, 'cancel timestamp'); }); Deno.test('adding time extends from the current deadline or the current time', () => { const futureDeadline = NOW + 30 * 60_000; assertEquals( extendDeadline(NOW, futureDeadline), futureDeadline + 5 * 60_000, 'ready-early call keeps its remaining time', ); assertEquals( extendDeadline(NOW, NOW - 60_000), NOW + 5 * 60_000, 'overdue call gets five minutes from now', ); }); Deno.test('publish failures distinguish startup and store outcomes', () => { assertEquals( CALL_TO_PLAY_CONNECTING_MESSAGE, 'Call to Play is still connecting to the LAN. Try again in a moment.', 'peer startup message', ); assertEquals( callToPlayPublishErrorMessage('Call-to-Play call x is unknown or expired'), 'This Call to Play has expired or already finished.', 'obsolete call message', ); assertEquals( callToPlayPublishErrorMessage('Call-to-Play call x is already terminal'), 'This Call to Play has expired or already finished.', 'missing expired history message', ); assertEquals( callToPlayPublishErrorMessage(new Error('Call-to-Play local event history is full')), 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.', 'active history limit message', ); assertEquals( callToPlayPublishErrorMessage('channel closed'), 'Could not send this Call to Play update.', 'unexpected failure message', ); }); Deno.test('reduction is order-independent and deduplicates events and messages', () => { const message = event('message-event', 'Bob', { SendMessage: { text: 'Ready?' }, }, NOW + 2); const events = [message, create(), message]; const [nomination] = reduceCallToPlayEvents(events, NOW + 4); assertEquals(nomination.messages.length, 1, 'event IDs deduplicate messages'); assertEquals(nomination.messages[0].text, 'Ready?', 'message retained'); }); Deno.test('actions timestamped before creation cannot mutate a call', () => { const events = [ event('early-cancel', 'Alice', 'Cancel', NOW - 1), event('early-response', 'Bob', { Respond: { ready_at: null } }, NOW - 1), create(), ]; const [nomination] = reduceCallToPlayEvents(events, NOW + 1); assert(nomination, 'call should survive a pre-creation cancel'); assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored'); }); Deno.test('terminal calls retain complete read-only history for fifteen minutes', () => { const events = [ create(), event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1), event('message', 'Bob', { SendMessage: { text: 'Launching' }, }, NOW + 2), event('start', 'Alice', 'Start', NOW + 3), event('late-message', 'Bob', { SendMessage: { text: 'Too late' }, }, NOW + 4), ]; const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS); assertEquals(running.state, 'running', 'running receipt remains'); assertEquals(Object.keys(running.participants).length, 2, 'terminal roster retained'); assertEquals(running.messages.length, 1, 'pre-terminal chat retained'); assertEquals( reduceCallToPlayEvents(events, NOW + 3 + TERMINAL_RETENTION_MS + 1).length, 0, 'terminal receipt retires after display window', ); }); Deno.test('terminal calls sort last and do not contribute to the badge', () => { const active = reduceCallToPlayEvents([create()], NOW)[0]; const running = reduceCallToPlayEvents([ create(), event('start', 'Alice', 'Start', NOW + 1), ], NOW + 2)[0]; const cancelled = reduceCallToPlayEvents([ create(), event('cancel', 'Alice', 'Cancel', NOW + 2), ], NOW + 3)[0]; const sorted = sortNominations([running, cancelled, active]); assertEquals(sorted[0].state, 'open', 'actionable call sorts first'); assertEquals(activeCallCount(sorted), 1, 'terminal calls excluded from badge'); assertEquals(statusOf(running, NOW + 2), 'running', 'running ticker status'); assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status'); }); Deno.test('incoming Call to Play views replace removed author slices wholesale', () => { const previous = { events: [create(), event('join', 'Bob', 'Rsvp', NOW + 1)] }; const incoming = { events: [create()] }; const replaced = replaceCallToPlayView(previous, incoming); assertEquals(replaced.events.length, 1, 'departed author slice is removed'); assertEquals(replaced.events[0].id, 'create', 'incoming projection is authoritative'); }); Deno.test('scheduled time input accepts design formats and wraps steppers', () => { assertEquals(normalizeTimeInput('20:00'), '20:00', 'colon format'); assertEquals(normalizeTimeInput('2000'), '20:00', 'compact format'); assertEquals(normalizeTimeInput('9:30'), '09:30', 'single-digit hour'); assertEquals(normalizeTimeInput('24:00'), null, 'invalid hour'); assertEquals(bumpTime('23:45', 'minutes', 15), '00:00', 'minute wrap'); assertEquals(bumpTime('00:00', 'hours', -1), '23:00', 'hour wrap'); });