// ── The event action registry (EVENTS.md §F, Phase 1) ────────────────────── // // Phase 1's acceptance criteria for the registry half, one test apiece: // // • core's three actions register on every boot and appear in the catalog // • the catalog never carries a callable — no `perform`, `revert` or `cost` // • a module registering an un-namespaced action fails, with the holder named // • the closed sets are closed: an invented risk or reversibility is refused // • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION, // not discovered at teardown when something has already been created // • an action id and a trigger id are DIFFERENT namespaces, so one id may // legitimately be both — the property the audience registry established and // this one inherits // // Point the DB at a closed port BEFORE requiring anything: registries.js reaches // utils/discordAnnounce, which reaches the pool at require time. 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 coreEventActions = require('../src/config/coreEventActions') const db = require('../src/utils/db') after(() => db.close()) beforeEach(() => registries._reset()) afterEach(() => registries._reset()) const ok = (over = {}) => ({ id: 'demo.thing.do', label: 'Do the thing', risk: 'change', reversible: 'none', perform: async () => ({ ok: true }), ...over, }) const register = (owner, entries) => { const api = registries.stage(owner) api.registerEventActions(entries) registries.apply(api.staged) } test('core registers its three actions on every boot', () => { registries.registerCore() const ids = registries.allEventActions().map((a) => a.id) assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue']) assert.equal(ids.length, coreEventActions.ACTIONS.length) }) test('the catalog carries no callable', () => { registries.registerCore() for (const action of registries.allEventActions()) { assert.equal(action.perform, undefined, `${action.id} leaked perform`) assert.equal(action.revert, undefined, `${action.id} leaked revert`) assert.equal(action.cost, undefined, `${action.id} leaked cost`) } // …and the runner's own lookup still has it, which is the half that makes the // stripping a boundary rather than a deletion. assert.equal(typeof registries.eventAction('core.wait').perform, 'function') }) test('core placeholders refuse rather than claiming success', async () => { // Phase 1 declares; Phase 2 dispatches. The placeholder's answer matters // because `ok: true` on an action that did nothing is a recorded world change // that did not occur — the one wrong answer a stub can give. registries.registerCore() for (const id of ['core.announce', 'core.wait', 'core.cue']) { const answer = await registries.eventAction(id).perform({}) assert.equal(answer.ok, false) assert.equal(answer.retry, false) assert.match(answer.error, new RegExp(id.replace('.', '\\.'))) } }) test('an action must be namespaced to its owner, and the holder is named', () => { assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/) register('demo', [ok()]) assert.throws( () => register('rival', [ok({ id: 'demo.thing.do' })]), /already registered by "demo"/, ) }) test('the same id twice in one batch is refused', () => { assert.throws(() => register('demo', [ok(), ok()]), /registered twice/) }) test('risk and reversibility are closed sets with no default', () => { assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/) assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/) assert.throws( () => register('demo', [ok({ reversible: undefined })]), /needs a reversible class/, ) assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/) }) test("reversible: 'ledger' without revert() is refused at registration", () => { assert.throws( () => register('demo', [ok({ reversible: 'ledger' })]), /is reversible: 'ledger' but has no revert\(\)/, ) // And the mirror: a revert() nothing will ever call is a promise core does not // keep, so it is refused just as loudly. assert.throws( () => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]), /declares revert\(\) but is reversible: 'none'/, ) register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })]) assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function') }) test('perform() is required and cost must be a function', () => { assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/) assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/) }) test('every param needs a type and an example', () => { const withParams = (params) => ok({ params }) assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/) assert.throws( () => register('demo', [withParams([{ name: 'x', type: 'int' }])]), /needs an example/, ) assert.throws( () => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]), /bad param name/, ) assert.throws( () => register('demo', [ withParams([ { name: 'x', type: 'int', example: 1 }, { name: 'x', type: 'int', example: 2 }, ]), ]), /declared twice/, ) register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])]) const [param] = registries.eventAction('demo.thing.do').params assert.deepEqual(param, { name: 'x', type: 'int', required: false, example: 12, source: 'demo.options.x', description: '', }) }) test('budgetMs defaults, and is bounded', () => { register('demo', [ok()]) assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS) registries._reset() assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/) assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/) }) test('actions and triggers are different namespaces, so one id may be both', () => { // The property §F states and the audience registry established first. A verb // called `demo.raid.start` and an event called `demo.raid.start` are two // unrelated declarations, and forbidding the pair would forbid the most // natural names a module will ever want. const api = registries.stage('demo') api.registerEventTriggers([ { id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' }, ]) api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })]) registries.apply(api.staged) assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started') assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid') }) test('a whole batch is refused or taken, never half', () => { assert.throws( () => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]), /needs a risk class/, ) // The shape check throws at the CALL, before anything is staged, so nothing // from the batch is visible. assert.equal(registries.eventAction('demo.thing.do'), null) }) test('_reset() hands the process back', () => { registries.registerCore() assert.equal(registries.allEventActions().length, 3) registries._reset() assert.equal(registries.allEventActions().length, 0) assert.equal(registries.isEventAction('core.wait'), false) })