Files
website/client/test/eventAuthoring.test.js
wtclaude 6e73660b52
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s
feat(events): schedule, recurrence and the calendar (Phase 4)
The four closed recurrence shapes computed in the definition's own IANA zone,
a fourteen-day materialisation horizon with projections beyond it, series as a
managed thing, and the admin calendar that replaces the plugin this feature
exists to replace. An event now happens on its own.

No schema change: Phase 1 built every column this needed.

- events/recurrence.js is the ONE place an occurrence is computed, so the
  runner's expansion and the calendar's forecast cannot disagree. No date
  library added — Node ships the tzdata one would vendor, behind Intl.
- The runner's materialise leg is now two halves: expand, then sweep. The
  window starts at `now - grace`, so an occurrence nobody could have seen is
  never invented retroactively; the horizon is what makes the missed sweep
  mean anything for a recurrence.
- Publishing is the schedule switch and archiving turns it off, and publishing
  re-pins every occurrence that has not started.
- A projection is never drawn over an instant a run occupies, so a cancelled
  occurrence does not reappear as a forecast.

54 new tests, incl. the DST fixture set the plan asked for and three new
statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail
(pre-existing CRLF). Walked end to end on the local review stack.

Docs: RunicGateway/docs#PENDING

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 16:10:16 -05:00

419 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
runControlsFor,
stepControlsFor,
isParked,
lastStartedSeqOf,
formFromDefinition,
payloadFromForm,
parseParams,
blankStep,
blankPhase,
describeLogLine,
runStatusWord,
describeSchedule,
scheduleFormFrom,
scheduleFromForm,
isProjected,
WEEKDAYS,
MONTHLY_NTHS,
} 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 actions 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 runners 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')
})
// ── The schedule form (Phase 4) ─────────────────────────────────────
//
// The form is the whole argument against cron: a closed set of four shapes has a
// dropdown, and a dropdown can be proofread. What is checked here is that the
// round trip through the form does not quietly change what the author wrote —
// the server would refuse a malformed schedule, but it cannot refuse a
// well-formed one that says something the author did not mean.
test('a schedule survives the round trip through the form unchanged', () => {
for (const schedule of [
{ kind: 'manual' },
{ kind: 'once', at: '2026-10-31T20:00' },
{ kind: 'weekly', days: ['monday', 'friday'], time: '20:00' },
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
]) {
const form = scheduleFormFrom(schedule)
assert.deepEqual(scheduleFromForm(form), schedule, JSON.stringify(schedule))
}
})
test('switching kind keeps the other shapes fields, and sends only the chosen one', () => {
// An author who clicks Weekly, then Monthly, then back must not find the days
// they picked gone — but the request body must still be a single clean shape,
// not a union of everything they touched.
const form = { ...scheduleFormFrom({ kind: 'weekly', days: ['friday'], time: '20:00' }), scheduleKind: 'monthly' }
const sent = scheduleFromForm(form)
assert.deepEqual(Object.keys(sent).sort(), ['kind', 'nth', 'time', 'weekday'])
assert.equal(form.scheduleDays.includes('friday'), true)
})
test('formFromDefinition carries the whole schedule, not only its kind', () => {
const form = formFromDefinition({
title: 'Fishing contest',
timezone: 'Europe/Berlin',
spec: {
schedule: { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
phases: [{ key: 'main', label: 'Main', steps: [] }],
},
})
assert.equal(form.scheduleKind, 'monthly')
assert.equal(form.scheduleNth, '-1')
assert.equal(form.scheduleWeekday, 'friday')
assert.equal(form.scheduleTime, '19:30')
const built = payloadFromForm(form)
assert.equal(built.ok, true)
assert.deepEqual(built.payload.spec.schedule, {
kind: 'monthly',
nth: -1,
weekday: 'friday',
time: '19:30',
})
})
test('a definition with no schedule at all reads as manual rather than as broken', () => {
const form = formFromDefinition({ title: 'x', spec: { phases: [] } })
assert.equal(form.scheduleKind, 'manual')
assert.deepEqual(scheduleFromForm(form), { kind: 'manual' })
})
test('every schedule describes as a sentence, and a half-built one says what is missing', () => {
assert.match(describeSchedule({ kind: 'manual' }), /by hand/)
assert.equal(
describeSchedule({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
'Every Friday and Saturday at 20:00 (Europe/Berlin)',
)
assert.equal(
describeSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
'The last Friday of every month at 19:30 (Asia/Kolkata)',
)
// Half-built is the state the preview spends most of its life in — an author
// is typing. It must prompt, never render "undefined".
for (const partial of [
{ kind: 'weekly', days: [], time: '20:00' },
{ kind: 'weekly', days: ['friday'], time: '' },
{ kind: 'monthly', nth: 1, weekday: '', time: '19:00' },
{ kind: 'once', at: '' },
]) {
const text = describeSchedule(partial, 'UTC')
assert.ok(text.length > 0)
assert.ok(!text.includes('undefined'), `${JSON.stringify(partial)} rendered: ${text}`)
assert.match(text, /choose|no date/i)
}
})
test('the weekday and nth vocabularies match the server', () => {
// Verbatim `events/recurrence.js`. A client list that drifted would offer a
// value the server refuses, which is exactly the class of failure this file
// exists to catch.
assert.deepEqual(WEEKDAYS, [
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
])
assert.deepEqual(MONTHLY_NTHS.map((n) => n.value), [1, 2, 3, 4, -1])
})
test('a projection is told apart from a run, because only one of them can be acted on', () => {
assert.equal(isProjected({ kind: 'projected', runId: null }), true)
assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
assert.equal(isProjected(null), false)
})