Files
website/client/test/eventAuthoring.test.js
wtclaude 9bc0bf5a3d
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s
feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.

`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.

One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.

A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-02 22:11:20 -05:00

560 lines
24 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,
blankAdvance,
advanceFormFrom,
advancePayload,
WEEKDAYS,
MONTHLY_NTHS,
ADVANCE_KINDS,
} 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, and only reports a JSON error', () => {
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', whereText: '' }, '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)
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{ not json' }, 'Phase 2 "Boss"', errors)
assert.equal(errors.length, 1)
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
// Whether the predicate is VALID is the server's answer, named variable and
// all. This only refuses text that cannot be put in a request.
const clean = []
assert.deepEqual(
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{"variable":"nope","cmp":"eq","value":1}' }, 'Phase 1', clean),
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
)
assert.equal(clean.length, 0)
})
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/,
)
})