Files
website/server/test/eventParamMultiple.test.js
wtclaude 743f1bc8b2
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 47s
PR Checks / client-build (pull_request) Successful in 48s
PR Checks / server-tests (pull_request) Successful in 13m55s
feat(events): a param that holds several values (MODULE_API 1.14.0)
Rust PLAN_REDESIGNS §12.3 (D344): an event action param may declare
`multiple: true`, with an optional `max` (1-100, 20 when left out), on a
`string` or `int` param. The permission reward steps take several permissions
in one step through it.

- registries: the flag and `max` are checked at registration and served in the
  catalog; refused on other types, on a duration, on a template's inputs, and
  with a list as the example. A param that does not say it reads back exactly
  as declared, without the keys.
- spec: the value is a JSON array, each item the type's literal, duplicates
  dropped in order, at most `max`; a bare value is a list of one; `required`
  means at least one; an empty item is refused.
- authorize: a multiple param's example is priced as a list of one, since
  cost() is handed the array.
- editor: a multi-select. Chips above a grouped, filterable checklist for a
  source that answered; the typeahead for a searchable source; a tag box
  otherwise. Ticking past max is refused in words. A new step holds the
  example as a list of one.
- run console: params read as `name: value`, a list joined by commas.
- MODULE_API 1.13.0 -> 1.14.0 on both halves; engagement-triggers.json and the
  catalog's OpenAPI note regenerated.

Additive only: Module-uo (`^1.10.0`) declares no multiple param.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 15:40:30 -05:00

158 lines
7.3 KiB
JavaScript

// ── A param that holds several values (MODULE_API 1.14.0) ──────────────────
//
// Rust PLAN_REDESIGNS §12.3 (D344): `multiple: true` (+ `max`) on a string or int
// action param. The declaration is checked at registration, the value at save —
// a JSON array, a bare value read as a list of one, duplicates dropped in order,
// `required` meaning at least one — and `cost()` is priced with a list. A param
// that does not say `multiple` must come out of the registry exactly as it went
// in: that is what keeps Module-uo's `^1.10.0` honest.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const spec = require('../src/events/spec')
const authorize = require('../src/events/authorize')
const db = require('../src/utils/db')
after(() => db.close())
const register = (actions, budgets = []) => {
const api = registries.stage('demo')
if (budgets.length) api.registerEventBudgets(budgets)
api.registerEventActions(actions)
registries.apply(api.staged)
}
const action = (params, extra = {}) => ({
id: 'demo.perk.lend',
label: 'Lend perks',
risk: 'notify',
reversible: 'none',
params,
perform: async () => ({ ok: true }),
...extra,
})
const perms = (extra = {}) => ({ name: 'perms', type: 'string', required: true, example: 'nightvision.allowed', multiple: true, ...extra })
beforeEach(() => {
registries._reset()
registries.registerCore()
})
afterEach(() => registries._reset())
const oneStep = (params) => ({
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'demo.perk.lend', params }] }],
})
// ── The declaration ───────────────────────────────────────────────────────
test('multiple is served with its max, 20 when left out', () => {
register([action([perms(), { name: 'ids', type: 'int', example: 7, multiple: true, max: 5 }])])
const [p, ids] = registries.eventAction('demo.perk.lend').params
assert.equal(p.multiple, true)
assert.equal(p.max, registries.DEFAULT_MULTIPLE_MAX)
assert.equal(ids.max, 5)
const served = registries.allEventActions().find((a) => a.id === 'demo.perk.lend')
assert.equal(served.params[0].multiple, true)
assert.equal(served.params[1].max, 5)
})
test('a param that does not say multiple does not grow the key', () => {
register([action([{ name: 'region', type: 'string', example: 'Yew' }, perms({ multiple: false })])])
for (const p of registries.eventAction('demo.perk.lend').params) {
assert.equal('multiple' in p, false)
assert.equal('max' in p, false)
}
// Core's own actions say none of it either.
for (const a of registries.allEventActions().filter((x) => x.owner === 'core')) {
for (const p of a.params) assert.equal('multiple' in p, false, `${a.id}.${p.name}`)
}
})
test('multiple is refused on a type, a duration, a bad max and a list example', () => {
const refused = (param, pattern) => assert.throws(() => register([action([param])]), pattern)
refused(perms({ type: 'boolean', example: true }), /is multiple and must be string or int, not boolean/)
refused(perms({ type: 'float', example: 1.5 }), /is multiple and must be string or int, not float/)
refused(perms({ type: 'int', example: 5, duration: 'minutes' }), /cannot be both multiple and a duration/)
refused(perms({ max: 0 }), /max must be 1\.\.100/)
refused(perms({ max: 101 }), /max must be 1\.\.100/)
refused(perms({ max: 2.5 }), /max must be 1\.\.100/)
refused(perms({ multiple: 'yes' }), /multiple must be true or left out/)
refused(perms({ example: ['a', 'b'] }), /needs ONE example, not a list/)
refused({ name: 'region', type: 'string', example: 'Yew', max: 3 }, /has a max but is not multiple/)
})
test('a template input cannot be multiple', () => {
const api = registries.stage('demo')
assert.throws(
() => api.registerEventTemplates([{
id: 'demo.template.perks',
label: 'Perks',
inputs: [perms()],
build: async () => null,
}]),
/input "perms" cannot be multiple/,
)
})
// ── The value ─────────────────────────────────────────────────────────────
test('a list is kept in order, deduplicated, and a bare value is a list of one', () => {
register([action([perms(), { name: 'ids', type: 'int', example: 7, multiple: true }])])
const listed = spec.validate(oneStep({ perms: ['b.two', 'a.one', 'b.two'], ids: [3, 1, 3] }))
assert.equal(listed.ok, true, listed.errors?.join('; '))
assert.deepEqual(listed.spec.phases[0].steps[0].params, { perms: ['b.two', 'a.one'], ids: [3, 1] })
const bare = spec.validate(oneStep({ perms: 'nightvision.allowed', ids: 9 }))
assert.equal(bare.ok, true, bare.errors?.join('; '))
assert.deepEqual(bare.spec.phases[0].steps[0].params, { perms: ['nightvision.allowed'], ids: [9] })
})
test('required means at least one; an optional empty list is left out', () => {
register([action([perms(), { name: 'ids', type: 'int', example: 7, multiple: true }])])
for (const empty of [undefined, null, '', []]) {
const r = spec.validate(oneStep(empty === undefined ? {} : { perms: empty }))
assert.equal(r.ok, false, JSON.stringify(empty))
assert.match(r.errors.join('\n'), /"perms" is required/)
}
const r = spec.validate(oneStep({ perms: ['a.one'], ids: [] }))
assert.equal(r.ok, true, r.errors?.join('; '))
assert.equal('ids' in r.spec.phases[0].steps[0].params, false)
})
test('each item is the type, none is empty, and max is a ceiling', () => {
register([action([perms({ max: 2 }), { name: 'ids', type: 'int', example: 7, multiple: true }])])
const errs = (params) => spec.validate(oneStep(params)).errors?.join('\n') || ''
assert.match(errs({ perms: ['a', 4] }), /"perms" item 2 expected a string/)
assert.match(errs({ perms: ['a', ''] }), /"perms" item 2 is empty/)
assert.match(errs({ perms: ['a'], ids: [1, 'two'] }), /"ids" item 2 expected an integer/)
assert.match(errs({ perms: ['a', 'b', 'c'] }), /"perms" holds 3 values; the most is 2/)
// A duplicate does not count against max.
assert.equal(spec.validate(oneStep({ perms: ['a', 'b', 'a'] })).ok, true)
assert.match(errs({ perms: [['nested']] }), /"perms" item 1 expected a string/)
})
// ── The price ─────────────────────────────────────────────────────────────
test('cost() is handed the list, and its dimensions are found from a list of one', () => {
const seen = []
register(
[action([perms()], {
cost: (params) => {
seen.push(params.perms)
return { 'demo.perks': (params.perms || []).length }
},
})],
[{ id: 'demo.perks', label: 'Perks', unit: 'perks' }],
)
const declared = registries.eventAction('demo.perk.lend')
assert.deepEqual(authorize.dimensionsOf(declared), ['demo.perks'])
assert.deepEqual(seen.at(-1), ['nightvision.allowed'])
assert.deepEqual(authorize.priceOf(declared, { perms: ['a', 'b', 'c'] }), { 'demo.perks': 3 })
})