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