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
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user