`EVENTS.md` §H told a module the revert contract accepts a `detail` on its
envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`,
`resources` and `participants` — and has never read a `detail`. So a module
that answered one was writing into nothing.
`module-uo` believed it, twice, since Phase 12b:
* `uo.item.grant` answers `{ granted, missed, why }`
* `uo.world.save` answers `{ started: true }`
The grant is the one that matters. A grant reaches the players a run's
participation ledger holds, and **which of them missed out is knowable only to
the module and reported nowhere else** — so an operator saw a step marked
`done` and never learned four of twelve got nothing.
Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose
template made the same mistake on §H's authority.
## What this adds
`detail` becomes a real, optional member of the two SUCCESS envelopes, beside
`resources` and `participants` — on both, because `await: 'human'` is a success
and a cue's confirm finishes the step without a second dispatch, so that is the
only moment its module could ever have said anything.
**Core never interprets it.** `safeDetail()` bounds it and nothing else reads a
key out of it, here or in the runner or in the browser. That is the point: a
module knows things about its own verb core cannot compute, and it had no other
way to say them.
* objects only — the column is JSON and the console renders keys, so a bare
string has nothing to render under, and core inventing a key would be core
interpreting it after all;
* 4KB of serialised JSON, dropped rather than truncated, because half a JSON
object is not a JSON object;
* unserialisable (circular, a throwing `toJSON`) is dropped — reaching the
runner would make the log INSERT throw, inside the one write documented
never to;
* re-parsed rather than passed through, so core holds no live reference into
a module's object;
* **anything wrong with it is dropped and logged, never a failure.** A step
that did what it was asked must not be re-run because its module's
commentary was malformed: that is a world write repeated for a log line.
The runner writes it as a `step.detail` run-log row, its own kind rather than a
field on `resource.recorded` — the grant that forced this ledgers nothing
(`reversible: 'none'`) and reports no participants, so it would have had
nowhere to ride.
## The renderer, which is half the fix
`describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling
through would have rendered as the literal string "step.detail" — the channel
existing and showing nothing, exactly the failure being fixed. It gets a case
that renders whatever keys the module put there, generically: a switch on known
keys would be the browser learning one module's vocabulary.
uo.item.grant — granted: 8, missed: 4, why: bank full, offline
uo.world.save — started: true
**`module-uo` needs no change**: the code it already shipped starts working.
MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line
route manifest diff; no route added. 2057 server tests, 400 client tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
911 lines
38 KiB
JavaScript
911 lines
38 KiB
JavaScript
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 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('`listed` round-trips, and an unlisted event is not quietly re-listed', () => {
|
||
// The trap this guards is `||` where `??` is meant. A definition an operator
|
||
// deliberately unlisted sends `listed: false`, and `event?.listed || true`
|
||
// would put it back on the public calendar on the author's next save — a
|
||
// surprise event announced by a typo fix.
|
||
const unlisted = payloadFromForm(
|
||
formFromDefinition({ title: 'Invasion', listed: false, spec: { schedule: { kind: 'manual' }, phases: [] } }),
|
||
)
|
||
assert.equal(unlisted.payload.listed, false)
|
||
|
||
const listed = payloadFromForm(
|
||
formFromDefinition({ title: 'Invasion', listed: true, spec: { schedule: { kind: 'manual' }, phases: [] } }),
|
||
)
|
||
assert.equal(listed.payload.listed, true)
|
||
})
|
||
|
||
test('a new definition defaults to listed', () => {
|
||
// The column's own default, and the ordinary case: unlisting is the
|
||
// deliberate act, not listing.
|
||
const fresh = payloadFromForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
|
||
assert.equal(fresh.payload.listed, true)
|
||
})
|
||
|
||
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}`)
|
||
}
|
||
})
|
||
|
||
// ── The one log line core did not compose (Phase 15) ──────────────────────
|
||
|
||
test('a module detail line renders the module keys, not the kind id', () => {
|
||
// The failure this guards is subtle and total: `step.detail` falling to the
|
||
// default renders the literal string "step.detail", which is the reporting
|
||
// channel existing and showing nothing — exactly what it was built to fix.
|
||
const text = describeLogLine({
|
||
kind: 'step.detail',
|
||
detail: { action: 'uo.item.grant', granted: 8, missed: 4, why: ['bank full', 'offline'] },
|
||
})
|
||
|
||
assert.ok(!text.includes('step.detail'), `the kind id leaked into the sentence: ${text}`)
|
||
assert.match(text, /uo\.item\.grant/)
|
||
assert.match(text, /granted: 8/)
|
||
assert.match(text, /missed: 4/)
|
||
assert.match(text, /bank full/)
|
||
})
|
||
|
||
test('a module detail is rendered generically, whatever a module puts in it', () => {
|
||
// Core does not interpret these keys and neither does the renderer — a switch
|
||
// here would be the browser learning one module vocabulary, which is the thing
|
||
// the module system exists to prevent. So an unfamiliar shape still reads.
|
||
const text = describeLogLine({
|
||
kind: 'step.detail',
|
||
detail: { action: 'rust.wipe.announce', servers: { eu: 3, us: 1 }, dryRun: false, at: null },
|
||
})
|
||
assert.ok(!text.includes('undefined'), text)
|
||
assert.ok(!text.includes('[object Object]'), `a nested object rendered as a brace: ${text}`)
|
||
assert.match(text, /eu 3/)
|
||
assert.match(text, /dryRun: false/, 'false is a value, not an absence')
|
||
})
|
||
|
||
test('a long module detail stays one line', () => {
|
||
const many = Array.from({ length: 40 }, (_, i) => `player-${i}`)
|
||
const text = describeLogLine({
|
||
kind: 'step.detail',
|
||
detail: { action: 'uo.item.grant', missed: many, note: 'x'.repeat(500) },
|
||
})
|
||
assert.match(text, /and 35 more/)
|
||
assert.ok(text.length < 300, `one row should not wrap eight times: ${text.length} chars`)
|
||
})
|
||
|
||
test('a module detail with nothing in it still reads as a sentence', () => {
|
||
const text = describeLogLine({ kind: 'step.detail', detail: { action: 'uo.world.save' } })
|
||
assert.ok(text.length > 0)
|
||
assert.ok(!text.includes('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)
|
||
})
|