// ── The step editor's contract additions (MODULE_API 1.13.0) ─────────────── // // Rust PLAN_REDESIGNS §2: event templates (D192), a param's `duration`, a lease's // `example` and an option row's `note`. Proved the way eventModuleContract.test.js // proves the rest of the seam — a real module in a real directory, scanned and // registered by the real loader — because a test that staged by hand would pass // against a loader that forwards none of it. // // The pool points at a closed port before anything is required. `draftAhead` // reads a version, and that read is stubbed on the module object the model holds. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const fs = require('fs') const os = require('os') const path = require('path') const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const express = require('express') const db = require('../src/utils/db') const registries = require('../src/modules/registries') const definitions = require('../src/model/events/eventDefinitions.model') const versionsDb = require('../src/model/events/eventVersions.db') after(() => db.close()) let tmpRoot const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router(), }) function loadModule(id, source, manifest = {}) { const dir = path.join(tmpRoot, id) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync( path.join(dir, 'module.json'), JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.13.0', server: 'index.js', ...manifest }), ) fs.writeFileSync(path.join(dir, 'index.js'), source) // A test that loads twice rewrites the same file, and require() would hand the // loader the first one back. delete require.cache[path.join(dir, 'index.js')] process.env.MODULES_DIR = tmpRoot registries._reset() delete require.cache[require.resolve('../src/modules/loader')] // eslint-disable-next-line global-require const loader = require('../src/modules/loader') loader.load(emptyTiers()) return loader.list().find((m) => m.id === id) } const assertRegistered = (record) => { assert.equal(record.state, 'registered', record.reason || 'expected the module to register') } const assertFailed = (record, pattern) => { assert.notEqual(record.state, 'registered', 'expected the module to be refused') assert.match(record.reason || '', pattern) } beforeEach(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-step-editor-')) }) // A template whose build() answers whatever the module's `mode` input says, so // one registration covers every path build() can take. const TEMPLATE_MODULE = `module.exports = (ctx, api) => { api.registerEventTemplates([{ id: 'demo.template.weekend', label: 'Weekend', description: 'Two days of something.', inputs: [ { name: 'what', type: 'string', required: true, example: 'double gather' }, { name: 'minutes', type: 'int', required: true, example: 60, duration: 'minutes' }, { name: 'mode', type: 'string', required: false, example: 'ok' }, ], async build({ inputs }) { if (inputs.mode === 'refuse') return { ok: false, error: 'that is not a weekend' } if (inputs.mode === 'throw') throw new Error('database exploded at row 7') if (inputs.mode === 'nothing') return null if (inputs.mode === 'bad') return { title: 'Bad', spec: { phases: [{ key: 'Not A Key', steps: [] }] } } return { title: 'Weekend: ' + inputs.what, summary: 'For ' + inputs.minutes + ' minutes.', spec: { phases: [{ key: 'hold', label: 'Hold', advance: { after: inputs.minutes + 'm' }, steps: [{ actionId: 'core.wait', params: { seconds: 5 } }], }], }, } }, }]) }` // ── Templates ───────────────────────────────────────────────────────────── test('a module registers a template, and the catalog lists it without build()', () => { assertRegistered(loadModule('demo', TEMPLATE_MODULE)) const [t] = registries.allEventTemplates() assert.equal(t.id, 'demo.template.weekend') assert.equal(t.owner, 'demo') assert.equal(t.build, undefined) // The inputs are params, checked by the same function: the duration survives. assert.deepEqual(t.inputs.map((p) => [p.name, p.type, p.required, p.duration || null]), [ ['what', 'string', true, null], ['minutes', 'int', true, 'minutes'], ['mode', 'string', false, null], ]) assert.equal(typeof registries.eventTemplate('demo.template.weekend').build, 'function') }) test('a template outside its module\'s prefix, without build(), or asking too much is refused', () => { assertFailed(loadModule('demo', `module.exports = (ctx, api) => { api.registerEventTemplates([{ id: 'other.template.x', label: 'X', build() { return {} } }]) }`), /not namespaced "demo\."/) assertFailed(loadModule('demo', `module.exports = (ctx, api) => { api.registerEventTemplates([{ id: 'demo.template.x', label: 'X' }]) }`), /has no build\(\)/) const nine = Array.from({ length: 9 }, (_, i) => `{ name: 'p${i}', type: 'string', example: 'x' }`).join(',') assertFailed(loadModule('demo', `module.exports = (ctx, api) => { api.registerEventTemplates([{ id: 'demo.template.x', label: 'X', inputs: [${nine}], build() { return {} } }]) }`), /asks for 9 inputs/) // An input is a param, so it needs an example like every param does. assertFailed(loadModule('demo', `module.exports = (ctx, api) => { api.registerEventTemplates([{ id: 'demo.template.x', label: 'X', inputs: [{ name: 'a', type: 'string' }], build() { return {} } }]) }`), /registerEventTemplates: demo\.template\.x: param "a" needs an example/) }) test('a filled-in template is a draft: validated, normalised, and saved nowhere', async () => { assertRegistered(loadModule('demo', TEMPLATE_MODULE)) registries.registerCore() const result = await definitions.buildFromTemplate('demo.template.weekend', { what: 'wood', minutes: 90 }) assert.equal(result.ok, true, JSON.stringify(result.errors)) assert.equal(result.draft.title, 'Weekend: wood') assert.equal(result.draft.summary, 'For 90 minutes.') assert.equal(result.draft.spec.phases[0].advance.after, '90m') assert.equal(result.draft.spec.phases[0].steps[0].actionId, 'core.wait') }) test('the inputs are checked before build() is called, in the author\'s words', async () => { assertRegistered(loadModule('demo', TEMPLATE_MODULE)) registries.registerCore() const missing = await definitions.buildFromTemplate('demo.template.weekend', { minutes: 10 }) assert.equal(missing.ok, false) assert.equal(missing.status, 400) assert.deepEqual(missing.errors, ['"what" is required']) const extra = await definitions.buildFromTemplate('demo.template.weekend', { what: 'x', minutes: 10, colour: 'red' }) assert.equal(extra.ok, false) assert.match(extra.errors[0], /"colour" is not a param of demo\.template\.weekend/) const unknown = await definitions.buildFromTemplate('demo.template.nope', {}) assert.equal(unknown.status, 404) }) test('every way build() can fail is an answer, and only a refusal reaches the author verbatim', async () => { assertRegistered(loadModule('demo', TEMPLATE_MODULE)) registries.registerCore() const base = { what: 'x', minutes: 10 } const refused = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'refuse' }) assert.equal(refused.status, 400) assert.deepEqual(refused.errors, ['that is not a weekend']) // A throw is logged, not shown: the module's internals are not the author's. const threw = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'throw' }) assert.equal(threw.status, 502) assert.doesNotMatch(threw.errors.join(' '), /database exploded/) assert.match(threw.errors[0], /"Weekend" could not be filled in/) const nothing = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'nothing' }) assert.equal(nothing.status, 502) const bad = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'bad' }) assert.equal(bad.status, 502) assert.match(bad.errors[0], /made a plan core cannot accept/) assert.ok(bad.errors.length > 1, 'the spec errors ride along for whoever reads the response') }) // ── A param's duration ──────────────────────────────────────────────────── test('a duration is minutes, on a number, and the catalog carries it', () => { assertRegistered(loadModule('demo', `module.exports = (ctx, api) => { api.registerEventActions([{ id: 'demo.hold', label: 'Hold', risk: 'notify', reversible: 'none', version: 1, params: [{ name: 'minutes', type: 'int', required: true, example: 5, duration: 'minutes' }], async perform() { return { ok: true } }, }]) }`)) assert.equal(registries.eventAction('demo.hold').params[0].duration, 'minutes') assertFailed(loadModule('demo', `module.exports = (ctx, api) => { api.registerEventActions([{ id: 'demo.hold', label: 'Hold', risk: 'notify', reversible: 'none', version: 1, params: [{ name: 'for', type: 'string', example: '5m', duration: 'minutes' }], async perform() { return { ok: true } }, }]) }`), /is a duration and must be int or float/) assertFailed(loadModule('demo', `module.exports = (ctx, api) => { api.registerEventActions([{ id: 'demo.hold', label: 'Hold', risk: 'notify', reversible: 'none', version: 1, params: [{ name: 'hours', type: 'int', example: 2, duration: 'hours' }], async perform() { return { ok: true } }, }]) }`), /has duration "hours"; the one unit is minutes/) }) test('core.lease\'s minutes is a duration, and its examples name no game', () => { registries._reset() registries.registerCore() const lease = registries.eventAction('core.lease') assert.equal(lease.params.find((p) => p.name === 'minutes').duration, 'minutes') // The params without a duration do not grow the key at all. assert.equal('duration' in lease.params.find((p) => p.name === 'value'), false) const words = registries.allEventActions() .filter((a) => a.owner === 'core') .flatMap((a) => a.params.map((p) => String(p.example))) .join(' ') assert.doesNotMatch(words, /\buo\.|Britain|Cove|skillgain/) }) // ── A lease's example ───────────────────────────────────────────────────── const leaseModule = (example) => `module.exports = (ctx, api) => { api.registerEventLeases([{ id: 'demo.flag', label: 'Flag', type: 'bool', maxDurationMs: 3600000, ${example === undefined ? '' : `example: ${example},`} async read() { return { ok: true, value: false } }, async apply() { return { ok: true } }, async restore() { return { ok: true } }, }]) }` test('a lease may carry an example for its value, and it is served', () => { assertRegistered(loadModule('demo', leaseModule('true'))) assert.equal(registries.allEventLeases()[0].example, true) assertRegistered(loadModule('demo', leaseModule(undefined))) assert.equal(registries.allEventLeases()[0].example, null) assertFailed(loadModule('demo', leaseModule('{ on: true }')), /example must be a single value/) }) // ── An option's note ────────────────────────────────────────────────────── test('an option may carry a note, kept as text and bounded', async () => { assertRegistered(loadModule('demo', `module.exports = (ctx, api) => { api.registerEventOptionSources([{ id: 'demo.options.kits', label: 'Kits', async resolve() { return [ { value: 'vip', label: 'VIP', note: '9 of 41 linked players have used it up' }, { value: 'long', note: 'x'.repeat(500) }, { value: 'plain' }, ] }, }]) }`)) const answer = await registries.resolveOptionSource('demo.options.kits') assert.equal(answer.options[0].note, '9 of 41 linked players have used it up') assert.equal(answer.options[1].note.length, 300) assert.equal('note' in answer.options[2], false) }) // ── The draft ahead of its published version ────────────────────────────── test('draftAhead compares the working copy with the published spec as data', async (t) => { const published = { phases: [{ key: 'a', steps: [{ actionId: 'core.wait', params: { seconds: 5 } }] }] } const original = versionsDb.getById t.after(() => { versionsDb.getById = original }) versionsDb.getById = async (id) => (id === 7 ? { id: 7, spec: published } : null) // Never published: nothing to be ahead of. assert.equal(await definitions.draftAhead({ current_version_id: null, spec: published }), false) // The same plan with its keys in another order is not an edit. const reordered = { phases: [{ steps: [{ params: { seconds: 5 }, actionId: 'core.wait' }], key: 'a' }] } assert.equal(await definitions.draftAhead({ current_version_id: 7, spec: reordered }), false) const edited = { phases: [{ key: 'a', steps: [{ actionId: 'core.wait', params: { seconds: 6 } }] }] } assert.equal(await definitions.draftAhead({ current_version_id: 7, spec: edited }), true) // A version that cannot be found answers false rather than asking the author // to choose between two plans one of which is missing. assert.equal(await definitions.draftAhead({ current_version_id: 8, spec: edited }), false) })