Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN. One table, one core action, one route, one body field, and two members added to MODULE_API 1.10.0 in place. The safety property the whole world-write half depends on: core now remembers what a run changed in the world, and gives it back on every terminal path. Four decisions settled by the org lead on 2026-09-03, all as recommended: - A lease is acquired by a new CORE action, `core.lease`. Section F puts the duration bound and the two-events-one-target conflict check on core's side of the seam, and a lease verb per module would be both re-implemented once per module, advisory everywhere. - Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key. A spawn's ref does not exist until the module answers, so what core writes before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never comes it stands, and cleanup calls revert() with the key and no resources -- which is why section F's revert takes the key at all. - Cleanup is one sweep over the ledger, not synthetic step rows. The step-shaped version costs a second retry counter beside `revert_attempts`. - `reconcile` is declared here and TRIGGERED BY THE MODULE, through `ctx.events.reconcile()`. Core has no concept of the game being up, so it cannot decide when to ask; it asks once at its own boot. MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`; while it is on `edge` it is amended in place, so the whole module contract reaches an author as one version they read once. Verify - `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the pre-existing engagementManifest CRLF failure, in a file this branch does not touch (`edge` before: 1950/1876/73/1). +75 tests. - The unique key was proved against a REAL MariaDB, because nothing else can prove it: whether multiple NULLs collide in a unique index, whether a STORED generated column is recomputed on UPDATE, and whether the SET NULL foreign key survives beside it are properties of the server. eventRunnerSql.test.js gained 16 tests; 65 pass against the container. The real schema.sql was applied to a fresh database and to an existing one. - Client: 362 pass, and it builds. routes:manifest and swagger -- one route added, none moved. The live walk found three defects, and two of them are the phase's real finding Driven by a throwaway `rig` module in website/modules/, deleted before commit. 1. A lease was never given back at all. `core.lease` reserves its own ledger row, so it never went through the ledger's dirty-marking, so a run holding only a lease kept `cleanup_status = 'not_required'` and the cleanup leg -- which selected on `pending` -- never looked at it. 2. EVENT_REVERT_MAX_ATTEMPTS meant one attempt, not three. The first failing sweep moved the run to `incomplete`, which took it out of the leg's own scan for ever. The test covering the bound asserted `<= 3` and was satisfied by 1: a bound has two halves, and a test that only asserts the ceiling passes against a floor. 3. The first fix for (2) made the console lie. Spending every row's `revert_attempts` was a tidy way to take a `cleanup: false` run out of a counter-bounded scan, and the run page then rendered "3 attempts" beside resources nothing had ever tried. Found by opening the page. Both (1) and (2) are the same mistake: deriving "is there anything to do" from a summary column instead of from the rows. Neither was visible to a unit test, because a test that calls the sweep directly never asks what would have selected the run. The two properties that need the process to die were walked as the plan asks. With the module's perform() hanging, the placeholder existed while the dispatch was in flight and nothing was named; after taskkill and a restart the reclaim re-dispatched the same idempotency key, the retry re-used its own placeholder, and everything was given back. Then, with the module reporting one of two resources as no longer in force, the boot-time reconcile marked the other `orphaned` -- never `reverted`. This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json. Co-Authored-By: Claude <noreply@anthropic.com>
461 lines
19 KiB
JavaScript
461 lines
19 KiB
JavaScript
// ── 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 four actions on every boot', () => {
|
|
registries.registerCore()
|
|
const ids = registries.allEventActions().map((a) => a.id)
|
|
// `core.lease` joined the three in Phase 8, and it is the only one of the four
|
|
// that genuinely changes the world — which is why it is core's rather than each
|
|
// module's: §F puts the duration bound and the two-events-one-target conflict
|
|
// check on core's side of the seam, and a lease verb per module would be that
|
|
// bound re-implemented once per module and advisory everywhere.
|
|
assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue', 'core.lease'])
|
|
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.reconcile, undefined, `${action.id} leaked reconcile`)
|
|
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.announce refuses an unregistered leg terminally, and never claims success', async () => {
|
|
// Phase 1's version of this test asserted that all three core actions REFUSED,
|
|
// because none of them was wired yet. Phase 2 gave them real bodies, so what
|
|
// survives is the half that was never about the placeholder: `ok: true` on an
|
|
// action that did nothing is a recorded world change that did not occur.
|
|
//
|
|
// `core.announce` is the one that can still legitimately refuse. A leg nobody
|
|
// registers will not appear between two attempts a minute apart, so the answer
|
|
// is terminal rather than transient — a human has to fix it.
|
|
registries.registerCore()
|
|
const answer = await registries.eventAction('core.announce').perform({
|
|
params: { leg: 'nowhere', body: 'hello' },
|
|
})
|
|
assert.equal(answer.ok, false)
|
|
assert.equal(answer.retry, false)
|
|
assert.match(answer.error, /nowhere/)
|
|
})
|
|
|
|
test('a dry run validates and reports, but dispatches nothing', async () => {
|
|
// §I's dry run: `verify === true` means validate and report, change nothing.
|
|
// `core.announce` is the only core action with an outside effect to suppress.
|
|
//
|
|
// **The leg is resolved BEFORE `verify` is honoured, and that ordering is the
|
|
// point rather than an oversight.** A dry run exists to report what would
|
|
// happen, and "this step names a leg nobody registers" is the most useful thing
|
|
// it can find. Answering `ok: true` first would make the dry run pass on
|
|
// exactly the definition that cannot work.
|
|
registries.registerCore()
|
|
const announce = registries.eventAction('core.announce')
|
|
|
|
const bad = await announce.perform({ params: { leg: 'nowhere', body: 'hello' }, verify: true })
|
|
assert.equal(bad.ok, false, 'a dry run must surface a leg that does not exist')
|
|
|
|
// A registered leg: reported good, and its transport never touched.
|
|
const leg = registries.announceLeg('discord')
|
|
const dispatch = leg.dispatch
|
|
let dispatched = 0
|
|
leg.dispatch = async () => {
|
|
dispatched += 1
|
|
return { ok: true }
|
|
}
|
|
try {
|
|
const good = await announce.perform({ params: { leg: 'discord', body: 'hello' }, verify: true })
|
|
assert.equal(good.ok, true)
|
|
assert.equal(dispatched, 0, 'a dry run sends nothing')
|
|
} finally {
|
|
leg.dispatch = dispatch
|
|
}
|
|
})
|
|
|
|
test('core.wait defers the next step rather than sleeping, and core.cue parks', async () => {
|
|
// Both answer through ordinary envelope members, which is what lets the runner
|
|
// honour them without knowing what either action is. A `perform` that slept
|
|
// would hold its claim for the duration and turn a five-minute pause into a
|
|
// five-minute lease.
|
|
registries.registerCore()
|
|
assert.deepEqual(await registries.eventAction('core.wait').perform({ params: { seconds: 300 } }), {
|
|
ok: true,
|
|
holdFor: 300,
|
|
})
|
|
assert.deepEqual(await registries.eventAction('core.cue').perform({ params: {} }), {
|
|
ok: true,
|
|
await: 'human',
|
|
})
|
|
})
|
|
|
|
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, 4)
|
|
registries._reset()
|
|
assert.equal(registries.allEventActions().length, 0)
|
|
assert.equal(registries.isEventAction('core.wait'), false)
|
|
})
|
|
|
|
// ── Budgets, leases and option sources (§F, Phase 7) ───────────────────────
|
|
//
|
|
// The three declarations that arrive WITH the module-facing seam. What is worth
|
|
// a test here is the same thing that was worth one for actions: the closed sets
|
|
// are closed, the required members are required, and each id space is its own.
|
|
// The seam being reachable by a module at all is `eventModuleContract.test.js`,
|
|
// against a real loader; these are the shape rules, at the call.
|
|
|
|
const registerBudgets = (owner, entries) => {
|
|
const api = registries.stage(owner)
|
|
api.registerEventBudgets(entries)
|
|
registries.apply(api.staged)
|
|
}
|
|
|
|
const registerLeases = (owner, entries) => {
|
|
const api = registries.stage(owner)
|
|
api.registerEventLeases(entries)
|
|
registries.apply(api.staged)
|
|
}
|
|
|
|
const lease = (over = {}) => ({
|
|
id: 'demo.rate.gain',
|
|
label: 'Gain rate',
|
|
type: 'float',
|
|
min: 0.5,
|
|
max: 5,
|
|
maxDurationMs: 3_600_000,
|
|
read: async () => ({ ok: true, value: 1 }),
|
|
apply: async () => ({ ok: true }),
|
|
restore: async () => ({ ok: true }),
|
|
...over,
|
|
})
|
|
|
|
test('a budget needs a unit, because a number on a cap box is ambiguous without one', () => {
|
|
assert.throws(
|
|
() => registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps' }]),
|
|
/has no unit/,
|
|
)
|
|
// Open vocabulary, deliberately: core never interprets a unit, it renders it.
|
|
// Closing the set would make "kilometres" a MODULE_API bump for a noun core
|
|
// does not read.
|
|
registerBudgets('demo', [{ id: 'demo.road', label: 'Road laid', unit: 'kilometres' }])
|
|
assert.equal(registries.eventBudget('demo.road').unit, 'kilometres')
|
|
})
|
|
|
|
test('a budget and an action are different id spaces, so one name may be both', () => {
|
|
// §F says it in one line — an action names a VERB and a budget names a
|
|
// RESOURCE — and this is the pair every module will actually write. Reading a
|
|
// collision here would forbid the most natural set of names there is.
|
|
const api = registries.stage('demo')
|
|
api.registerEventBudgets([{ id: 'demo.creatures', label: 'Creatures', unit: 'count' }])
|
|
api.registerEventActions([ok({ id: 'demo.creatures' })])
|
|
registries.apply(api.staged)
|
|
assert.equal(registries.eventBudget('demo.creatures').label, 'Creatures')
|
|
assert.equal(registries.eventAction('demo.creatures').label, 'Do the thing')
|
|
})
|
|
|
|
test('a budget is claimed once, and the second claim names who holds it', () => {
|
|
registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
|
|
assert.throws(
|
|
() => registerBudgets('other', [{ id: 'demo.wisps', label: 'Theirs', unit: 'count' }]),
|
|
/already registered by "demo"/,
|
|
)
|
|
})
|
|
|
|
test('a lease declares all three callables, and restore is not optional', () => {
|
|
// `read` could stand in for `restore`, and that is exactly why it may not:
|
|
// `read` answers "what is it now" and `restore` answers "put this back, and
|
|
// tell me if someone else has moved it". The drift check is the one thing a
|
|
// module must not be allowed to skip — a restore that writes blindly silently
|
|
// reverts an operator's manual fix.
|
|
for (const missing of ['read', 'apply', 'restore']) {
|
|
assert.throws(
|
|
() => registerLeases('demo', [lease({ [missing]: undefined })]),
|
|
new RegExp(`has no ${missing}\(\)`),
|
|
)
|
|
}
|
|
})
|
|
|
|
test('a numeric lease is bounded, and an unbounded one is refused', () => {
|
|
// A lease on a rate multiplier with no range is an operator one keystroke away
|
|
// from setting a shard's skill gain to 5000 — and unlike a cap, a bad lease
|
|
// value is in force the moment it is applied.
|
|
assert.throws(() => registerLeases('demo', [lease({ min: undefined })]), /numeric min and max/)
|
|
assert.throws(() => registerLeases('demo', [lease({ min: 9, max: 2 })]), /min 9 above max 2/)
|
|
assert.throws(
|
|
() => registerLeases('demo', [lease({ type: 'int', min: 0.5, max: 5 })]),
|
|
/whole-number min and max/,
|
|
)
|
|
// A bool holds no range and is not asked for one.
|
|
registerLeases('demo', [lease({ id: 'demo.seasonal', type: 'bool', min: undefined, max: undefined })])
|
|
assert.equal(registries.eventLease('demo.seasonal').min, null)
|
|
})
|
|
|
|
test('a lease may not be held longer than core is willing to promise', () => {
|
|
assert.throws(
|
|
() => registerLeases('demo', [lease({ maxDurationMs: registries.MAX_LEASE_MS + 1 })]),
|
|
/maxDurationMs must be 1\.\./,
|
|
)
|
|
assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 0 })]), /maxDurationMs must be/)
|
|
assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 1.5 })]), /maxDurationMs must be/)
|
|
})
|
|
|
|
test('an unknown lease type is refused, because core validates operator input against it', () => {
|
|
assert.throws(() => registerLeases('demo', [lease({ type: 'colour' })]), /needs a type, one of/)
|
|
assert.deepEqual(registries.LEASE_TYPES, ['int', 'float', 'bool', 'string'])
|
|
})
|
|
|
|
test('an option source needs a resolver, and core registers one of its own', () => {
|
|
const api = registries.stage('demo')
|
|
assert.throws(
|
|
() => api.registerEventOptionSources([{ id: 'demo.options.hues', label: 'Hues' }]),
|
|
/has no resolve\(\)/,
|
|
)
|
|
|
|
// Core through the same door (Phase 7): `core.announce`'s `leg` param names a
|
|
// source, and the announce legs are already a registry with labels in them.
|
|
registries.registerCore()
|
|
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), [
|
|
'core.options.legs',
|
|
// Phase 8's, and it is the same argument one phase on: `core.lease`'s `lease`
|
|
// param would otherwise be a free-text box whose typo is caught at dispatch,
|
|
// mid-run — and the leases are already a registry with labels in them.
|
|
'core.options.leases',
|
|
])
|
|
const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
|
|
assert.equal(leg.source, 'core.options.legs')
|
|
const which = registries.eventAction('core.lease').params.find((p) => p.name === 'lease')
|
|
assert.equal(which.source, 'core.options.leases')
|
|
})
|
|
|
|
// ── `reconcile`, the one member Phase 8 added to the action shape ──────────
|
|
//
|
|
// Optional where `revert` is required, and the asymmetry is the design: a module
|
|
// that cannot say what the game still has is not broken — core keeps believing
|
|
// its own ledger, which is the behaviour before this phase — whereas a module
|
|
// that created something and cannot undo it has made a promise core has no way
|
|
// to keep.
|
|
|
|
test('reconcile is optional, must be a function, and only on an action that ledgers', () => {
|
|
const ledgering = {
|
|
id: 'demo.spawn',
|
|
label: 'Spawn',
|
|
risk: 'change',
|
|
reversible: 'ledger',
|
|
perform: async () => ({ ok: true }),
|
|
revert: async () => ({ ok: true }),
|
|
}
|
|
|
|
// Absent is legal, and it lands as an explicit null rather than as a missing
|
|
// key — the same shape `revert` and `cost` take, so the catalog's strip list
|
|
// and the sweep's `typeof` check both have something to look at.
|
|
register('demo', [ledgering])
|
|
assert.equal(registries.eventAction('demo.spawn').reconcile, null)
|
|
registries._reset()
|
|
|
|
assert.throws(
|
|
() => register('demo', [{ ...ledgering, reconcile: 'yes please' }]),
|
|
/reconcile must be a function/,
|
|
)
|
|
|
|
// The mirror check `revert` already has. An action that ledgers nothing has no
|
|
// rows for core to ask about, so a `reconcile` on one is an author who believes
|
|
// something is being tracked and a sweep that will never call it.
|
|
assert.throws(
|
|
() =>
|
|
register('demo', [
|
|
{
|
|
id: 'demo.shout',
|
|
label: 'Shout',
|
|
risk: 'notify',
|
|
reversible: 'none',
|
|
perform: async () => ({ ok: true }),
|
|
reconcile: async () => ({ ok: true, inForce: [] }),
|
|
},
|
|
]),
|
|
/declares reconcile\(\) but is reversible: 'none' and ledgers nothing/,
|
|
)
|
|
})
|
|
|
|
test('an override action may reconcile, because a lease is ledgered too', () => {
|
|
register('demo', [
|
|
{
|
|
id: 'demo.borrow',
|
|
label: 'Borrow',
|
|
risk: 'change',
|
|
reversible: 'override',
|
|
perform: async () => ({ ok: true }),
|
|
reconcile: async () => ({ ok: true, inForce: [] }),
|
|
},
|
|
])
|
|
assert.equal(typeof registries.eventAction('demo.borrow').reconcile, 'function')
|
|
})
|
|
|
|
test('_reset() hands back the three new registries too', () => {
|
|
registries.registerCore()
|
|
registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
|
|
registerLeases('demo', [lease()])
|
|
assert.equal(registries.allEventBudgets().length, 1)
|
|
registries._reset()
|
|
assert.deepEqual(registries.allEventBudgets(), [])
|
|
assert.deepEqual(registries.allEventLeases(), [])
|
|
assert.deepEqual(registries.allEventOptionSources(), [])
|
|
})
|