Add a durable, acknowledged Local network sharing switch with fail-closed hydration, serialized mutation, and redacted ephemeral-identity diagnostics. Keep local Call-to-Play state available while gating every network action on the effective sharing generation. Render revisioned verification, invalid-source retry, and sticky source exhaustion states. Preserve opaque attempt IDs through progress delivery so out-of-order webview events cannot attach stale bytes to a successor transfer, and keep terminal exhaustion visible after the last source departs. Own listeners, native invokes, persistence, dialogs, and companion-window creation through webview close. Late creation is settled and cleaned before the parent realm is destroyed. Test Plan: - `just frontend-test` -- passed (91/91) - `just build` -- passed with TypeScript, Vite, and release Tauri compilation - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `git diff --cached --check` -- passed
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import {
|
|
CALL_TO_PLAY_CONNECTING_MESSAGE,
|
|
CHECKIN_LEAD_MS,
|
|
EXPIRED_RETENTION_MS,
|
|
TERMINAL_RETENTION_MS,
|
|
activeCallCount,
|
|
callToPlayPublishErrorMessage,
|
|
extendDeadline,
|
|
phaseOf,
|
|
bumpTime,
|
|
normalizeTimeInput,
|
|
readyCountOf,
|
|
reduceCallToPlayEvents,
|
|
replaceCallToPlayView,
|
|
sortNominations,
|
|
statusOf,
|
|
} from '../src/lib/callToPlay.ts';
|
|
import { type CallToPlayAction, type CallToPlayViewEvent } from '../src/lib/types.ts';
|
|
|
|
const NOW = 1_000_000;
|
|
|
|
function assert(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
|
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
|
};
|
|
|
|
const event = (
|
|
id: string,
|
|
actorId: string,
|
|
action: CallToPlayAction,
|
|
at = NOW,
|
|
actorName = actorId,
|
|
): CallToPlayViewEvent => ({
|
|
id,
|
|
call_id: 'call-1',
|
|
author_id: actorId,
|
|
author_name: actorName,
|
|
at,
|
|
action,
|
|
});
|
|
|
|
const create = (
|
|
scheduledFor: number | null = null,
|
|
deadline = NOW + 30 * 60_000,
|
|
): CallToPlayViewEvent => event('create', 'Alice', {
|
|
Create: {
|
|
game_id: 'game-1',
|
|
max_players: 3,
|
|
scheduled_for: scheduledFor,
|
|
deadline,
|
|
},
|
|
});
|
|
|
|
Deno.test('play-now call starts with its creator ready', () => {
|
|
const [nomination] = reduceCallToPlayEvents([create()], NOW);
|
|
assert(nomination, 'call should exist');
|
|
assertEquals(nomination.creator, 'Alice', 'creator');
|
|
assertEquals(nomination.participants.Alice.status, 'ready', 'creator status');
|
|
assertEquals(phaseOf(nomination, NOW), 'now', 'phase');
|
|
assertEquals(statusOf(nomination, NOW), 'call', 'status outside starting-soon window');
|
|
});
|
|
|
|
Deno.test('scheduled RSVP becomes check-in and pending response becomes ready over time', () => {
|
|
const scheduledFor = NOW + 60 * 60_000;
|
|
const events = [
|
|
create(scheduledFor, scheduledFor),
|
|
event('rsvp', 'Bob', 'Rsvp', NOW + 1),
|
|
event('respond', 'Bob', { Respond: { ready_at: scheduledFor - 5 * 60_000 } }, NOW + 2),
|
|
];
|
|
const [far] = reduceCallToPlayEvents(events, NOW);
|
|
assertEquals(phaseOf(far, NOW), 'scheduled', 'far-out phase');
|
|
assertEquals(far.participants.Bob.status, 'pending', 'buffered response state');
|
|
|
|
const checkinNow = scheduledFor - CHECKIN_LEAD_MS;
|
|
const [checkin] = reduceCallToPlayEvents(events, checkinNow);
|
|
assertEquals(phaseOf(checkin, checkinNow), 'checkin', 'check-in phase');
|
|
assertEquals(readyCountOf(checkin, checkinNow), 0, 'nobody ready at check-in opening');
|
|
|
|
const [ready] = reduceCallToPlayEvents(events, scheduledFor - 4 * 60_000);
|
|
assertEquals(readyCountOf(ready, scheduledFor - 4 * 60_000), 1, 'elapsed buffer is ready');
|
|
});
|
|
|
|
Deno.test('call resolves when the roster fills or its deadline elapses', () => {
|
|
const full = [
|
|
create(),
|
|
event('bob', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
|
|
event('carol', 'Carol', { Respond: { ready_at: null } }, NOW + 2),
|
|
];
|
|
assertEquals(reduceCallToPlayEvents(full, NOW + 2)[0].state, 'done', 'full roster');
|
|
assertEquals(
|
|
reduceCallToPlayEvents([create()], NOW + 31 * 60_000)[0].state,
|
|
'done',
|
|
'elapsed deadline',
|
|
);
|
|
});
|
|
|
|
Deno.test('elapsed calls show as expired briefly and then disappear', () => {
|
|
const deadline = NOW + 30 * 60_000;
|
|
const events = [create(null, deadline)];
|
|
const [expired] = reduceCallToPlayEvents(events, deadline + 1);
|
|
assert(expired, 'freshly expired call remains visible');
|
|
assertEquals(statusOf(expired, deadline + 1), 'expired', 'elapsed status');
|
|
assertEquals(
|
|
reduceCallToPlayEvents(events, deadline + EXPIRED_RETENTION_MS + 1).length,
|
|
0,
|
|
'expired call retention',
|
|
);
|
|
});
|
|
|
|
Deno.test('creator-only controls cannot be forged by another participant', () => {
|
|
const forged = [
|
|
create(),
|
|
event('cancel', 'Mallory', 'Cancel', NOW + 1),
|
|
event('start', 'Mallory', 'Start', NOW + 2),
|
|
event('extend', 'Mallory', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 3),
|
|
];
|
|
const [nomination] = reduceCallToPlayEvents(forged, NOW + 4);
|
|
assert(nomination, 'forged cancel should not remove call');
|
|
assertEquals(nomination.state, 'open', 'forged start ignored');
|
|
assertEquals(nomination.deadline, NOW + 30 * 60_000, 'forged extension ignored');
|
|
});
|
|
|
|
Deno.test('stable peer ids keep duplicate display names distinct', () => {
|
|
const createByCommander = event('create', 'peer-a', {
|
|
Create: {
|
|
game_id: 'game-1',
|
|
max_players: 3,
|
|
scheduled_for: null,
|
|
deadline: NOW + 30 * 60_000,
|
|
},
|
|
}, NOW, 'Commander');
|
|
const events = [
|
|
createByCommander,
|
|
event('join', 'peer-b', { Respond: { ready_at: null } }, NOW + 1, 'Commander'),
|
|
event('forged-cancel', 'peer-b', 'Cancel', NOW + 2, 'Commander'),
|
|
];
|
|
|
|
const [nomination] = reduceCallToPlayEvents(events, NOW + 3);
|
|
assert(nomination, 'same-name participant must not cancel the call');
|
|
assertEquals(nomination.creatorId, 'peer-a', 'creator identity');
|
|
assertEquals(Object.keys(nomination.participants).length, 2, 'distinct peer participants');
|
|
});
|
|
|
|
Deno.test('creator can extend, start, and cancel a call', () => {
|
|
const extended = reduceCallToPlayEvents([
|
|
create(),
|
|
event('extend', 'Alice', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 1),
|
|
], NOW + 40 * 60_000)[0];
|
|
assertEquals(extended.state, 'open', 'extension reopens an elapsed call');
|
|
|
|
const started = reduceCallToPlayEvents([
|
|
create(),
|
|
event('start', 'Alice', 'Start', NOW + 1),
|
|
], NOW + 2)[0];
|
|
assertEquals(started.state, 'running', 'creator start');
|
|
assertEquals(started.terminalAt, NOW + 1, 'running timestamp');
|
|
|
|
const [cancelled] = reduceCallToPlayEvents([
|
|
create(),
|
|
event('cancel', 'Alice', 'Cancel', NOW + 1),
|
|
], NOW + 2);
|
|
assertEquals(cancelled.state, 'cancelled', 'creator cancel');
|
|
assertEquals(cancelled.terminalAt, NOW + 1, 'cancel timestamp');
|
|
});
|
|
|
|
Deno.test('adding time extends from the current deadline or the current time', () => {
|
|
const futureDeadline = NOW + 30 * 60_000;
|
|
assertEquals(
|
|
extendDeadline(NOW, futureDeadline),
|
|
futureDeadline + 5 * 60_000,
|
|
'ready-early call keeps its remaining time',
|
|
);
|
|
assertEquals(
|
|
extendDeadline(NOW, NOW - 60_000),
|
|
NOW + 5 * 60_000,
|
|
'overdue call gets five minutes from now',
|
|
);
|
|
});
|
|
|
|
Deno.test('publish failures distinguish startup and store outcomes', () => {
|
|
assertEquals(
|
|
CALL_TO_PLAY_CONNECTING_MESSAGE,
|
|
'Call to Play is still connecting to the LAN. Try again in a moment.',
|
|
'peer startup message',
|
|
);
|
|
assertEquals(
|
|
callToPlayPublishErrorMessage('Call-to-Play call x is unknown or expired'),
|
|
'This Call to Play has expired or already finished.',
|
|
'obsolete call message',
|
|
);
|
|
assertEquals(
|
|
callToPlayPublishErrorMessage('Call-to-Play call x is already terminal'),
|
|
'This Call to Play has expired or already finished.',
|
|
'missing expired history message',
|
|
);
|
|
assertEquals(
|
|
callToPlayPublishErrorMessage(new Error('Call-to-Play local event history is full')),
|
|
'Call to Play has reached its active update limit. Start or cancel an active call, then try again.',
|
|
'active history limit message',
|
|
);
|
|
assertEquals(
|
|
callToPlayPublishErrorMessage('channel closed'),
|
|
'Could not send this Call to Play update.',
|
|
'unexpected failure message',
|
|
);
|
|
});
|
|
|
|
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
|
|
const message = event('message-event', 'Bob', {
|
|
SendMessage: { text: 'Ready?' },
|
|
}, NOW + 2);
|
|
const events = [message, create(), message];
|
|
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
|
|
assertEquals(nomination.messages.length, 1, 'event IDs deduplicate messages');
|
|
assertEquals(nomination.messages[0].text, 'Ready?', 'message retained');
|
|
});
|
|
|
|
Deno.test('actions timestamped before creation cannot mutate a call', () => {
|
|
const events = [
|
|
event('early-cancel', 'Alice', 'Cancel', NOW - 1),
|
|
event('early-response', 'Bob', { Respond: { ready_at: null } }, NOW - 1),
|
|
create(),
|
|
];
|
|
const [nomination] = reduceCallToPlayEvents(events, NOW + 1);
|
|
assert(nomination, 'call should survive a pre-creation cancel');
|
|
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
|
|
});
|
|
|
|
Deno.test('terminal calls retain complete read-only history for fifteen minutes', () => {
|
|
const events = [
|
|
create(),
|
|
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
|
|
event('message', 'Bob', {
|
|
SendMessage: { text: 'Launching' },
|
|
}, NOW + 2),
|
|
event('start', 'Alice', 'Start', NOW + 3),
|
|
event('late-message', 'Bob', {
|
|
SendMessage: { text: 'Too late' },
|
|
}, NOW + 4),
|
|
];
|
|
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
|
|
assertEquals(running.state, 'running', 'running receipt remains');
|
|
assertEquals(Object.keys(running.participants).length, 2, 'terminal roster retained');
|
|
assertEquals(running.messages.length, 1, 'pre-terminal chat retained');
|
|
assertEquals(
|
|
reduceCallToPlayEvents(events, NOW + 3 + TERMINAL_RETENTION_MS + 1).length,
|
|
0,
|
|
'terminal receipt retires after display window',
|
|
);
|
|
});
|
|
|
|
Deno.test('terminal calls sort last and do not contribute to the badge', () => {
|
|
const active = reduceCallToPlayEvents([create()], NOW)[0];
|
|
const running = reduceCallToPlayEvents([
|
|
create(),
|
|
event('start', 'Alice', 'Start', NOW + 1),
|
|
], NOW + 2)[0];
|
|
const cancelled = reduceCallToPlayEvents([
|
|
create(),
|
|
event('cancel', 'Alice', 'Cancel', NOW + 2),
|
|
], NOW + 3)[0];
|
|
|
|
const sorted = sortNominations([running, cancelled, active]);
|
|
assertEquals(sorted[0].state, 'open', 'actionable call sorts first');
|
|
assertEquals(activeCallCount(sorted), 1, 'terminal calls excluded from badge');
|
|
assertEquals(statusOf(running, NOW + 2), 'running', 'running ticker status');
|
|
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
|
|
});
|
|
|
|
Deno.test('incoming Call to Play views replace removed author slices wholesale', () => {
|
|
const previous = { events: [create(), event('join', 'Bob', 'Rsvp', NOW + 1)] };
|
|
const incoming = { events: [create()] };
|
|
|
|
const replaced = replaceCallToPlayView(previous, incoming);
|
|
assertEquals(replaced.events.length, 1, 'departed author slice is removed');
|
|
assertEquals(replaced.events[0].id, 'create', 'incoming projection is authoritative');
|
|
});
|
|
|
|
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
|
|
assertEquals(normalizeTimeInput('20:00'), '20:00', 'colon format');
|
|
assertEquals(normalizeTimeInput('2000'), '20:00', 'compact format');
|
|
assertEquals(normalizeTimeInput('9:30'), '09:30', 'single-digit hour');
|
|
assertEquals(normalizeTimeInput('24:00'), null, 'invalid hour');
|
|
assertEquals(bumpTime('23:45', 'minutes', 15), '00:00', 'minute wrap');
|
|
assertEquals(bumpTime('00:00', 'hours', -1), '23:00', 'hour wrap');
|
|
});
|