/** * DWELL BY KIND — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5. * * The classification is exhaustive over `Intent['type']` at COMPILE time: `kindOf` declares a * `StepKind` return and has no `default`, so a new intent breaks the build rather than landing * silently in a fallback tier. These tests add the part the compiler cannot do — they read the * intent union out of the source, so the guard survives someone later adding a `default:` that * would swallow the very thing the exhaustiveness was protecting. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { DWELL, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts'; import type { StepKind } from '../src/sim/pacing.ts'; import type { Intent } from '../src/engine/intents.ts'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); /** Every `type: '…'` literal in the Intent union, read from the source rather than hand-listed. */ function declaredIntents(): string[] { const src = readFileSync(join(root, 'src/engine/intents.ts'), 'utf8'); return [...new Set([...src.matchAll(/type: '([a-zA-Z.]+)'/g)].map((m) => m[1]!))].sort(); } const KINDS: StepKind[] = ['switching', 'action', 'phase', 'bookkeeping']; describe('pacing — dwell by kind', () => { it('classifies every intent the engine declares', () => { const declared = declaredIntents(); assert.ok(declared.length > 25, `only found ${declared.length} intents — the parse is wrong`); for (const intent of declared) { const kind = kindOf(intent as Intent['type']); assert.ok( KINDS.includes(kind), `${intent} classified as "${kind}", which is not a StepKind — a default case has crept in`, ); } }); it('protects switching and collapses bookkeeping', () => { // The two ends of the measured argument: a switching move is the thing worth watching, and // `*.end` bookkeeping is over half of a real game's intents. assert.equal(kindOf('switch.move'), 'switching'); assert.equal(kindOf('switch.dropCars'), 'switching'); assert.equal(kindOf('switch.sortConsist'), 'switching'); assert.equal(kindOf('draw.end'), 'bookkeeping'); assert.equal(kindOf('loadUnload.end'), 'bookkeeping'); assert.equal(kindOf('switch.end'), 'bookkeeping'); assert.equal(kindOf('localOps.choose'), 'bookkeeping'); assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action'); assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all'); }); it('starts switching at a full second, per the 2026-09-09 decision', () => { // Jesse: "start at 1s and tune down". Pinned so a later tune is a deliberate edit rather than // a drift, and so the number in the plan and the number in the code cannot disagree. assert.equal(DWELL.switching, 1000); assert.equal(dwellFor('switch.move'), 1000); }); it('scales with the viewer\'s pace, and 0 turns it off', () => { assert.equal(dwellFor('switch.move', 1), 1000); assert.equal(dwellFor('switch.move', 0.5), 500); assert.equal(dwellFor('switch.move', 2), 2000); // TODO #18's "a player who has seen it a hundred times will want it off" — no second mechanism. for (const intent of declaredIntents()) { assert.equal(dwellFor(intent as Intent['type'], 0), 0, `${intent} still dwells at pace 0`); } // A negative pace is a corrupt preference, not a request to run time backwards. assert.equal(dwellFor('switch.move', -3), 0); }); it('counts only the steps a player will actually watch', () => { /** * The counter's whole point. A backlog of 17 where 12 are bookkeeping must read "5", not "17" * followed by an instant plummet to 5 — the countdown is meant to be steady enough to decide * whether to press Skip. */ const queue: Intent['type'][] = [ ...Array(12).fill('draw.end'), ...Array(5).fill('switch.move'), ]; assert.equal(queue.length, 17); assert.equal(watchableCount(queue), 5); assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind'); }); it('a silent step beats only when the clock turns over — TODO #18', () => { /** * Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past * phases that moved trains without saying so, killing the very thing #18 asks for. "Anything * that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6 * times per intent — which came to a quarter of an hour a game. */ const silent = { cause: 'phase' as const, lines: [] as string[] }; assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing'); assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing'); assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase); assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase); // Narration always earns the dwell of whatever caused it, clock or no clock. assert.equal( dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }), DWELL.switching, ); }); it('a real switching turn is watchable in a few seconds, not tens of them', () => { // Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary // case for one crew and the arithmetic the design promised: ~6s to watch a whole exercise. const turn: Intent['type'][] = [ 'localOps.choose', ...Array(6).fill('switch.move'), 'switch.end', ]; const total = turn.reduce((ms, i) => ms + dwellFor(i), 0); assert.equal(total, 6000); assert.equal(watchableCount(turn), 6, 'the choose and the end are not things to watch'); }); });