Files
website/server/test/eventVerify.test.js
wtclaude 8c30cbbddd
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 6m8s
PR Checks / client-build (pull_request) Successful in 7m58s
feat(events): a dry run carries a module's advice (MODULE_API 1.16.0)
An action answering verify: true with { ok: true, detail: { warnings } } has
each line shown as a warning finding (code `advice`) that never fails the
dry run. Strings only, at most ten per step, each cut at 500 characters;
anything else under warnings is ignored, and a refused step carries none.

Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the
world as it stands now (a player on it, a rock beside it), which may change
before the run, so the editor shows it as advice. One optional reading of an
existing member, so minor; Module-uo's ^1.10.0 still resolves and says no
warnings. STEAM_SIGNIN.md's claim moves to 1.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:53 -05:00

303 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The dry run (EVENTS_PLAN.md Phase 6, EVENTS.md §I) ─────────────────────
//
// *"Materialise the steps, dispatch each with `verify: true`, report what would
// happen and what it would cost against the caps."*
//
// **The finding worth the most is the one no other path can make.** Every
// per-step check here is also made at save or at dispatch; the TOTAL is not.
// Three steps each spawning 15 under a cap of 30 pass every individual check and
// breach the cap on the third — at two in the morning, unattended, with the world
// half-changed. Adding the costs up across the whole version is the thing only a
// look at the plan as a whole can do, and it is why a dry run is worth more than
// the sum of its step checks.
//
// The second property this file holds is the one §I states outright: **`verify:
// true` must change nothing and must answer honestly.** So the actions here
// record what they were asked and assert that the flag arrived — a dry run that
// silently dispatched for real is the single worst bug this feature could ship,
// and it would look exactly like a passing test otherwise.
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 { verifySpec } = require('../src/events/verify')
const settingsDb = require('../src/model/events/eventActionSettings.db')
const db = require('../src/utils/db')
after(() => db.close())
const originals = { ...settingsDb }
let store
let seen
beforeEach(() => {
registries._reset()
store = new Map()
seen = []
settingsDb.byIds = async (ids) =>
new Map([...new Set(ids || [])].filter((i) => store.has(i)).map((i) => [i, store.get(i)]))
settingsDb.get = async (id) => store.get(id) || null
})
afterEach(() => {
Object.assign(settingsDb, originals)
registries._reset()
})
/**
* Declare the budget dimensions a batch of actions prices.
*
* §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
* is refused BEFORE any cap arithmetic runs. Without this, every cap test below
* would pass for the WRONG reason — refused by the layer above the one under
* test. Discovered the way the switchboard discovers them, by pricing the
* action's own declared examples, so a test never has to keep a second list of
* its dimensions in step with its `cost()`.
*
* Written out here rather than borrowed from `authorize.dimensionsOf` so this
* file keeps stubbing exactly what it means to stub. The undeclared case is not
* an omission; it has its own test.
*/
const declaredBudgets = (entries, owner) => {
const dimensions = new Set()
for (const e of entries) {
if (typeof e.cost !== 'function') continue
const params = {}
for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
let priced
try {
priced = e.cost(params)
} catch {
continue
}
for (const d of Object.keys(priced || {})) dimensions.add(d)
}
return [...dimensions]
.filter((id) => id.startsWith(`${owner}.`))
.map((id) => ({ id, label: id, unit: 'count' }))
}
const register = (entries, owner = 'test') => {
const api = registries.stage(owner)
api.registerEventActions(entries)
api.registerEventBudgets(declaredBudgets(entries, owner))
registries.apply(api.staged)
}
const setSetting = (id, enabled, caps = {}) =>
store.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
/** An action that records every envelope it is handed and then answers `answer`. */
const recorder = (id, over = {}) => ({
id,
label: over.label || id,
risk: over.risk || 'notify',
reversible: 'none',
version: over.version || 1,
params: over.params || [],
...(over.cost ? { cost: over.cost } : {}),
async perform(envelope) {
seen.push({ id, ...envelope })
if (over.throws) throw new Error(over.throws)
return over.answer || { ok: true }
},
})
const spec = (steps) => ({ phases: [{ key: 'one', steps }] })
const step = (actionId, params = {}, extra = {}) => ({ actionId, params, ...extra })
const ADMIN = { id: 1, role: 'admin' }
const EDITOR = { id: 2, role: 'editor' }
test('every step is dispatched with verify true, and nothing is asked to act', async () => {
// §I's promise, held as an assertion about the envelope each action received.
register([recorder('test.tell'), recorder('test.wait', { risk: 'inspect' })])
const report = await verifySpec(spec([step('test.tell', { body: 'x' }), step('test.wait')]), { user: ADMIN })
assert.equal(report.ok, true)
assert.equal(report.steps, 2)
assert.deepEqual(report.findings, [])
assert.equal(seen.length, 2)
for (const envelope of seen) assert.equal(envelope.verify, true)
})
test('the cost of the whole plan is added up across steps, and a total over the cap is a finding', async () => {
// The one check that only exists here. Each of the three steps fits under 30 on
// its own; together they do not.
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
const report = await verifySpec(
spec([
step('test.spawn', { count: 15 }),
step('test.spawn', { count: 15 }),
step('test.spawn', { count: 15 }),
]),
{ user: ADMIN },
)
assert.equal(report.ok, false)
const total = report.findings.find((f) => f.code === 'cap-total')
assert.ok(total, 'the whole-plan total must be its own finding')
assert.match(total.message, /asks for 45 of "test.creatures" across all its steps, and this deployment allows 30/)
assert.deepEqual(report.cost, [
{ dimension: 'test.creatures', total: 45, cap: 30, from: 'test.spawn', over: true },
])
})
test('a plan that fits reports its cost without a finding', async () => {
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
const report = await verifySpec(spec([step('test.spawn', { count: 12 }), step('test.spawn', { count: 8 })]), {
user: ADMIN,
})
assert.equal(report.ok, true)
assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }])
})
test('an uncapped dimension is reported with its total and no cap', async () => {
// Worth showing rather than hiding: "this event will spawn 40 creatures and
// nothing bounds that" is exactly what an operator opening the switchboard
// wants to have seen first.
register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 40 }) })])
setSetting('test.spawn', true, {})
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, true)
assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 40, cap: null, from: null, over: false }])
})
test('a step naming an action nobody registers is a dormant finding, and the rest are still checked', async () => {
// Reported rather than thrown, so an author sees EVERY problem in one pass. A
// verification that stopped at the first finding would make fixing a
// twelve-step definition twelve round trips.
register([recorder('test.tell')])
const report = await verifySpec(spec([step('gone.away'), step('test.tell')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.equal(report.findings.length, 1)
assert.equal(report.findings[0].code, 'dormant')
assert.equal(report.findings[0].actionId, 'gone.away')
assert.equal(seen.length, 1, 'the registered step is still dispatched')
})
test('the caller’s own role is what the report answers against', async () => {
// The value of doing it here: an editor is told a step needs an administrator
// at the moment they can still do something about it, rather than at the moment
// it does not run.
register([recorder('test.change', { risk: 'change', label: 'Change things' })])
setSetting('test.change', true)
const asEditor = await verifySpec(spec([step('test.change')]), { user: EDITOR })
assert.equal(asEditor.ok, false)
assert.equal(asEditor.findings[0].code, 'role')
const asAdmin = await verifySpec(spec([step('test.change')]), { user: ADMIN })
assert.equal(asAdmin.ok, true)
})
test('a disabled action is a finding, and its step is never dispatched', async () => {
// The order matters: dispatching a disabled action under `verify: true` would
// change nothing, but it would call code the deployment has switched off, and
// "we only ran it to ask whether we could run it" is not a defence anyone wants
// to make.
register([recorder('test.change', { risk: 'change' })])
const report = await verifySpec(spec([step('test.change')]), { user: ADMIN })
assert.equal(report.findings[0].code, 'disabled')
assert.equal(seen.length, 0)
})
test('the module’s own refusal is a finding in the module’s words', async () => {
// The half core cannot compute: whether the landmark exists, whether the
// creature is on the allowlist, whether the shard is reachable at all.
register([recorder('test.spawn', { answer: { ok: false, retry: false, error: 'no such landmark "Bratain"' } })])
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.equal(report.findings[0].code, 'refused')
assert.match(report.findings[0].message, /no such landmark/)
})
test('an action that throws under verify is a finding, not a 500 on the author’s screen', async () => {
register([recorder('test.spawn', { throws: 'exploded' })])
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.equal(report.findings[0].code, 'refused')
assert.match(report.findings[0].message, /exploded/)
})
test('a step authored against an older action version is a WARNING, not an error', async () => {
// §F: a bump makes the editor render a warning rather than refusing to run. The
// level is the whole point — a warning does not hold a scheduled start, and an
// error does.
register([recorder('test.tell', { version: 3 })])
const report = await verifySpec(spec([step('test.tell', {}, { actionVersion: 1 })]), { user: ADMIN })
assert.equal(report.ok, true, 'a drift warning must not fail the dry run')
assert.equal(report.findings[0].level, 'warning')
assert.equal(report.findings[0].code, 'version-drift')
assert.match(report.findings[0].message, /authored against version 1/)
})
test('a module’s advice is a WARNING finding per line, and the dry run still passes (MODULE_API 1.16.0)', async () => {
// Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the
// world as it stands now. A player standing on it may walk off before the run,
// so the check is advice, never a reason the plan fails.
register([
recorder('test.spawn', {
answer: { ok: true, detail: { warnings: ['the spot is refused now: a player is within 50 m', ' ', 'at the spot now: Rock is too large', 7] } },
}),
])
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, true, 'advice must not fail the dry run')
assert.deepEqual(
report.findings.map((f) => [f.level, f.code, f.message, f.actionId]),
[
['warning', 'advice', 'the spot is refused now: a player is within 50 m', 'test.spawn'],
['warning', 'advice', 'at the spot now: Rock is too large', 'test.spawn'],
],
)
})
test('advice is bounded: at most ten lines, each cut at 500 characters, and anything but strings ignored', async () => {
const { adviceOf } = require('../src/events/verify')
assert.deepEqual(adviceOf(null), [])
assert.deepEqual(adviceOf({ warnings: 'one line' }), [])
assert.deepEqual(adviceOf({ note: 'no warnings here' }), [])
assert.equal(adviceOf({ warnings: Array.from({ length: 30 }, (_, i) => `line ${i}`) }).length, 10)
const long = adviceOf({ warnings: ['x'.repeat(900)] })[0]
assert.equal(long.length, 500)
assert.ok(long.endsWith('…'))
})
test('a refused step carries no advice: the refusal is the finding', async () => {
register([recorder('test.spawn', { answer: { ok: false, retry: false, error: 'no such base', detail: { warnings: ['ignored'] } } })])
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.deepEqual(report.findings.map((f) => f.code), ['refused'])
})
test('a finding says which phase and which step it is about', async () => {
// A twelve-step definition needs the finding anchored, or the author is left
// reading the message and counting rows.
register([recorder('test.tell')])
const twoPhases = {
phases: [
{ key: 'open', steps: [step('test.tell')] },
{ key: 'close', steps: [step('test.tell'), step('gone.away')] },
],
}
const report = await verifySpec(twoPhases, { user: ADMIN })
assert.equal(report.steps, 3)
assert.deepEqual(
report.findings.map((f) => ({ phase: f.phase, seq: f.seq })),
[{ phase: 'close', seq: 1 }],
)
})
test('an empty spec verifies clean and costs nothing', async () => {
const report = await verifySpec({ phases: [] }, { user: ADMIN })
assert.deepEqual(report, { ok: true, steps: 0, findings: [], cost: [] })
})