feat(events): the authoring UI proper (Phase 13)
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

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
This commit is contained in:
2026-09-07 16:34:03 -05:00
parent db8e01e868
commit 8453762e3b
13 changed files with 1913 additions and 149 deletions

View File

@@ -23,6 +23,17 @@ import {
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
@@ -479,29 +490,97 @@ test('a gate round-trips through the form without losing the other shape', () =>
assert.equal(ADVANCE_KINDS[0].value, '')
})
test('advancePayload sends one shape, and only reports a JSON error', () => {
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', whereText: '' }, 'Phase 1', errors),
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)
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 = []
// 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, whereText: '{"variable":"nope","cmp":"eq","value":1}' }, 'Phase 1', clean),
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(clean.length, 0)
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', () => {
@@ -602,3 +681,159 @@ test("the log renders Phase 6's three kinds, and a refusal does not read as a fa
/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)
})