Files
website/client/test/eventAuthoring.test.js
wtclaude 8453762e3b
Some checks failed
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Failing after 9m1s
feat(events): the authoring UI proper (Phase 13)
Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.

One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.

A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.

Two defects fixed in already-merged code:

  * Creating an event has been impossible since Phase 6. `events/new` was added
    beside `events/:id` and binds no param, and React Router ranks a static
    segment above a dynamic one whatever the order -- so the editor was handed no
    id and fetched /admin/events/undefined. Worse, the failure was invisible:
    `!form` is true for every failed load, so the error state sat behind a
    spinner that never stopped.
  * 12b's searchable sources had no consumer. The server half shipped and the
    only UI that reads a source never sent a term, so the 6,707-entry spawner
    list was picked from a 2,000-entry truncation with nothing saying so.

Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 16:34:03 -05:00

840 lines
35 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,
logKindWord,
runStatusWord,
describeSchedule,
scheduleFormFrom,
scheduleFromForm,
isProjected,
blankAdvance,
advanceFormFrom,
advancePayload,
WEEKDAYS,
MONTHLY_NTHS,
ADVANCE_KINDS,
blankWhere,
whereFormFrom,
paramsRenderable,
paramsMode,
paramValue,
setParam,
datetimeInputValue,
priceBodyFrom,
worthPricing,
PARAM_FORM,
PARAM_JSON,
} 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)
})
// -- The advance gate (Phase 5) ---------------------------------------------
//
// What this screen must get right is what it OFFERS. `advance` is the one
// control in this feature whose whole point is that it overrides the engine, so
// a button offered in a state the server refuses would be the "control that
// answers 409 and does nothing" this feature has refused twice.
test('advance is offered only when the phase is waiting on its gate', () => {
const gate = (over = {}) => [{ phase: 'boss', satisfied: false, ...over }]
const done = [{ phase: 'boss', status: 'done' }]
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), done).advance, true)
// A phase with an open step is held by the STEP, and skip is its control.
assert.equal(
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [...done, { phase: 'boss', status: 'pending' }]).advance,
false,
)
assert.equal(
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [{ phase: 'boss', status: 'running' }]).advance,
false,
)
// A phase with no gate advances on its steps and always has.
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, [], done).advance, false)
// A gate already satisfied is not waiting.
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate({ satisfied: true }), done).advance, false)
// And a run that is not running is waiting on nothing.
for (const status of ['scheduled', 'starting', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
assert.equal(runControlsFor({ status, currentPhase: 'boss' }, gate(), done).advance, false, status)
}
})
test('runControlsFor still answers with no gates or steps at all', () => {
// The three Phase 3 controls were called with one argument for two phases, and
// the calendar still calls it that way.
const controls = runControlsFor({ status: 'running', currentPhase: 'boss' })
assert.equal(controls.pause, true)
assert.equal(controls.advance, false)
})
test('a gate round-trips through the form without losing the other shape', () => {
assert.deepEqual(advanceFormFrom(null), blankAdvance())
assert.equal(advanceFormFrom({ after: '2h' }).kind, 'after')
assert.equal(advanceFormFrom({ after: '2h' }).after, '2h')
const on = advanceFormFrom({ on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 3 })
assert.equal(on.kind, 'on')
assert.equal(on.count, 3)
assert.deepEqual(JSON.parse(on.whereText), { variable: 'region', cmp: 'eq', value: 'Yew' })
// The dropdown's three options, and the empty one is what nearly every phase
// is — so it is first and it is not called "none".
assert.equal(ADVANCE_KINDS[0].value, '')
})
test('advancePayload sends one shape, built from the builder\u2019s rows', () => {
const errors = []
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
assert.deepEqual(
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', ...blankWhere() }, 'Phase 1', errors),
{ on: 'uo.champ.boss_up', count: 2 },
'an empty predicate is omitted, not sent as an empty object',
)
assert.equal(errors.length, 0)
// Whether the predicate is VALID is still the server's answer, named variable
// and all \u2014 the builder only offers what the trigger declares, and a variable
// that has gone away comes back named from the save.
assert.deepEqual(
advancePayload(
{
kind: 'on',
on: 'x',
count: 1,
...blankWhere(),
whereRows: [{ variable: 'nope', cmp: 'eq', value: '1' }],
},
'Phase 1',
errors,
[{ name: 'nope', type: 'int' }],
),
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
)
assert.equal(errors.length, 0)
})
test('the builder coerces each literal to the type the trigger declared', () => {
// The trap this closes: every value in an HTML input is a string, and
// `{ cmp: 'gt', value: "5" }` against an int variable is refused by
// engagement/conditions.js. Without this the author reads an error about JSON
// rather than about what they typed.
const built = advancePayload(
{
kind: 'on',
on: 'x',
count: 1,
...blankWhere(),
whereOp: 'or',
whereRows: [
{ variable: 'tier', cmp: 'gte', value: '3' },
{ variable: 'region', cmp: 'in', value: 'Yew, Britain' },
],
},
'Phase 1',
[],
[{ name: 'tier', type: 'int' }, { name: 'region', type: 'string' }],
)
assert.deepEqual(built.where, {
op: 'or',
nodes: [
{ variable: 'tier', cmp: 'gte', value: 3 },
{ variable: 'region', cmp: 'in', value: ['Yew', 'Britain'] },
],
})
})
test('a predicate the builder cannot render is posted back unchanged, not flattened', () => {
// `A and (B or C)` is not `A and B and C` \u2014 they fire on different events \u2014
// and an author would have no way to know the save had done it. The condition
// builder's own rule, and this is the same function.
const nested = {
op: 'and',
nodes: [
{ variable: 'region', cmp: 'eq', value: 'Yew' },
{ op: 'or', nodes: [{ variable: 'tier', cmp: 'eq', value: 1 }, { variable: 'tier', cmp: 'eq', value: 2 }] },
],
}
const form = whereFormFrom(nested)
assert.equal(form.whereEditable, false)
assert.deepEqual(form.whereRows, [])
const errors = []
const built = advancePayload({ kind: 'on', on: 'x', count: 1, ...form }, 'Phase 1', errors)
assert.deepEqual(built.where, nested, 'the tree survives a screen that cannot draw it')
assert.equal(errors.length, 0)
// And the text is still the thing that can fail to parse, which is the only
// reason this path keeps an error channel at all.
advancePayload(
{ kind: 'on', on: 'x', count: 1, whereEditable: false, whereText: '{ not json' },
'Phase 2 "Boss"',
errors,
)
assert.equal(errors.length, 1)
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
})
test('a phase with no gate sends no `advance` key', () => {
const form = formFromDefinition({
title: 'x',
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
})
const built = payloadFromForm(form)
assert.equal(built.ok, true)
assert.equal('advance' in built.payload.spec.phases[0], false)
})
test('an authored gate survives the round trip through the form', () => {
const form = formFromDefinition({
title: 'x',
spec: {
schedule: { kind: 'manual' },
phases: [
{ key: 'boss', label: 'Boss', steps: [], advance: { on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 } },
{ key: 'loot', label: 'Loot', steps: [], advance: { after: '10m' } },
],
},
})
const built = payloadFromForm(form)
assert.equal(built.ok, true)
assert.deepEqual(built.payload.spec.phases[0].advance, {
on: 'uo.champ.boss_up',
where: { variable: 'region', cmp: 'eq', value: 'Yew' },
count: 2,
})
assert.deepEqual(built.payload.spec.phases[1].advance, { after: '10m' })
})
test('the log renders Phase 5\'s three kinds, including the near miss', () => {
assert.match(
describeLogLine({ kind: 'phase.gate', phase: 'boss', detail: { kind: 'on', trigger: 'uo.champ.boss_up', needed: 2, where: 'region is "Yew"' } }),
/boss advances on 2 × uo\.champ\.boss_up where region is "Yew"/,
)
assert.match(describeLogLine({ kind: 'phase.gate', phase: 'loot', detail: { kind: 'after', after: '10m' } }), /loot advances 10m after it started/)
assert.match(
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: false, seen: 0, needed: 2 } }),
/did not count — 0 of 2/,
)
assert.match(
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: true, seen: 2, needed: 2, satisfied: true } }),
/counted — 2 of 2, condition met/,
)
assert.match(
describeLogLine({ kind: 'phase.advanced', phase: 'boss', detail: { because: 'forced', waitedSeconds: 4080, reason: 'never spawned' } }),
/boss advanced by hand after 4080s: never spawned/,
)
assert.match(
describeLogLine({ kind: 'phase.advanced', phase: 'loot', detail: { because: 'elapsed', waitedSeconds: 600 } }),
/loot advanced on its deadline after 600s/,
)
})
test("the log renders Phase 6's three kinds, and a refusal does not read as a failure", () => {
// The distinction the whole kind exists for. An operator scanning a stopped run
// has to be able to see that nothing is broken — the deployment simply does not
// permit what the author asked for — and the answer differs by cause: a switch
// for "not enabled", a number for "over the cap".
assert.match(
describeLogLine({
kind: 'step.refused',
detail: { action: 'uo.creature.spawn', error: 'asks for 12 of "uo.creatures"; 28 of 30 is already spent this run' },
}),
/uo\.creature\.spawn refused: asks for 12 of "uo\.creatures"; 28 of 30 is already spent this run/,
)
assert.match(
describeLogLine({
kind: 'step.refused',
detail: { action: 'uo.creature.spawn', error: '"Spawn creatures" is not enabled on this deployment' },
}),
/refused: "Spawn creatures" is not enabled/,
)
assert.equal(logKindWord('step.refused'), 'Refused')
// The caps a run was seeded with, and which switch set each — so a number on
// the meter can be traced back to something an operator can change.
assert.match(
describeLogLine({
kind: 'run.budget',
detail: { dimensions: [{ dimension: 'uo.creatures', cap: 30, from: 'uo.creature.spawn' }] },
}),
/uo\.creatures capped at 30 \(uo\.creature\.spawn\)/,
)
assert.match(
describeLogLine({ kind: 'run.budget', detail: { dimensions: [{ dimension: 'uo.gate.minutes', cap: null, from: null }] } }),
/uo\.gate\.minutes capped at nothing/,
)
// A run with no capped dimension at all still gets a sentence rather than an
// empty line, because an empty log entry reads as a bug.
assert.match(describeLogLine({ kind: 'run.budget', detail: { dimensions: [] } }), /no caps apply to this run/)
assert.match(
describeLogLine({ kind: 'version.verified', detail: { versionId: 4, version: 2, by: 1 } }),
/Version 2 passed its dry run — scheduled occurrences may start/,
)
})
// ── Step params as a form (Phase 13) ──────────────────────────────
//
// The form is not a boundary either — `events/spec.js` still decides what may be
// saved. What is tested here is the thing that would be wrong SILENTLY: a form
// that drops a param it cannot draw, or writes a value the author never typed.
const spawn = {
id: 'test.spawn',
label: 'Spawn',
params: [
{ name: 'creature', type: 'string', required: true, example: 'orc', source: 'test.creatures' },
{ name: 'count', type: 'int', required: true, example: 8 },
{ name: 'tame', type: 'boolean', required: false, example: false },
{ name: 'at', type: 'datetime', required: false, example: '2026-09-07T20:00:00.000Z' },
],
}
const stepWith = (params, over = {}) => ({
actionId: 'test.spawn',
paramsText: JSON.stringify(params, null, 2),
...over,
})
test('a step whose params the form can hold opens as a form', () => {
const mode = paramsMode(stepWith({ creature: 'orc', count: 8 }), spawn)
assert.deepEqual(mode, { mode: PARAM_FORM, forced: false, reason: null })
})
test('an author who chose JSON stays in JSON', () => {
const mode = paramsMode(stepWith({ creature: 'orc' }, { paramsMode: PARAM_JSON }), spawn)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, false, 'their choice, so no reason is shown')
})
test('a param the action does not declare FORCES the JSON box and says which', () => {
// The form would render four fields and post four values, having deleted
// `radius` — a save that looks clean and means something else. The save path
// refuses it by name, which is what the author needs to see.
const mode = paramsMode(stepWith({ creature: 'orc', count: 8, radius: 12 }), spawn)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, true)
assert.match(mode.reason, /carries "radius", which test\.spawn does not declare/)
})
test('a value no single control can hold forces the JSON box', () => {
assert.match(paramsMode(stepWith({ creature: ['orc', 'troll'] }), spawn).reason, /holds a list/)
assert.match(paramsMode(stepWith({ creature: { id: 'orc' } }), spawn).reason, /holds a structure/)
})
test('a dormant step is edited as JSON, because there is no declaration to draw', () => {
const mode = paramsMode(stepWith({ creature: 'orc' }), undefined)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, true)
assert.match(mode.reason, /not installed/)
})
test('a params box that is not JSON opens as JSON with the parse error', () => {
const mode = paramsMode({ actionId: 'test.spawn', paramsText: '{ not json' }, spawn)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, true)
assert.match(mode.reason, /not valid JSON/)
})
test('paramsRenderable accepts a step with nothing in it', () => {
// A brand-new step with an optional-only action, and the empty case a form
// needs to survive before anybody has typed.
assert.deepEqual(paramsRenderable(spawn, {}), { ok: true })
})
test('setParam writes the type the param declared, not the string the input held', () => {
const step = stepWith({ creature: 'orc', count: 8 })
assert.deepEqual(JSON.parse(setParam(step, 'count', '12', 'int')), { creature: 'orc', count: 12 })
assert.deepEqual(JSON.parse(setParam(step, 'tame', 'true', 'boolean')), {
creature: 'orc',
count: 8,
tame: true,
})
})
test('a half-typed number is kept as typed rather than turned into NaN', () => {
// `coerceLiteral`'s rule, and the reason it is borrowed rather than rewritten:
// turning `-` into NaN while somebody types would either post a value they
// never wrote or make a negative impossible to enter. The server's type check
// then names the param.
const step = stepWith({ count: 8 })
assert.deepEqual(JSON.parse(setParam(step, 'count', '-', 'int')), { count: '-' })
})
test('clearing a field REMOVES the key rather than posting an empty string', () => {
// `checkParams` treats undefined, null and '' alike — absent — so a required
// param left blank comes back as "is required", which is the error the author
// needs, instead of a type complaint about "".
const step = stepWith({ creature: 'orc', count: 8 })
assert.deepEqual(JSON.parse(setParam(step, 'creature', '', 'string')), { count: 8 })
})
test('setParam leaves an unparseable box alone rather than overwriting it', () => {
// The only way to reach this is a race between the mode switch and a
// keystroke; silently replacing the text with `{ "count": 1 }` would destroy
// whatever the author was midway through writing.
const step = { actionId: 'test.spawn', paramsText: '{ not json' }
assert.equal(setParam(step, 'count', '1', 'int'), '{ not json')
})
test('paramValue reads one param, and answers nothing for a box that does not parse', () => {
assert.equal(paramValue(stepWith({ count: 8 }), 'count'), 8)
assert.equal(paramValue(stepWith({ count: 8 }), 'creature'), undefined)
assert.equal(paramValue({ paramsText: '{ not json' }, 'count'), undefined)
})
test('a datetime is sliced to what the input wants, and anything else is empty', () => {
assert.equal(datetimeInputValue('2026-09-07T20:00:00.000Z'), '2026-09-07T20:00')
assert.equal(datetimeInputValue(undefined), '')
assert.equal(datetimeInputValue(12), '')
})
// ── The meter's request (Phase 13) ────────────────────────────
test('the price body carries the plan and nothing else', () => {
const form = formFromDefinition({
title: 'Invasion',
spec: {
schedule: { kind: 'manual' },
phases: [
{ key: 'warn', label: 'Warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
{ key: 'assault', label: 'Assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
],
},
})
assert.deepEqual(priceBodyFrom(form), {
phases: [
{ key: 'warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
{ key: 'assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
],
})
})
test('a step whose params do not parse is priced with none rather than dropped', () => {
// Dropping it would move every step after it up an ordinal, so the meter's
// "phase 2 step 3" would name a different step from the one on the screen.
const form = {
phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', paramsText: '{ not json' }] }] ,
}
assert.deepEqual(priceBodyFrom(form).phases[0].steps, [{ actionId: 'test.spawn', params: {} }])
})
test('an empty plan is not worth pricing', () => {
// Otherwise the meter asks the server what nothing costs on every keystroke of
// the title field.
assert.equal(worthPricing({ phases: [] }), false)
assert.equal(worthPricing({ phases: [{ steps: [] }] }), false)
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: '' }] }] }), false)
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: 'test.spawn' }] }] }), true)
})