feat(call-to-play): retain terminal outcomes
Keep complete running and cancelled histories visible for fifteen minutes so peers retain the roster, chat, and outcome long enough to understand what happened. Compact them to terminal tombstones afterward without charging settled calls against the active-history limit. Model running and cancelled as durable read-only frontend states, exclude them from active badges, prune retired raw events, and document the lifecycle. Add peer scenario S49 to prove a late joiner reconstructs a terminal call with its roster and chat intact. Test Plan: - just fmt - just clippy - just test - just frontend-test - just build - just peer-cli-tests S48 S49 - python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py - git diff --cached --check
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
CHECKIN_LEAD_MS,
|
||||
EXPIRED_RETENTION_MS,
|
||||
TERMINAL_RETENTION_MS,
|
||||
activeCallCount,
|
||||
phaseOf,
|
||||
bumpTime,
|
||||
normalizeTimeInput,
|
||||
pruneCallToPlayEvents,
|
||||
readyCountOf,
|
||||
reduceCallToPlayEvents,
|
||||
sortNominations,
|
||||
statusOf,
|
||||
} from '../src/lib/callToPlay.ts';
|
||||
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
|
||||
@@ -148,13 +152,15 @@ Deno.test('creator can extend, start, and cancel a call', () => {
|
||||
create(),
|
||||
event('start', 'Alice', 'Start', NOW + 1),
|
||||
], NOW + 2)[0];
|
||||
assertEquals(started.state, 'started', 'creator start');
|
||||
assertEquals(started.state, 'running', 'creator start');
|
||||
assertEquals(started.terminalAt, NOW + 1, 'running timestamp');
|
||||
|
||||
const cancelled = reduceCallToPlayEvents([
|
||||
const [cancelled] = reduceCallToPlayEvents([
|
||||
create(),
|
||||
event('cancel', 'Alice', 'Cancel', NOW + 1),
|
||||
], NOW + 2);
|
||||
assertEquals(cancelled.length, 0, 'creator cancel removes call');
|
||||
assertEquals(cancelled.state, 'cancelled', 'creator cancel');
|
||||
assertEquals(cancelled.terminalAt, NOW + 1, 'cancel timestamp');
|
||||
});
|
||||
|
||||
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
|
||||
@@ -181,10 +187,75 @@ Deno.test('actions timestamped before creation cannot mutate a call', () => {
|
||||
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
|
||||
});
|
||||
|
||||
Deno.test('started calls expire from local presentation history', () => {
|
||||
const events = [create(), event('start', 'Alice', 'Start', NOW + 1)];
|
||||
assertEquals(reduceCallToPlayEvents(events, NOW + 2).length, 1, 'fresh started call remains');
|
||||
assertEquals(reduceCallToPlayEvents(events, NOW + 5_000).length, 0, 'old started call expires');
|
||||
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: { message_id: 'message-1', text: 'Launching' },
|
||||
}, NOW + 2),
|
||||
event('start', 'Alice', 'Start', NOW + 3),
|
||||
event('late-message', 'Bob', {
|
||||
SendMessage: { message_id: 'message-2', 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('retired calls are pruned from the frontend raw event map', () => {
|
||||
const terminalEvents = [
|
||||
create(),
|
||||
event('start', 'Alice', 'Start', NOW + 1),
|
||||
];
|
||||
const map = new Map(terminalEvents.map(item => [item.id, item]));
|
||||
|
||||
assertEquals(
|
||||
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
|
||||
2,
|
||||
'visible terminal history stays cached',
|
||||
);
|
||||
assertEquals(
|
||||
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
|
||||
0,
|
||||
'retired terminal history is pruned',
|
||||
);
|
||||
|
||||
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
|
||||
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
|
||||
assertEquals(
|
||||
pruneCallToPlayEvents(
|
||||
tombstoneMap,
|
||||
NOW + 1 + TERMINAL_RETENTION_MS + 1,
|
||||
).size,
|
||||
0,
|
||||
'backend tombstone is pruned too',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
|
||||
|
||||
Reference in New Issue
Block a user