setStepParam(pi, si, step, p, raw)}
/>
- ))}
+ )))}
{action && (action.params || []).length === 0 && (
This action takes no params.
@@ -2002,7 +2164,7 @@ export default function EventEditor() {
{action && (action.params || []).length > 0 && (
{action.id} declares{' '}
- {(action.params || []).map((p) => `${p.name} (${p.type}${p.required ? ', required' : ''})`).join(', ')}.
+ {(action.params || []).map((p) => `${p.name} (${p.multiple ? `a list of ${p.type}, at most ${p.max}` : p.type}${p.required ? ', required' : ''})`).join(', ')}.
)}
>
diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx
index 69c3e59..ed4f660 100644
--- a/client/src/routes/admin/views/EventRun.jsx
+++ b/client/src/routes/admin/views/EventRun.jsx
@@ -9,6 +9,7 @@ import {
runControlsFor,
stepControlsFor,
describeLogLine,
+ describeParams,
} from '../../../lib/eventAuthoring.js'
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
@@ -655,7 +656,7 @@ export default function EventRun() {
{step.actionId}
- {JSON.stringify(step.params)}
+ {describeParams(step.params)}
|
diff --git a/client/test/eventParamMultiple.test.js b/client/test/eventParamMultiple.test.js
new file mode 100644
index 0000000..c717e97
--- /dev/null
+++ b/client/test/eventParamMultiple.test.js
@@ -0,0 +1,94 @@
+// A param that holds several values (MODULE_API 1.14.0; Rust PLAN_REDESIGNS
+// §12.3): what the multi-select stores, when it gives way to JSON, and how the
+// run console reads a list back.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import {
+ blankStep,
+ paramsMode,
+ PARAM_FORM,
+ PARAM_JSON,
+ listValue,
+ setParamList,
+ addToList,
+ filterOptions,
+ missingParams,
+ describeParams,
+} from '../src/lib/eventAuthoring.js'
+
+const LEND = {
+ id: 'test.perm.lend',
+ params: [
+ { name: 'server', type: 'string', required: true, example: 'main' },
+ { name: 'perms', type: 'string', required: true, example: 'nightvision.allowed', multiple: true, max: 3 },
+ { name: 'ids', type: 'int', required: false, example: 7, multiple: true, max: 20 },
+ ],
+}
+const step = (params) => ({ actionId: LEND.id, paramsText: JSON.stringify(params) })
+
+test('a new step holds the example as a list of one', () => {
+ const fresh = JSON.parse(blankStep(LEND).paramsText)
+ assert.deepEqual(fresh.perms, ['nightvision.allowed'])
+ assert.deepEqual(fresh.ids, [7])
+ assert.equal(fresh.server, 'main')
+})
+
+test('a list is a value the form can hold; a structure in it is not', () => {
+ assert.equal(paramsMode(step({ perms: ['a', 'b'] }), LEND).mode, PARAM_FORM)
+ assert.equal(paramsMode(step({ perms: 'a' }), LEND).mode, PARAM_FORM)
+ const nested = paramsMode(step({ perms: [{ a: 1 }] }), LEND)
+ assert.equal(nested.mode, PARAM_JSON)
+ assert.match(nested.reason, /"perms" holds a structure/)
+ // A list against a single-value param still forces JSON, as before 1.14.0.
+ assert.equal(paramsMode(step({ server: ['main'] }), LEND).mode, PARAM_JSON)
+})
+
+test('listValue reads a bare value as a list of one, and nothing as none', () => {
+ assert.deepEqual(listValue(['a', 'b']), ['a', 'b'])
+ assert.deepEqual(listValue('a'), ['a'])
+ for (const empty of [undefined, null, '']) assert.deepEqual(listValue(empty), [])
+})
+
+test('setParamList coerces, drops duplicates in order, and removes an empty list', () => {
+ const s = step({ server: 'main' })
+ assert.deepEqual(JSON.parse(setParamList(s, 'ids', ['3', '1', '3'], 'int')).ids, [3, 1])
+ assert.deepEqual(JSON.parse(setParamList(s, 'perms', ['b', 'a', 'b', ''], 'string')).perms, ['b', 'a'])
+ const cleared = JSON.parse(setParamList(step({ server: 'main', perms: ['a'] }), 'perms', [], 'string'))
+ assert.equal('perms' in cleared, false)
+ // A box that does not parse is handed back untouched.
+ assert.equal(setParamList({ paramsText: '{nope' }, 'perms', ['a'], 'string'), '{nope')
+})
+
+test('addToList ignores blanks and repeats, and refuses past max in words', () => {
+ assert.deepEqual(addToList(['a'], ' b ', 3).list, ['a', 'b'])
+ assert.deepEqual(addToList(['a'], 'a', 3).list, ['a'])
+ assert.deepEqual(addToList(['a'], ' ', 3).list, ['a'])
+ assert.match(addToList(['a', 'b', 'c'], 'd', 3).refused, /at most 3/)
+})
+
+test('filterOptions matches value, label or group and keeps the source order', () => {
+ const options = [
+ { value: 'nightvision.allowed', label: 'nightvision.allowed', group: 'NightVision' },
+ { value: 'betterchat.color', label: 'betterchat.color', group: 'BetterChat' },
+ { value: 'kits.vip', label: 'kits.vip', group: 'Kits' },
+ ]
+ assert.equal(filterOptions(options, '').length, 3)
+ assert.deepEqual(filterOptions(options, 'NIGHT').map((o) => o.value), ['nightvision.allowed'])
+ assert.deepEqual(filterOptions(options, 'chat').map((o) => o.value), ['betterchat.color'])
+ assert.deepEqual(filterOptions(options, 'i').map((o) => o.value), ['nightvision.allowed', 'kits.vip'])
+})
+
+test('an empty required list is missing', () => {
+ assert.deepEqual(missingParams(step({ server: 'main', perms: [] }), LEND), ['perms'])
+ assert.deepEqual(missingParams(step({ server: 'main' }), LEND), ['perms'])
+ assert.deepEqual(missingParams(step({ server: 'main', perms: ['a'] }), LEND), [])
+})
+
+test('the run console shows a list as its values joined by commas', () => {
+ assert.equal(
+ describeParams({ server: 'main', perms: ['nightvision.allowed', 'betterchat.color'], confirm: true }),
+ 'server: main · perms: nightvision.allowed, betterchat.color · confirm: true',
+ )
+ assert.equal(describeParams({ where: { a: 1 } }), 'where: {"a":1}')
+ assert.equal(describeParams(null), '')
+})
diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json
index 46bc521..430f963 100644
--- a/server/engagement-triggers.json
+++ b/server/engagement-triggers.json
@@ -1,6 +1,6 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
- "moduleApiVersion": "1.13.0",
+ "moduleApiVersion": "1.14.0",
"triggers": [
{
"id": "event.phase.changed",
diff --git a/server/src/events/authorize.js b/server/src/events/authorize.js
index acf88b7..c3b5130 100644
--- a/server/src/events/authorize.js
+++ b/server/src/events/authorize.js
@@ -144,7 +144,9 @@ function priceOf(action, params) {
function dimensionsOf(action) {
const params = {}
for (const p of action?.params || []) {
- if (p.example !== undefined && p.example !== null) params[p.name] = p.example
+ // A `multiple` param's example is one value and `cost()` is handed a list
+ // (1.14.0), so it is priced as a list of one.
+ if (p.example !== undefined && p.example !== null) params[p.name] = p.multiple ? [p.example] : p.example
}
const priced = priceOf(action, params)
return priced ? Object.keys(priced).sort() : []
diff --git a/server/src/events/spec.js b/server/src/events/spec.js
index 8b1afa9..a9f3401 100644
--- a/server/src/events/spec.js
+++ b/server/src/events/spec.js
@@ -323,6 +323,37 @@ function validateAdvance(raw, path, errors) {
return { on: triggerId, where: checked.conditions, count, dormant: false }
}
+/**
+ * A `multiple` param's value as the list it is (MODULE_API 1.14.0, Rust
+ * PLAN_REDESIGNS §12.3).
+ *
+ * `{ value: [...] }` or `{ error }`. **A bare value is a list of one**, so a
+ * spec written by hand or over the API before its author knew the param took
+ * several does not trip on it. Absent, `null`, `''` and `[]` are all the empty
+ * list, which the caller reads as unset — `required` means at least one.
+ *
+ * Each element is the type's literal, checked by the same `checkLiteral` a
+ * single value is; an empty string inside the list is refused rather than kept,
+ * because a blank chip is nothing anyone picked. Duplicates are dropped keeping
+ * the first, so the order the author picked in is the order the module reads.
+ */
+function checkList(p, raw) {
+ if (raw === undefined || raw === null || raw === '') return { value: [] }
+ const items = Array.isArray(raw) ? raw : [raw]
+ const out = []
+ const seen = new Set()
+ for (let i = 0; i < items.length; i++) {
+ if (items[i] === '') return { error: `item ${i + 1} is empty` }
+ const checked = checkLiteral(p.type, items[i])
+ if (checked.error) return { error: `item ${i + 1} ${checked.error}` }
+ if (seen.has(checked.value)) continue
+ seen.add(checked.value)
+ out.push(checked.value)
+ }
+ if (out.length > p.max) return { error: `holds ${out.length} values; the most is ${p.max}` }
+ return { value: out }
+}
+
/**
* Check one authored param object against an action's declared params.
*
@@ -349,6 +380,14 @@ function checkParams(declaration, raw, path) {
for (const p of declaration.params || []) {
const value = given[p.name]
+ if (p.multiple) {
+ const listed = checkList(p, value)
+ if (listed.error) errors.push(`${path}.params: "${p.name}" ${listed.error}`)
+ else if (!listed.value.length) {
+ if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
+ } else params[p.name] = listed.value
+ continue
+ }
if (value === undefined || value === null || value === '') {
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
continue
diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js
index 5d152ad..8bdf12c 100644
--- a/server/src/modules/registries.js
+++ b/server/src/modules/registries.js
@@ -1030,11 +1030,23 @@ const MAX_BUDGET_MS = 3_600_000
// vocabulary of one is a vocabulary that can grow without a version.
const PARAM_DURATIONS = ['minutes']
+// A param that holds several values (MODULE_API 1.14.0; Rust PLAN_REDESIGNS
+// §12.3, D344). Two types, because the one step that needed it picks strings
+// from a source and an int list is the same check one type over; a list of
+// booleans, dates or links is a shape nothing has asked for, and every control
+// the editor draws for a list is a picker or a tag box.
+const MULTIPLE_PARAM_TYPES = ['string', 'int']
+// `max` when the declaration leaves it out, and the most it may say. The ceiling
+// is a bound on one step's params, not a number anyone should reach: a list of a
+// hundred is a step nobody can read back.
+const DEFAULT_MULTIPLE_MAX = 20
+const MAX_MULTIPLE_MAX = 100
+
// `call` names the registration in the error, because a template's inputs are
// checked by this same function (they ARE params: one control each, with a
// name, a type, an example and maybe a source).
function checkActionParam(actionId, entry, seen, call = 'registerEventActions') {
- const { name, type, required, example, description, source, duration } = entry || {}
+ const { name, type, required, example, description, source, duration, multiple, max } = entry || {}
const where = `${call}: ${actionId}`
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
@@ -1072,6 +1084,35 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions')
throw new Error(`${where}: param "${name}" is a duration and must be int or float, not ${type}`)
}
}
+ // **`multiple` makes the value a list** (1.14.0, §12.3). The example stays ONE
+ // value — it is the placeholder of the box a value is typed into — and the
+ // value a step stores is a JSON array of them (`events/spec.js`).
+ //
+ // Refused on a duration, because a list of lengths of time is not a hold the
+ // editor can keep a phase open for, and on a template's inputs, because
+ // `build()` is a module's own code reading `inputs.x` as one value and this
+ // version does not change what it is handed.
+ if (multiple !== undefined && multiple !== null && multiple !== false) {
+ if (multiple !== true) throw new Error(`${where}: param "${name}" multiple must be true or left out`)
+ if (call === 'registerEventTemplates') {
+ throw new Error(`${where}: input "${name}" cannot be multiple; a template's inputs are single values`)
+ }
+ if (!MULTIPLE_PARAM_TYPES.includes(type)) {
+ throw new Error(`${where}: param "${name}" is multiple and must be ${MULTIPLE_PARAM_TYPES.join(' or ')}, not ${type}`)
+ }
+ if (duration !== undefined && duration !== null) {
+ throw new Error(`${where}: param "${name}" cannot be both multiple and a duration`)
+ }
+ if (Array.isArray(example)) {
+ throw new Error(`${where}: param "${name}" needs ONE example, not a list (it is the placeholder of one value)`)
+ }
+ }
+ if (max !== undefined && max !== null) {
+ if (multiple !== true) throw new Error(`${where}: param "${name}" has a max but is not multiple`)
+ if (!Number.isInteger(max) || max < 1 || max > MAX_MULTIPLE_MAX) {
+ throw new Error(`${where}: param "${name}" max must be 1..${MAX_MULTIPLE_MAX}, got "${max}"`)
+ }
+ }
return {
name,
type,
@@ -1080,6 +1121,9 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions')
source: source === undefined ? null : source,
description: description || '',
...(duration ? { duration } : {}),
+ // Absent rather than `false` on every other param, as `duration` is, so a
+ // module that never says it reads back exactly the declaration it wrote.
+ ...(multiple === true ? { multiple: true, max: max ?? DEFAULT_MULTIPLE_MAX } : {}),
}
}
@@ -2164,6 +2208,9 @@ module.exports = {
allEventTemplates,
eventTemplate,
PARAM_DURATIONS,
+ MULTIPLE_PARAM_TYPES,
+ DEFAULT_MULTIPLE_MAX,
+ MAX_MULTIPLE_MAX,
resolveOptionSource,
allEngagementSeeds,
engagementSeedsFor,
diff --git a/server/src/modules/version.js b/server/src/modules/version.js
index 619cd95..0b1110f 100644
--- a/server/src/modules/version.js
+++ b/server/src/modules/version.js
@@ -9,6 +9,16 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
+// 1.14.0 — an action param may hold several values: `multiple: true`, with an
+// optional `max` (1–100, 20 if left out), on a `string` or `int` param (Rust
+// PLAN_REDESIGNS §12.3, D344; docs/website/EVENTS.md §F). The step stores a JSON
+// array; a bare value is read as a list of one; `required` means at least one;
+// `perform()` and `cost()` are handed the array. Refused on a duration and on a
+// template's inputs. The editor draws it as a multi-select.
+// One optional flag, so minor. Module-uo declares `^1.10.0` and uses none of it:
+// a param that does not say `multiple` is checked, stored and drawn exactly as it
+// was, and the declaration the registry hands back does not grow the key.
+//
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2; docs/website/EVENTS.md
// §F, §I). Four additions, each optional:
// • `api.registerEventTemplates([{ id, label, description, inputs, build }])` —
@@ -188,6 +198,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
-const MODULE_API_VERSION = '1.13.0'
+const MODULE_API_VERSION = '1.14.0'
module.exports = { MODULE_API_VERSION }
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
index 33af059..e84bd5d 100644
--- a/server/src/router/v1/admin/events.router.js
+++ b/server/src/router/v1/admin/events.router.js
@@ -51,7 +51,7 @@ eventsRouter.get(
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step — the action says what params it takes, a param source names a dropdown, and a cap box is a budget label and unit. A source resolves its VALUES on a request of its own (/options/:sourceId), because a source can be slow or down and must not take the catalog with it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- /* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true }, templates: { type: "array", description: "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.", items: { type: "object", additionalProperties: true } } } } } } } */
+ /* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", description: "Each action with its declared params. MODULE_API 1.14.0: a param may carry multiple: true and max (1 to 100, 20 when left out), and its value is then a JSON array of at most max values.", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true }, templates: { type: "array", description: "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.catalog,
)
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 9964cd1..ce9ae0a 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -3869,6 +3869,7 @@
"properties": {
"actions": {
"type": "array",
+ "description": "Each action with its declared params. MODULE_API 1.14.0: a param may carry multiple: true and max (1 to 100, 20 when left out), and its value is then a JSON array of at most max values.",
"items": {
"type": "object",
"additionalProperties": true
diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js
index d329130..68f5e3c 100644
--- a/server/test/eventModuleContract.test.js
+++ b/server/test/eventModuleContract.test.js
@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
// assertion that says it does. The number itself is pinned in version.js.
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
- assert.equal(MODULE_API_VERSION, '1.13.0')
+ assert.equal(MODULE_API_VERSION, '1.14.0')
})
test('a module registers actions, budgets, leases and option sources', () => {
diff --git a/server/test/eventParamMultiple.test.js b/server/test/eventParamMultiple.test.js
new file mode 100644
index 0000000..73682cd
--- /dev/null
+++ b/server/test/eventParamMultiple.test.js
@@ -0,0 +1,157 @@
+// ── 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 })
+})
|