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
This commit is contained in:
@@ -133,7 +133,9 @@ const nextPhaseKey = (phases) => {
|
||||
export function blankStep(action) {
|
||||
const params = {}
|
||||
for (const p of action?.params || []) {
|
||||
if (p.required || p.example !== undefined) params[p.name] = p.example
|
||||
// A list's example is one value (MODULE_API 1.14.0), so the step starts
|
||||
// holding that one.
|
||||
if (p.required || p.example !== undefined) params[p.name] = p.multiple ? [p.example] : p.example
|
||||
}
|
||||
return {
|
||||
actionId: action?.id || '',
|
||||
@@ -545,7 +547,9 @@ export const PARAM_JSON = 'json'
|
||||
* is what the author needs to see — and a form that dropped it would post a
|
||||
* step that saves cleanly having deleted something they typed.
|
||||
* • **a value no single control can hold** — an object or an array against a
|
||||
* scalar declaration.
|
||||
* scalar declaration, or anything but a flat list against a `multiple` one
|
||||
* (MODULE_API 1.14.0). A bare value there is a list of one, as the server
|
||||
* reads it, and the multi-select shows it as one chip.
|
||||
*/
|
||||
export function paramsRenderable(action, params) {
|
||||
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
|
||||
@@ -554,6 +558,13 @@ export function paramsRenderable(action, params) {
|
||||
if (!declared.has(name)) {
|
||||
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
|
||||
}
|
||||
if (declared.get(name).multiple) {
|
||||
const items = Array.isArray(value) ? value : [value]
|
||||
if (items.some((v) => v !== null && typeof v === 'object')) {
|
||||
return { ok: false, reason: `"${name}" holds a structure, which a list of values cannot hold` }
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
|
||||
}
|
||||
@@ -625,6 +636,81 @@ export function setParam(step, name, raw, type) {
|
||||
*/
|
||||
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
|
||||
|
||||
// ── A param that holds several values (MODULE_API 1.14.0, Rust PLAN_REDESIGNS §12.3) ──
|
||||
|
||||
/**
|
||||
* A `multiple` param's value as the list the multi-select draws: an array as
|
||||
* stored, a bare value as a list of one (the server's own reading), and nothing
|
||||
* as the empty list.
|
||||
*/
|
||||
export function listValue(value) {
|
||||
if (value === undefined || value === null || value === '') return []
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a `multiple` param's whole list, and give back the whole box.
|
||||
*
|
||||
* Each item is coerced as `setParam` coerces one value, duplicates dropped
|
||||
* keeping the first, and **an empty list removes the key** — `setParam`'s rule,
|
||||
* for its reason: the server reads absent and `[]` alike, and "is required" is
|
||||
* the error a required list left empty should get.
|
||||
*/
|
||||
export function setParamList(step, name, items, type) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return step?.paramsText || '{}'
|
||||
const next = { ...parsed.params }
|
||||
const out = []
|
||||
for (const raw of items || []) {
|
||||
if (raw === '' || raw === undefined || raw === null) continue
|
||||
const value = coerceLiteral(type, raw)
|
||||
if (!out.some((v) => v === value)) out.push(value)
|
||||
}
|
||||
if (out.length) next[name] = out
|
||||
else delete next[name]
|
||||
return JSON.stringify(next, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one value to a list, or say why not. `{ list }` or `{ refused }`, so the
|
||||
* control can put the sentence where the click was rather than silently doing
|
||||
* nothing past `max`.
|
||||
*/
|
||||
export function addToList(list, value, max) {
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return { list }
|
||||
if (list.some((v) => String(v) === text)) return { list }
|
||||
if (max && list.length >= max) return { refused: `This takes at most ${max} — remove one first.` }
|
||||
return { list: [...list, text] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The options a filter box leaves, matched on value, label and group alike,
|
||||
* case-insensitively. The order is the source's own — a source sorts its rows
|
||||
* (§12.2) and the editor keeps it.
|
||||
*/
|
||||
export function filterOptions(options, term) {
|
||||
const t = String(term || '').trim().toLowerCase()
|
||||
if (!t) return options || []
|
||||
return (options || []).filter((o) =>
|
||||
[o.value, o.label, o.group].some((s) => String(s || '').toLowerCase().includes(t)))
|
||||
}
|
||||
|
||||
/**
|
||||
* One step's params as a line the run console can show: `name: value`, a list
|
||||
* joined by commas, anything else as its JSON. Shown rather than the raw object
|
||||
* so a list of ten permissions reads as ten names, not as brackets and quotes.
|
||||
*/
|
||||
export function describeParams(params) {
|
||||
if (!params || typeof params !== 'object') return ''
|
||||
return Object.entries(params)
|
||||
.map(([k, v]) => {
|
||||
if (Array.isArray(v) && v.every((x) => x === null || typeof x !== 'object')) return `${k}: ${v.join(', ')}`
|
||||
return `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`
|
||||
})
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
// ── The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2) ──
|
||||
//
|
||||
// What the controls need to decide and nothing the server re-decides: which
|
||||
@@ -671,7 +757,7 @@ export function missingParams(step, action, leasesById = new Map()) {
|
||||
if (!action) return []
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return []
|
||||
const empty = (v) => v === undefined || v === null || v === ''
|
||||
const empty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
|
||||
const missing = (action.params || []).filter((p) => p.required && empty(parsed.params[p.name])).map((p) => p.name)
|
||||
if (action.id === 'core.lease') {
|
||||
const lease = leasesById.get(parsed.params.lease)
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.14.0 — an action param's `multiple` (Rust PLAN_REDESIGNS §12.3): a param
|
||||
// that holds several values, drawn by core's step editor as a multi-select.
|
||||
// Server-side declaration; this file bumps for the reason at the top.
|
||||
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2): event templates, a
|
||||
// param's `duration`, a lease's `example` and an option's `note`. All four are
|
||||
// server-side declarations the editor, core's own page, renders. This file bumps
|
||||
@@ -85,4 +88,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.13.0'
|
||||
export const MODULE_API_VERSION = '1.14.0'
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
paramsMode,
|
||||
paramValue,
|
||||
setParam,
|
||||
setParamList,
|
||||
listValue,
|
||||
addToList,
|
||||
filterOptions,
|
||||
datetimeInputValue,
|
||||
priceBodyFrom,
|
||||
worthPricing,
|
||||
@@ -456,6 +460,154 @@ function ParamField({ param, value, sourceEntry, disabled, onChange, missing, la
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A param that holds several values, as a multi-select (MODULE_API 1.14.0; Rust
|
||||
* PLAN_REDESIGNS §12.3, D344).
|
||||
*
|
||||
* The chosen values are chips above the control, each with its remove button,
|
||||
* and the control is whichever the param's source allows:
|
||||
*
|
||||
* • **a source that answered** — a grouped checklist in the source's own order,
|
||||
* with a filter box over it, because a server's few hundred permissions are a
|
||||
* list to narrow, not to scroll;
|
||||
* • **a searchable source** — the typeahead, each pick adding a chip;
|
||||
* • **no source, or one that failed** — a tag box, a value per Enter. A failure
|
||||
* is said and the field stays usable, the single-value field's rule (§F).
|
||||
*
|
||||
* Ticking past `max` is refused in words, where the click was. The order the
|
||||
* author picked in is the order stored, and the server keeps it.
|
||||
*/
|
||||
function MultiParamField({ param, value, sourceEntry, disabled, onChange, missing }) {
|
||||
const list = listValue(value)
|
||||
const [term, setTerm] = useState('')
|
||||
const [typed, setTyped] = useState('')
|
||||
const [refused, setRefused] = useState(null)
|
||||
const max = param.max || 20
|
||||
|
||||
const answered = sourceEntry && sourceEntry.state === 'ok'
|
||||
const byValue = new Map(answered ? sourceEntry.options.map((o) => [o.value, o]) : [])
|
||||
const add = (v) => {
|
||||
const next = addToList(list, v, max)
|
||||
if (next.refused) setRefused(next.refused)
|
||||
else {
|
||||
setRefused(null)
|
||||
onChange(next.list)
|
||||
}
|
||||
}
|
||||
const remove = (v) => {
|
||||
setRefused(null)
|
||||
onChange(list.filter((x) => String(x) !== String(v)))
|
||||
}
|
||||
const toggle = (v) => (list.some((x) => String(x) === v) ? remove(v) : add(v))
|
||||
|
||||
const shown = answered && !sourceEntry.searchable ? filterOptions(sourceEntry.options, term) : []
|
||||
const groups = [...new Set(shown.map((o) => o.group || ''))]
|
||||
const chosenNotes = list
|
||||
.map((v) => ({ v, note: byValue.get(String(v))?.note }))
|
||||
.filter((x) => x.note)
|
||||
|
||||
const tagBox = (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
<input className="input" style={{ fontSize: '0.8rem' }} disabled={disabled} value={typed}
|
||||
placeholder={param.example === undefined || param.example === null ? '' : String(param.example)}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
add(typed)
|
||||
setTyped('')
|
||||
}
|
||||
}} />
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={disabled || !typed.trim()}
|
||||
onClick={() => { add(typed); setTyped('') }}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
let control
|
||||
if (param.source && sourceEntry?.searchable) {
|
||||
control = (
|
||||
<SearchableOptions sourceId={param.source} label={sourceEntry.label || param.source}
|
||||
disabled={disabled} onPick={add} />
|
||||
)
|
||||
} else if (param.source && answered && sourceEntry.options.length) {
|
||||
control = (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<input className="input" style={{ fontSize: '0.75rem' }} disabled={disabled} value={term}
|
||||
placeholder={`Filter ${sourceEntry.label || param.source}…`} onChange={(e) => setTerm(e.target.value)} />
|
||||
<div role="group" aria-label={param.name}
|
||||
style={{ maxHeight: 240, overflowY: 'auto', marginTop: 4, border: '1px solid rgba(255,255,255,0.08)', padding: '4px 8px' }}>
|
||||
{shown.length === 0 && (
|
||||
<div className="dim" style={{ fontSize: '0.72rem' }}>Nothing matches “{term}”.</div>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<div key={g || '—'}>
|
||||
{g && <div className="field-label" style={{ fontSize: '0.7rem', margin: '6px 0 2px' }}>{g}</div>}
|
||||
{shown.filter((o) => (o.group || '') === g).map((o) => (
|
||||
<label key={o.value} style={{ display: 'flex', alignItems: 'baseline', gap: 6, fontSize: '0.76rem' }}>
|
||||
<input type="checkbox" disabled={disabled}
|
||||
checked={list.some((x) => String(x) === o.value)}
|
||||
onChange={() => toggle(o.value)} />
|
||||
<span>{o.label}</span>
|
||||
{o.note && <span className="dim" style={{ fontSize: '0.7rem' }}>{o.note}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
control = (
|
||||
<>
|
||||
{param.source && (
|
||||
<ParamOptions entry={sourceEntry} label={sourceEntry?.label || param.source} disabled={disabled} onPick={add} />
|
||||
)}
|
||||
{tagBox}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span className="field-label">
|
||||
{param.name} <span className="dim" style={{ fontWeight: 'normal' }}>· {list.length} of at most {max}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||||
{list.length === 0 && <span className="sans dim" style={{ fontSize: '0.76rem' }}>None chosen yet.</span>}
|
||||
{list.map((v) => (
|
||||
<span key={String(v)} className="pill" style={{ fontSize: '0.74rem', display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||||
{byValue.get(String(v))?.label || String(v)}
|
||||
<button type="button" aria-label={`Remove ${v}`} disabled={disabled}
|
||||
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
||||
onClick={() => remove(v)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{refused && (
|
||||
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84', marginTop: 4 }}>{refused}</span>
|
||||
)}
|
||||
{control}
|
||||
{missing && (
|
||||
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84' }}>
|
||||
This is required — choose at least one before saving.
|
||||
</span>
|
||||
)}
|
||||
{param.description && (
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>{param.description}</span>
|
||||
)}
|
||||
{chosenNotes.map(({ v, note }) => (
|
||||
<span key={String(v)} className="sans" style={{ display: 'block', fontSize: '0.76rem', color: '#d9c184', marginTop: 4 }}>
|
||||
{byValue.get(String(v))?.label || String(v)}: {note}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `core.lease`, drawn as the chosen lease (Rust PLAN_REDESIGNS §2.1, U-4, F11).
|
||||
*
|
||||
@@ -1971,7 +2123,17 @@ export default function EventEditor() {
|
||||
{ timed: name === 'lease' || Boolean(declared.duration) })
|
||||
}}
|
||||
/>
|
||||
) : (action?.params || []).map((p) => (
|
||||
) : (action?.params || []).map((p) => (p.multiple ? (
|
||||
<MultiParamField
|
||||
key={p.name}
|
||||
param={p}
|
||||
value={paramValue(step, p.name)}
|
||||
sourceEntry={p.source ? sources[p.source] : null}
|
||||
disabled={archived}
|
||||
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
|
||||
onChange={(list) => setStep(pi, si, { paramsText: setParamList(step, p.name, list, p.type) })}
|
||||
/>
|
||||
) : (
|
||||
<ParamField
|
||||
key={p.name}
|
||||
param={p}
|
||||
@@ -1981,7 +2143,7 @@ export default function EventEditor() {
|
||||
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
|
||||
onChange={(raw) => setStepParam(pi, si, step, p, raw)}
|
||||
/>
|
||||
))}
|
||||
)))}
|
||||
{action && (action.params || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
|
||||
This action takes no params.
|
||||
@@ -2002,7 +2164,7 @@ export default function EventEditor() {
|
||||
{action && (action.params || []).length > 0 && (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
<code>{action.id}</code> 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(', ')}.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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() {
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
|
||||
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{JSON.stringify(step.params)}
|
||||
{describeParams(step.params)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
|
||||
|
||||
94
client/test/eventParamMultiple.test.js
Normal file
94
client/test/eventParamMultiple.test.js
Normal file
@@ -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), '')
|
||||
})
|
||||
Reference in New Issue
Block a user