import { test } from 'node:test' import assert from 'node:assert/strict' import { runControlsFor, stepControlsFor, isParked, lastStartedSeqOf, formFromDefinition, payloadFromForm, parseParams, blankStep, blankPhase, describeLogLine, runStatusWord, } from '../src/lib/eventAuthoring.js' // lib/eventAuthoring.js — what the three Events screens say and what they let // staff press (EVENTS.md §I, Phase 3). // // None of this is a boundary: `events/spec.js` decides what may be saved and the // six control statements decide what may happen to a run, each of them a // compare-and-set that re-checks the status this file only predicted. // // **The controls get most of the tests, and the reason is worth stating.** A // button offered that the server refuses is not a wrong write — but it is the // failure an operator meets at 2am, on the screen they opened because something // is already going wrong, about the run they are trying to stop. So the guards // are deliberately written twice and this is where the copy is checked against // the original. const run = (over = {}) => ({ id: 1, status: 'running', currentPhase: 'main', ...over }) const step = (over = {}) => ({ id: 10, phase: 'main', seq: 0, status: 'pending', parked: false, ...over, }) // ── The run controls ─────────────────────────────────────────────────────── test('pause is offered only for a run in flight', () => { assert.equal(runControlsFor(run({ status: 'running' })).pause, true) assert.equal(runControlsFor(run({ status: 'starting' })).pause, true) // A scheduled occurrence that should not happen is cancelled, not paused: // resuming one after its grace window would produce a `missed` from a button // labelled resume. assert.equal(runControlsFor(run({ status: 'scheduled' })).pause, false) assert.equal(runControlsFor(run({ status: 'paused' })).pause, false) }) test('cancel is offered right up to the moment a run goes terminal, and never after', () => { for (const status of ['scheduled', 'starting', 'running', 'paused', 'ending']) { assert.equal(runControlsFor(run({ status })).cancel, true, `${status} should be cancellable`) } for (const status of ['completed', 'cancelled', 'failed', 'missed']) { assert.equal(runControlsFor(run({ status })).cancel, false, `${status} should not be`) } }) test('resume is offered for exactly one status', () => { assert.equal(runControlsFor(run({ status: 'paused' })).resume, true) assert.equal(runControlsFor(run({ status: 'running' })).resume, false) }) // ── The step controls ────────────────────────────────────────────────────── test('a parked step is running with nothing holding it, and only that', () => { assert.equal(isParked(step({ status: 'running', parked: true })), true) assert.equal(isParked(step({ status: 'running', parked: false })), false, 'a live lease is a dispatch') assert.equal(isParked(step({ status: 'pending', parked: true })), false) }) test('confirm is offered for a parked cue and for nothing else', () => { const r = run() const parked = step({ status: 'running', parked: true }) assert.equal(stepControlsFor(r, parked, [parked]).confirm, true) const dispatching = step({ status: 'running', parked: false }) assert.equal(stepControlsFor(r, dispatching, [dispatching]).confirm, false) const pending = step() assert.equal(stepControlsFor(r, pending, [pending]).confirm, false) }) test('skip is offered for a pending step and a parked cue', () => { const r = run() const pending = step() const parked = step({ id: 11, seq: 1, status: 'running', parked: true }) const dispatching = step({ id: 12, seq: 2, status: 'running', parked: false }) const failed = step({ id: 13, seq: 3, status: 'failed' }) const steps = [pending, parked, dispatching, failed] assert.equal(stepControlsFor(r, pending, steps).skip, true) assert.equal(stepControlsFor(r, parked, steps).skip, true) assert.equal(stepControlsFor(r, dispatching, steps).skip, false) // A failed step does not need skipping: the runner already steps over it, so // resuming the run carries the phase past it. assert.equal(stepControlsFor(r, failed, steps).skip, false) }) test('retry is offered for the failed step a paused run is stopped at', () => { const r = run({ status: 'paused' }) const done = step({ id: 1, seq: 0, status: 'done' }) const failed = step({ id: 2, seq: 1, status: 'failed' }) const pending = step({ id: 3, seq: 2, status: 'pending' }) const steps = [done, failed, pending] assert.equal(stepControlsFor(r, failed, steps).retry, true) assert.equal(stepControlsFor(r, done, steps).retry, false) assert.equal(stepControlsFor(r, pending, steps).retry, false) }) test('retry is NOT offered for a failed step the run has moved past', () => { // The case the server guard exists for, and the one this copy of it has to // agree about: a phase that carried on past an `on_failure: skip` failure and // then paused at a later step. Offering retry on the first would re-queue a row // behind the runner's own cursor, where it sits pending for ever. const r = run({ status: 'paused' }) const skippedOver = step({ id: 1, seq: 0, status: 'failed' }) const carriedOn = step({ id: 2, seq: 1, status: 'done' }) const stoppedAt = step({ id: 3, seq: 2, status: 'failed' }) const notYet = step({ id: 4, seq: 3, status: 'pending' }) const steps = [skippedOver, carriedOn, stoppedAt, notYet] assert.equal(stepControlsFor(r, skippedOver, steps).retry, false) assert.equal(stepControlsFor(r, stoppedAt, steps).retry, true) }) test('retry is not offered while the run is still running, or in a phase it has left', () => { const failed = step({ status: 'failed' }) assert.equal(stepControlsFor(run({ status: 'running' }), failed, [failed]).retry, false) const old = step({ phase: 'one', status: 'failed' }) const r = run({ status: 'paused', currentPhase: 'two' }) assert.equal(stepControlsFor(r, old, [old]).retry, false) }) test('no control is offered on a run that is over', () => { for (const status of ['completed', 'cancelled', 'failed', 'missed']) { const parked = step({ status: 'running', parked: true }) assert.deepEqual(stepControlsFor(run({ status }), parked, [parked]), { confirm: false, skip: false, retry: false, }) } }) test('lastStartedSeqOf is the furthest step of the phase, and null when none has run', () => { const steps = [ step({ id: 1, seq: 0, status: 'failed' }), step({ id: 2, seq: 1, status: 'done' }), step({ id: 3, seq: 2, status: 'pending' }), step({ id: 4, seq: 0, phase: 'other', status: 'done' }), ] assert.equal(lastStartedSeqOf(steps, 'main'), 1) assert.equal(lastStartedSeqOf([step({ status: 'pending' })], 'main'), null) assert.equal(lastStartedSeqOf(steps, 'nothing-here'), null) }) // ── The definition form ──────────────────────────────────────────────────── const ANNOUNCE = { id: 'core.announce', label: 'Announce', risk: 'notify', params: [ { name: 'leg', type: 'string', required: true, example: 'discord' }, { name: 'title', type: 'string', required: false, example: 'The gates open' }, { name: 'body', type: 'string', required: true, example: 'A caravan was sighted.' }, ], } test('a new step arrives prefilled from the action’s declared examples', () => { const fresh = blankStep(ANNOUNCE) assert.equal(fresh.actionId, 'core.announce') assert.deepEqual(JSON.parse(fresh.paramsText), { leg: 'discord', title: 'The gates open', body: 'A caravan was sighted.', }) }) test('a new phase never collides with an existing key', () => { // Two phases sharing a key would silently collapse at materialisation — // `event_run_steps` is UNIQUE on (run_id, phase, seq) — so half the authored // steps would never exist. The server refuses it; the form must not propose it. const first = blankPhase([]) const second = blankPhase([first]) const third = blankPhase([first, second]) assert.equal(new Set([first.key, second.key, third.key]).size, 3) }) test('the form round-trips a definition without losing a step', () => { const event = { title: 'Invasion', graceSeconds: 600, timezone: 'Europe/Berlin', concurrencyKey: 'invasion:{region}', spec: { schedule: { kind: 'manual' }, phases: [ { key: 'warn', label: 'Warning', steps: [ { actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } }, { actionId: 'core.wait', params: { seconds: 300 } }, ], }, ], }, } const built = payloadFromForm(formFromDefinition(event)) assert.equal(built.ok, true) assert.deepEqual(built.payload.spec.phases, [ { key: 'warn', label: 'Warning', steps: [ { actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } }, { actionId: 'core.wait', params: { seconds: 300 } }, ], }, ]) assert.equal(built.payload.graceSeconds, 600) assert.equal(built.payload.concurrencyKey, 'invasion:{region}') }) test('an unchosen onFailure is omitted rather than invented', () => { // The server defaults it from the action's risk class, which is the whole // reason `risk` is required at registration. A form that posted a value would // silently override that — turning a `change` action's `pause` into a `skip` // and advancing a run over a half-changed world. const form = formFromDefinition({ spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] }, }) const built = payloadFromForm(form) assert.equal('onFailure' in built.payload.spec.phases[0].steps[0], false) }) test('a params box that is not JSON is refused with the step named', () => { const form = formFromDefinition({ spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] }, }) form.phases[0].steps[0].paramsText = '{ leg: discord }' const built = payloadFromForm(form) assert.equal(built.ok, false) assert.match(built.errors[0], /Phase 1 "Main", step 1/) }) test('an empty params box is an empty object, not an error', () => { assert.deepEqual(parseParams('').params, {}) assert.deepEqual(parseParams(' ').params, {}) assert.ok(parseParams('[1,2]').error, 'an array is not a params object') assert.ok(parseParams('"leg"').error) }) // ── Rendering what happened ──────────────────────────────────────────────── test('a human transition reads differently from the runner’s own', () => { // Both are `run.status` rows. `detail.control` is the only thing that separates // "the runner paused this because a world write failed" from "somebody pressed // pause", and the console has to tell them apart at a glance. const byRunner = describeLogLine({ kind: 'run.status', detail: { from: 'running', to: 'paused', because: 'core.spawn' }, }) const byPerson = describeLogLine({ kind: 'run.status', detail: { from: 'running', to: 'paused', control: 'pause', by: 4, reason: 'shard is lagging' }, }) assert.match(byRunner, /Running → Paused/) assert.match(byRunner, /core\.spawn/) assert.match(byPerson, /pause/) assert.match(byPerson, /by staff/) assert.match(byPerson, /shard is lagging/) }) test('the log lines a run produces all render as something', () => { const lines = [ { kind: 'run.created', detail: { version: 3, rehearsal: true } }, { kind: 'run.blocked', detail: { heldBy: 9, concurrencyKey: 'invasion:Yew' } }, { kind: 'run.health', detail: { to: 'degraded', because: 'core.announce' } }, { kind: 'phase.entered', phase: 'warn', detail: { steps: 2 } }, { kind: 'phase.completed', phase: 'warn', detail: {} }, { kind: 'step.parked', detail: { action: 'core.cue' } }, { kind: 'step.retry', detail: { action: 'core.announce', attempt: 1, of: 3, error: 'timeout' } }, { kind: 'step.status', detail: { action: 'core.wait', to: 'done' } }, { kind: 'note', detail: {} }, ] for (const line of lines) { const text = describeLogLine(line) assert.equal(typeof text, 'string') assert.ok(text.length > 0, `${line.kind} rendered as nothing`) assert.ok(!text.includes('undefined'), `${line.kind} rendered an undefined: ${text}`) } }) test('every run status has a word, and an unknown one falls through rather than blanking', () => { for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) { assert.ok(runStatusWord(s).length > 0) } assert.equal(runStatusWord('something-new'), 'something-new') })