// ── 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 }) })