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(/