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
158 lines
7.3 KiB
JavaScript
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 })
|
|
})
|