From 37899da18a95caca7b9aba3ab185f755fcc01a89 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Tue, 15 Sep 2026 23:27:47 +0200 Subject: [PATCH] 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 --- README.md | 6 +- crates/lanspread-tauri-deno-ts/deno.test.json | 9 + .../components/calltoplay/NominationCard.tsx | 60 +++--- .../tests/nominationCard.test.ts | 173 ++++++++++++++++++ design/launcher/SPEC.md | 14 +- justfile | 2 +- 6 files changed, 228 insertions(+), 36 deletions(-) create mode 100644 crates/lanspread-tauri-deno-ts/deno.test.json create mode 100644 crates/lanspread-tauri-deno-ts/tests/nominationCard.test.ts diff --git a/README.md b/README.md index ee0fab4..399bccd 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ just test just frontend-test ``` +Frontend tests render components with the React packages installed by +`just setup`. Their separate Deno configuration uses those installed packages +without rewriting the application's dependency lockfile. + See [launcher shutdown ownership](crates/lanspread-tauri-deno-ts/SHUTDOWN.md) for the cancellation/drain sequence and a native close-button regression probe. @@ -319,7 +323,7 @@ just peer-cli-charlie | `just fmt` | Format Rust, TOML, Markdown, and the Justfile. | | `just clippy` | Lint every Rust workspace target with warnings denied. | | `just test` | Run every Rust workspace test target. | -| `just frontend-test` | Run the frontend reducer/lifecycle tests. | +| `just frontend-test` | Run frontend reducer, lifecycle, and rendered component tests. | | `just peer-cli-build` | Build the JSONL peer harness. | | `just peer-cli-image` | Check fixture authority and build the harness Docker image. | | `just peer-cli-tests [SCENARIO ...]` | Run selected scenarios, or the complete matrix when no IDs are supplied. | diff --git a/crates/lanspread-tauri-deno-ts/deno.test.json b/crates/lanspread-tauri-deno-ts/deno.test.json new file mode 100644 index 0000000..a92e0cf --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/deno.test.json @@ -0,0 +1,9 @@ +{ + "nodeModulesDir": "manual", + "lock": false, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "lib": ["deno.window", "dom", "dom.iterable"] + } +} diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx index 2eff269..9380f1d 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx @@ -306,14 +306,36 @@ const CardActions = ({ ); } + // Readiness and the schedule guide coordination; the caller may start early. + if (isCreator) { + return ( + <> + + {isDone && ( + + )} + {scheduled && ( +
+ Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start. +
+ )} + {!isDone && !scheduled && myStatus?.status === 'in' && ( + + )} + + ); + } if (scheduled) { - if (isCreator) { - return ( -
- Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start. -
- ); - } if (isMe) { return ( <> @@ -333,30 +355,6 @@ const CardActions = ({ ); } - if (isCreator) { - if (isDone) { - return ( - <> - - - - ); - } - if (myStatus?.status === 'in') { - return ; - } - return
Waiting for players to ready up…
; - } if (isDone) { return (
diff --git a/crates/lanspread-tauri-deno-ts/tests/nominationCard.test.ts b/crates/lanspread-tauri-deno-ts/tests/nominationCard.test.ts new file mode 100644 index 0000000..86c6599 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/nominationCard.test.ts @@ -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>/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'); +}); diff --git a/design/launcher/SPEC.md b/design/launcher/SPEC.md index e632e3b..2bee99f 100644 --- a/design/launcher/SPEC.md +++ b/design/launcher/SPEC.md @@ -818,7 +818,7 @@ Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared | Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) | | Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later | | Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window | -| Who starts it | the **caller** decides the actual launch | the **caller**, once people have checked in | +| Who starts it | the **caller** decides the actual launch | the **caller** decides, including before check-in | **Check-in window.** `CHECKIN_LEAD_MS = 15 min`. A scheduled call sits in the `scheduled` phase collecting RSVPs until 15 minutes before its start time, then @@ -830,6 +830,13 @@ When the deadline passes, the call is labeled **Time's up** rather than Ready. It remains visible for five minutes so the caller can start or extend it, then the call and its history expire as a unit. +The caller can use **Start now** at any time while the call is visible and has +not been started or cancelled, including before scheduled check-in. The player +count and deadline guide coordination; they do not gate starting. An +eight-player call can start with six ready players, or even the caller alone. +Starting marks the call Running and then invokes the caller's local game action +only after the peer accepts it. + **Every call carries a small group chat** (see "Per-call chat" below). ### Three surfaces @@ -892,8 +899,9 @@ Top to bottom: avatar chips for each participant plus empty slots up to `maxPlayers`. 5. **Actions** — context-dependent on your role (**creator** / **participant** / **outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons, - `I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved), - `Leave` / `Can't make it`, or a status note. + `I'm in` (RSVP), `Start now` (creator at any time before Start/Cancel), + `Add 5 more minutes` (creator once resolved), `Leave` / `Can't make it`, or a + status note. 6. **Chat** — the collapsible per-call chat panel. 7. **Cancel** — creators get a `Cancel this call` link with an inline confirm. diff --git a/justfile b/justfile index a824a78..5b9a69b 100644 --- a/justfile +++ b/justfile @@ -232,7 +232,7 @@ catalog-generate-production-game PACKAGES_DIR GAME_ID: --game-id "$GAME_ID" frontend-test: - cd crates/lanspread-tauri-deno-ts && deno test --unstable-sloppy-imports tests + cd crates/lanspread-tauri-deno-ts && deno test --config deno.test.json --allow-env=NODE_ENV --unstable-sloppy-imports tests clean: cargo clean