Compare commits
2
Commits
a73acccf1a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc326a0e3f | ||
|
|
37899da18a |
No files matched your search
@@ -91,6 +91,10 @@ just test
|
|||||||
just frontend-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)
|
See [launcher shutdown ownership](crates/lanspread-tauri-deno-ts/SHUTDOWN.md)
|
||||||
for the cancellation/drain sequence and a native close-button regression probe.
|
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 fmt` | Format Rust, TOML, Markdown, and the Justfile. |
|
||||||
| `just clippy` | Lint every Rust workspace target with warnings denied. |
|
| `just clippy` | Lint every Rust workspace target with warnings denied. |
|
||||||
| `just test` | Run every Rust workspace test target. |
|
| `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-build` | Build the JSONL peer harness. |
|
||||||
| `just peer-cli-image` | Check fixture authority and build the harness Docker image. |
|
| `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. |
|
| `just peer-cli-tests [SCENARIO ...]` | Run selected scenarios, or the complete matrix when no IDs are supplied. |
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"nodeModulesDir": "manual",
|
||||||
|
"lock": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"lib": ["deno.window", "dom", "dom.iterable"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,10 +70,14 @@ export const NominationCard = ({
|
|||||||
onLaunch,
|
onLaunch,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||||
|
const [confirmStart, setConfirmStart] = useState(false);
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
}, [focused]);
|
}, [focused]);
|
||||||
|
useEffect(() => {
|
||||||
|
setConfirmStart(false);
|
||||||
|
}, [nomination.id, nomination.state, actorId]);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const entries = Object.entries(nomination.participants);
|
const entries = Object.entries(nomination.participants);
|
||||||
@@ -212,6 +216,8 @@ export const NominationCard = ({
|
|||||||
actions={actions}
|
actions={actions}
|
||||||
onLaunch={onLaunch}
|
onLaunch={onLaunch}
|
||||||
now={now}
|
now={now}
|
||||||
|
confirmStart={confirmStart}
|
||||||
|
onStartConfirmationChange={setConfirmStart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -255,6 +261,8 @@ interface CardActionsProps {
|
|||||||
actions: CallToPlayActions;
|
actions: CallToPlayActions;
|
||||||
onLaunch: (game: Game) => void;
|
onLaunch: (game: Game) => void;
|
||||||
now: number;
|
now: number;
|
||||||
|
confirmStart: boolean;
|
||||||
|
onStartConfirmationChange: (confirm: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||||||
@@ -278,13 +286,15 @@ const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CardActions = ({
|
export const CardActions = ({
|
||||||
nomination,
|
nomination,
|
||||||
game,
|
game,
|
||||||
actorId,
|
actorId,
|
||||||
actions,
|
actions,
|
||||||
onLaunch,
|
onLaunch,
|
||||||
now,
|
now,
|
||||||
|
confirmStart,
|
||||||
|
onStartConfirmationChange,
|
||||||
}: CardActionsProps) => {
|
}: CardActionsProps) => {
|
||||||
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||||||
const isMe = myStatus !== undefined;
|
const isMe = myStatus !== undefined;
|
||||||
@@ -294,6 +304,11 @@ const CardActions = ({
|
|||||||
const isExpired = statusOf(nomination, now) === 'expired';
|
const isExpired = statusOf(nomination, now) === 'expired';
|
||||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
|
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
|
||||||
const readyCount = readyCountOf(nomination, now);
|
const readyCount = readyCountOf(nomination, now);
|
||||||
|
const startCall = async () => {
|
||||||
|
onStartConfirmationChange(false);
|
||||||
|
const accepted = await actions.startNow(nomination.id);
|
||||||
|
if (accepted && game) onLaunch(game);
|
||||||
|
};
|
||||||
|
|
||||||
if (terminal) {
|
if (terminal) {
|
||||||
return (
|
return (
|
||||||
@@ -306,14 +321,57 @@ const CardActions = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (scheduled) {
|
// Readiness and the schedule guide coordination; the caller may start early.
|
||||||
if (isCreator) {
|
if (isCreator) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{confirmStart && !isDone
|
||||||
|
? (
|
||||||
|
<div className="ctp-start-confirm" role="group" aria-label="Start this call early?">
|
||||||
|
<span>Start this call early?</span>
|
||||||
|
<div className="ctp-start-confirm-btns">
|
||||||
|
<button className="act-btn act-play" onClick={startCall}>
|
||||||
|
<Icon.play /><span>Start now</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-btn"
|
||||||
|
onClick={() => onStartConfirmationChange(false)}
|
||||||
|
>Keep waiting</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
className="act-btn act-play"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isDone) {
|
||||||
|
onStartConfirmationChange(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return startCall();
|
||||||
|
}}
|
||||||
|
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
||||||
|
)}
|
||||||
|
{isDone && (
|
||||||
|
<button
|
||||||
|
className="ghost-btn"
|
||||||
|
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
|
||||||
|
>
|
||||||
|
Add 5 more minutes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{scheduled && (
|
||||||
<div className="ctp-note">
|
<div className="ctp-note">
|
||||||
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
|
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{!isDone && !scheduled && myStatus?.status === 'in' && (
|
||||||
|
<ReadyButtons nomination={nomination} actions={actions} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (scheduled) {
|
||||||
if (isMe) {
|
if (isMe) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -333,30 +391,6 @@ const CardActions = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isCreator) {
|
|
||||||
if (isDone) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="act-btn act-play"
|
|
||||||
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
|
||||||
if (accepted && game) onLaunch(game);
|
|
||||||
})}
|
|
||||||
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
|
||||||
<button
|
|
||||||
className="ghost-btn"
|
|
||||||
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
|
|
||||||
>
|
|
||||||
Add 5 more minutes
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (myStatus?.status === 'in') {
|
|
||||||
return <ReadyButtons nomination={nomination} actions={actions} />;
|
|
||||||
}
|
|
||||||
return <div className="ctp-note">Waiting for players to ready up…</div>;
|
|
||||||
}
|
|
||||||
if (isDone) {
|
if (isDone) {
|
||||||
return (
|
return (
|
||||||
<div className="ctp-note">
|
<div className="ctp-note">
|
||||||
|
|||||||
@@ -2201,26 +2201,38 @@
|
|||||||
.ctp-cancel-link:hover {
|
.ctp-cancel-link:hover {
|
||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
}
|
}
|
||||||
.ctp-cancel-confirm {
|
.ctp-cancel-confirm,
|
||||||
|
.ctp-start-confirm {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: rgba(239,68,68,0.08);
|
|
||||||
border: 1px solid rgba(239,68,68,0.35);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: #fca5a5;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.ctp-cancel-confirm-btns {
|
.ctp-cancel-confirm {
|
||||||
|
background: rgba(239,68,68,0.08);
|
||||||
|
border: 1px solid rgba(239,68,68,0.35);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.ctp-start-confirm {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.ctp-cancel-confirm-btns,
|
||||||
|
.ctp-start-confirm-btns {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ctp-cancel-confirm-btns .ghost-btn {
|
.ctp-cancel-confirm-btns .ghost-btn,
|
||||||
|
.ctp-actions .ctp-start-confirm-btns .act-btn,
|
||||||
|
.ctp-actions .ctp-start-confirm-btns .ghost-btn {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
|
||||||
|
import { CardActions, 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 assertEquals = <T>(actual: T, expected: T, message: string): void => {
|
||||||
|
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
type ClickHandler = () => void | Promise<void>;
|
||||||
|
|
||||||
|
const findClickHandler = (node: ReactNode, label: string): ClickHandler | undefined => {
|
||||||
|
if (!isValidElement<{ children?: ReactNode; onClick?: ClickHandler }>(node)) return;
|
||||||
|
if (node.type === 'button' && buttonNamed(renderToStaticMarkup(node), label)) {
|
||||||
|
return node.props.onClick;
|
||||||
|
}
|
||||||
|
for (const child of Children.toArray(node.props.children)) {
|
||||||
|
const handler = findClickHandler(child, label);
|
||||||
|
if (handler) return handler;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// CardActions is controlled by NominationCard. Exercise its real button
|
||||||
|
// callbacks and rerender with the resulting confirmation state.
|
||||||
|
const startHarness = (
|
||||||
|
nomination: Nomination,
|
||||||
|
publication: Promise<boolean> = Promise.resolve(true),
|
||||||
|
localGame: Game | null = game,
|
||||||
|
) => {
|
||||||
|
let confirming = false;
|
||||||
|
const published: string[] = [];
|
||||||
|
const launched: Game[] = [];
|
||||||
|
const view = () => CardActions({
|
||||||
|
nomination,
|
||||||
|
game: localGame,
|
||||||
|
actorId: 'Alice',
|
||||||
|
actions: {
|
||||||
|
...actions,
|
||||||
|
startNow: callId => {
|
||||||
|
published.push(callId);
|
||||||
|
return publication;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
now: Date.now(),
|
||||||
|
onLaunch: launchedGame => launched.push(launchedGame),
|
||||||
|
confirmStart: confirming,
|
||||||
|
onStartConfirmationChange: value => { confirming = value; },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
published,
|
||||||
|
launched,
|
||||||
|
markup: () => renderToStaticMarkup(view()),
|
||||||
|
click: (label: string) => {
|
||||||
|
const handler = findClickHandler(view(), label);
|
||||||
|
assert(handler, `${label} must have a click handler`);
|
||||||
|
return handler();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test('early start asks for confirmation without publishing or launching', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const nomination of [
|
||||||
|
call(now, 6),
|
||||||
|
call(now),
|
||||||
|
call(now, 1, now + 60 * 60_000),
|
||||||
|
call(now, 1, now + 10 * 60_000),
|
||||||
|
]) {
|
||||||
|
const harness = startHarness(nomination);
|
||||||
|
await harness.click('Start now');
|
||||||
|
assert(harness.markup().includes('Start this call early?'), 'early-start question appears');
|
||||||
|
assert(buttonNamed(harness.markup(), 'Keep waiting'), 'waiting remains an explicit choice');
|
||||||
|
assert(harness.published.length === 0, 'first click must not publish Start');
|
||||||
|
assert(harness.launched.length === 0, 'first click must not launch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('keeping a call waiting dismisses confirmation and requires it again next time', async () => {
|
||||||
|
const harness = startHarness(call(Date.now(), 6));
|
||||||
|
await harness.click('Start now');
|
||||||
|
await harness.click('Keep waiting');
|
||||||
|
assert(!harness.markup().includes('Start this call early?'), 'confirmation closes');
|
||||||
|
assertStartAvailable(harness.markup());
|
||||||
|
await harness.click('Start now');
|
||||||
|
assert(harness.markup().includes('Start this call early?'), 'a later early start asks again');
|
||||||
|
assert(harness.published.length === 0, 'keeping the call waiting publishes nothing');
|
||||||
|
assert(harness.launched.length === 0, 'keeping the call waiting launches nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('confirming an early start launches only after core acceptance', async () => {
|
||||||
|
let accept!: (accepted: boolean) => void;
|
||||||
|
const publication = new Promise<boolean>(resolve => { accept = resolve; });
|
||||||
|
const nomination = call(Date.now(), 6);
|
||||||
|
const harness = startHarness(nomination, publication);
|
||||||
|
await harness.click('Start now');
|
||||||
|
const confirmation = harness.click('Start now');
|
||||||
|
assert(harness.published.length === 1, 'confirmation publishes exactly one Start');
|
||||||
|
assert(harness.published[0] === nomination.id, 'Start targets this call');
|
||||||
|
assertEquals(harness.launched.length, 0, 'launch waits for core acceptance');
|
||||||
|
accept(true);
|
||||||
|
await confirmation;
|
||||||
|
assert(harness.launched.at(0) === game, 'accepted Start launches the local game');
|
||||||
|
assert(harness.launched.length === 1, 'the game launches exactly once');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('rejected early starts never launch and require confirmation on retry', async () => {
|
||||||
|
const harness = startHarness(call(Date.now(), 6), Promise.resolve(false));
|
||||||
|
await harness.click('Start now');
|
||||||
|
await harness.click('Start now');
|
||||||
|
assert(harness.published.length === 1, 'confirmation reaches the peer');
|
||||||
|
assert(harness.launched.length === 0, 'rejection cannot launch the game');
|
||||||
|
await harness.click('Start now');
|
||||||
|
assert(harness.markup().includes('Start this call early?'), 'retry requires confirmation');
|
||||||
|
assert(harness.published.length === 1, 'retry first click does not publish');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('ready and elapsed calls still start with one click', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const nomination of [call(now, 8), call(now, 1, null, now - 1_000)]) {
|
||||||
|
const harness = startHarness(nomination);
|
||||||
|
await harness.click('Start now');
|
||||||
|
assert(harness.published.length === 1, 'normal start publishes on the first click');
|
||||||
|
assert(harness.launched.length === 1, 'accepted normal start launches');
|
||||||
|
assert(!harness.markup().includes('Start this call early?'), 'normal start needs no confirmation');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('confirmed early start without a local game only marks the call running', async () => {
|
||||||
|
const harness = startHarness(call(Date.now(), 6), Promise.resolve(true), null);
|
||||||
|
await harness.click('Mark as running');
|
||||||
|
assertEquals(harness.published.length, 0, 'marking early also requires confirmation');
|
||||||
|
await harness.click('Start now');
|
||||||
|
assert(harness.published.length === 1, 'confirmation publishes Start');
|
||||||
|
assert(harness.launched.length === 0, 'an unavailable game cannot launch locally');
|
||||||
|
});
|
||||||
+15
-3
@@ -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) |
|
| 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 |
|
| 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 |
|
| 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
|
**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
|
`scheduled` phase collecting RSVPs until 15 minutes before its start time, then
|
||||||
@@ -830,6 +830,17 @@ 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
|
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 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.
|
||||||
|
Before the roster is ready or the deadline has elapsed, clicking **Start now**
|
||||||
|
opens a small inline confirmation: **Start this call early?**, with **Start
|
||||||
|
now** and **Keep waiting** buttons. Keeping the call waiting publishes no
|
||||||
|
action. Ready and Time's-up calls start with one click. 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).
|
**Every call carries a small group chat** (see "Per-call chat" below).
|
||||||
|
|
||||||
### Three surfaces
|
### Three surfaces
|
||||||
@@ -892,8 +903,9 @@ Top to bottom:
|
|||||||
avatar chips for each participant plus empty slots up to `maxPlayers`.
|
avatar chips for each participant plus empty slots up to `maxPlayers`.
|
||||||
5. **Actions** — context-dependent on your role (**creator** / **participant** /
|
5. **Actions** — context-dependent on your role (**creator** / **participant** /
|
||||||
**outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons,
|
**outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons,
|
||||||
`I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved),
|
`I'm in` (RSVP), `Start now` (creator at any time before Start/Cancel),
|
||||||
`Leave` / `Can't make it`, or a status note.
|
`Add 5 more minutes` (creator once resolved), `Leave` / `Can't make it`, or a
|
||||||
|
status note.
|
||||||
6. **Chat** — the collapsible per-call chat panel.
|
6. **Chat** — the collapsible per-call chat panel.
|
||||||
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
|
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
|
||||||
|
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ catalog-generate-production-game PACKAGES_DIR GAME_ID:
|
|||||||
--game-id "$GAME_ID"
|
--game-id "$GAME_ID"
|
||||||
|
|
||||||
frontend-test:
|
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:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
|
|||||||
Reference in new issue
Block a user