fix(call-to-play): let callers start before the roster fills

The card hid Start now until the roster filled or the deadline elapsed,
preventing groups from starting early with fewer players. Expose the existing
start action for every unfinished caller-owned call, including scheduled calls
before check-in. Keep check-in controls and launch only after core acceptance.

Add rendered-card regressions for six-of-eight players, solo and scheduled
starts, creator authority, terminal states, and unavailable local games. Use
installed React packages for these tests without rewriting the app lockfile,
and document the caller's control over start time.

Test Plan:
- `just frontend-test` -- passed, 107 tests including 8 rendered-card tests
- `just test` -- passed, 797 Rust tests
- `just clippy` -- passed with warnings denied
- `just fmt` -- passed
- `deno task build` -- passed TypeScript and Vite production build
- `git diff --cached --check` -- passed
This commit is contained in:
ddidderr committed 2026-09-15 23:27:47 +02:00
1 parent a73acccf1a
commit 37899da18a
6 files changed
+228 -36

No files matched your search

@@ -0,0 +1,173 @@
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\b[^>]*>.*?<\/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');
});