The first boot against real core refused the whole module at register: `rust.options.runZones` fails core's EVENT_ID grammar, which is lowercase dotted segments only. The fake api validates none of it, so 310 green tests said nothing. The four fixed-choice sources and the chat-server source are renamed, and entry.test now holds every action, budget, lease and option-source id against a copy of the grammar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
297 lines
14 KiB
JavaScript
297 lines
14 KiB
JavaScript
// ── The registration handshake ────────────────────────────────────────────
|
||
//
|
||
// The one suite every module should have, whatever else it does. Core validates
|
||
// all of this at boot and refuses to mount a module that fails — so testing it
|
||
// here is the difference between finding out in half a second and finding out on
|
||
// an operator's install.
|
||
|
||
const test = require('node:test')
|
||
const assert = require('node:assert')
|
||
|
||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||
const manifest = require('../../module.json')
|
||
|
||
/** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */
|
||
function register(ctx = fakeCtx()) {
|
||
require('../core')._reset()
|
||
const api = fakeApi()
|
||
require('../index')(ctx, api)
|
||
return { api, ctx }
|
||
}
|
||
|
||
test('registers exactly the mounts module.json declares', () => {
|
||
const { api } = register()
|
||
|
||
// Core compares these two and rejects a mismatch in EITHER direction: a prefix
|
||
// declared and never registered is as fatal as a route registered and never
|
||
// declared. Asserting against the manifest rather than against a literal is
|
||
// what keeps the test true after a prefix is added.
|
||
assert.deepStrictEqual(
|
||
Object.keys(api.record.routes).sort(),
|
||
Object.keys(manifest.mounts).sort(),
|
||
)
|
||
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
|
||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort())
|
||
}
|
||
})
|
||
|
||
test('all three tiers are mounted (R14)', () => {
|
||
const { api } = register()
|
||
|
||
// Not the assertion above restated. That one says the manifest and the code
|
||
// agree; this one says WHICH answer they agree on, so that deleting a tier from
|
||
// both halves at once still fails. R14 puts this module on all three from the
|
||
// start precisely so that a later phase adding a player surface does not have
|
||
// to move an address clients are already calling.
|
||
assert.deepStrictEqual(Object.keys(api.record.routes).sort(), ['admin', 'player', 'public'])
|
||
for (const tier of ['admin', 'player', 'public']) {
|
||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]), ['/rust'])
|
||
}
|
||
})
|
||
|
||
test('every registered mount is a real express router', () => {
|
||
const { api } = register()
|
||
for (const byPrefix of Object.values(api.record.routes)) {
|
||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||
assert.strictEqual(typeof router, 'function', `${prefix} is not a router`)
|
||
assert.ok(router.stack, `${prefix} has no middleware stack`)
|
||
}
|
||
}
|
||
})
|
||
|
||
test('prefixes are one segment, lowercase, no parameters', () => {
|
||
// §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a
|
||
// module that fails it does not mount at all.
|
||
for (const prefixes of Object.values(manifest.mounts)) {
|
||
for (const prefix of prefixes) {
|
||
assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`)
|
||
}
|
||
}
|
||
})
|
||
|
||
test('registration touches no database and awaits nothing', () => {
|
||
const ctx = fakeCtx()
|
||
register(ctx)
|
||
|
||
// §2.2's first rule. Core requires `app.js` with the pool pointed at a dead
|
||
// port in two build tools, so a query here would hang both — and the symptom is
|
||
// a build that never finishes rather than an error naming this module.
|
||
assert.deepStrictEqual(ctx.db.query.calls, [])
|
||
})
|
||
|
||
test('registers both lifecycle hooks', () => {
|
||
const { api } = register()
|
||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||
})
|
||
|
||
test('the manifest declares what the loader requires', () => {
|
||
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
|
||
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||
assert.ok(manifest.coreApi, 'coreApi is required — it is the version check')
|
||
// Declaring a schema without a purge is refused: a module that can create
|
||
// tables and cannot drop them leaves an operator with orphaned data.
|
||
if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file')
|
||
// The chunk must be in a SUBDIRECTORY — the directory it sits in is what core
|
||
// serves, so an entry in the module root would publish the whole module.
|
||
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
||
})
|
||
|
||
test('the manifest declares no extension slot it does not fill', () => {
|
||
const { api } = register()
|
||
|
||
// §11.3 of the plan reads `extensions` as "declared, and held against reality
|
||
// by the loader". Only the first half is true: the loader checks that a named
|
||
// slot EXISTS (`registries.hasSlot`) and never checks that the module went on
|
||
// to fill it — `checkDeclared` covers `mounts` alone. So a declaration with
|
||
// nothing behind it loads cleanly and means nothing, which is exactly why this
|
||
// module does not write one until it has an extension to register.
|
||
//
|
||
// The other half of that correction: `admin.users.detail` is the ONLY server
|
||
// slot core declares. `site.footer.status` is a CLIENT slot and is registered
|
||
// from the chunk — naming it here would fail the load with
|
||
// `unknown extension slot "site.footer.status"`.
|
||
const declared = manifest.extensions || []
|
||
const filled = api.record.extensions.map((e) => e.slot)
|
||
assert.deepStrictEqual([...declared].sort(), [...filled].sort())
|
||
})
|
||
|
||
test('the Team provider is registered, whole, with the page core links to (phase 9)', () => {
|
||
const { api } = register()
|
||
const provider = api.record.teamProvider
|
||
|
||
// The three required methods, the optional fourth (D48's roster audience),
|
||
// and the fifth member, which is DATA: core substitutes `{externalId}` and
|
||
// nothing else, so the page cannot be nested under its server (D56).
|
||
for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders', 'projectRoster']) {
|
||
assert.strictEqual(typeof provider[name], 'function', `${name} must be a function`)
|
||
}
|
||
assert.strictEqual(provider.pageUrlTemplate, '/rust/clans/{externalId}')
|
||
})
|
||
|
||
test('nothing is registered that has nothing behind it yet', () => {
|
||
const { api } = register()
|
||
|
||
// The phase-1 statement, written down so that removing it is deliberate. A
|
||
// declared trigger nothing emits and a declared slot nothing fills are both
|
||
// surfaces an operator can configure and then wait on — worse than an absent
|
||
// one, because the absence is visible. Each of these arrives with the phase
|
||
// that has something real to put in it, and this assertion is what that phase
|
||
// deletes. Phase 9 deleted the Team provider's line; phase 10 the four
|
||
// engagement lines, and the announce leg and post hook it deliberately did
|
||
// NOT register (D62) moved into the assertions below. Phase 12 deleted the
|
||
// leases and option sources, and kept budgets here on purpose (D79): a lease
|
||
// spends none, and a dimension nothing spends is a dial that does nothing.
|
||
// Phase 13a deleted the budgets and actions lines, and registered each budget
|
||
// beside the verb that spends it (below). Phase 13b registered the announce
|
||
// leg (D104) — the post hook is still absent: nothing in game mirrors a post.
|
||
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['rust.chat'])
|
||
assert.strictEqual(api.record.hooks.post, undefined)
|
||
})
|
||
|
||
test('the event verbs are registered, and every budget has a verb that spends it (phases 13a, 13b)', () => {
|
||
const { api } = register()
|
||
|
||
const actions = api.record.eventActions
|
||
const budgets = api.record.eventBudgets
|
||
assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
|
||
'rust.announce',
|
||
'rust.crate.place',
|
||
'rust.kit.entitle',
|
||
'rust.npc.place',
|
||
'rust.participation.collect',
|
||
'rust.participation.open',
|
||
'rust.zone.open',
|
||
])
|
||
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
|
||
'rust.announcements',
|
||
'rust.grants',
|
||
'rust.npcs',
|
||
'rust.prefabs',
|
||
'rust.zone.minutes',
|
||
])
|
||
|
||
// D79/D89/D97: every dimension has a verb that spends it, and a verb that
|
||
// spends anything spends exactly ONE — priced from its own declared examples,
|
||
// which is how core decides which cap boxes the switchboard shows. A verb
|
||
// whose dimension moved with its params would hide the other dial from every
|
||
// operator. The two tally verbs spend nothing: counting people costs no loot.
|
||
const spent = new Set()
|
||
for (const a of actions) {
|
||
const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
|
||
const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
|
||
if (a.id.startsWith('rust.participation.')) {
|
||
assert.strictEqual(dims.length, 0, `${a.id} prices ${dims.join(', ')}`)
|
||
continue
|
||
}
|
||
assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`)
|
||
spent.add(dims[0])
|
||
}
|
||
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
|
||
|
||
// What a teardown can give back is declared honestly: a line said and a
|
||
// collect filed are not undone.
|
||
const once = new Set(['rust.announce', 'rust.participation.collect'])
|
||
for (const a of actions) {
|
||
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
|
||
if (once.has(a.id)) continue
|
||
assert.strictEqual(typeof a.revert, 'function')
|
||
assert.strictEqual(typeof a.reconcile, 'function')
|
||
// Every param source is one this module registers.
|
||
const sources = new Set(api.record.eventOptionSources.map((s) => s.id))
|
||
for (const p of a.params) if (p.source) assert.ok(sources.has(p.source), `${a.id}.${p.name} names ${p.source}`)
|
||
}
|
||
})
|
||
|
||
test('every event id is one the core grammar accepts (phase 13b)', () => {
|
||
const { api } = register()
|
||
|
||
// Core's EVENT_ID (modules/registries.js), copied rather than imported: this
|
||
// module cannot reach core's tree. The fake api validates none of it, and
|
||
// phase 13b's first boot against real core refused the WHOLE module over one
|
||
// camelCase source id (`rust.options.runZones`). Lowercase dotted segments only.
|
||
const EVENT_ID = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
|
||
const ids = [
|
||
...api.record.eventActions.map((a) => a.id),
|
||
...api.record.eventBudgets.map((b) => b.id),
|
||
...api.record.eventOptionSources.map((s) => s.id),
|
||
...api.record.eventLeases.map((l) => l.id),
|
||
]
|
||
for (const a of api.record.eventActions) for (const p of a.params) if (p.source) ids.push(p.source)
|
||
for (const id of ids) assert.match(id, EVENT_ID, id)
|
||
})
|
||
|
||
test('the leases and their option sources are registered, every source a lease reads (phase 12)', () => {
|
||
const { api } = register()
|
||
|
||
const leases = api.record.eventLeases
|
||
const sources = api.record.eventOptionSources
|
||
assert.deepStrictEqual(
|
||
leases.map((l) => l.id).sort(),
|
||
['rust.decay.scale', 'rust.group.permission', 'rust.population', 'rust.spawn.scalar'],
|
||
)
|
||
|
||
// D78: exactly the sources the leases' targets name, plus those the world
|
||
// verbs' params name (phase 13a) — none without a reader.
|
||
const read = new Set(leases.map((l) => l.target.source))
|
||
for (const a of api.record.eventActions) for (const p of a.params) if (p.source) read.add(p.source)
|
||
assert.deepStrictEqual([...read].sort(), sources.map((s) => s.id).sort())
|
||
|
||
for (const l of leases) {
|
||
// D73: every lease is targeted, because the target is what names the server.
|
||
assert.ok(l.target && l.target.label && l.target.example, `${l.id} has no target`)
|
||
for (const fn of ['read', 'apply', 'restore', 'inForce']) {
|
||
assert.strictEqual(typeof l[fn], 'function', `${l.id} has no ${fn}()`)
|
||
}
|
||
}
|
||
})
|
||
|
||
test('the engagement set is registered as one decision (phase 10, R7)', () => {
|
||
const { api } = register()
|
||
|
||
const triggers = api.record.triggers
|
||
const streams = api.record.streams
|
||
assert.ok(Array.isArray(triggers) && triggers.length > 0)
|
||
assert.ok(Array.isArray(api.record.audiences) && api.record.audiences.length === 3)
|
||
assert.ok(api.record.engagementSeeds && Array.isArray(api.record.engagementSeeds.ruleGroups))
|
||
|
||
// A stream is a toggle for a trigger; one with no trigger behind it is a
|
||
// toggle nothing can ever fire (D65, one namespace across both facets).
|
||
const triggerIds = new Set(triggers.map((t) => t.id))
|
||
for (const s of streams) assert.ok(triggerIds.has(s.id), `stream ${s.id} has no trigger`)
|
||
assert.deepStrictEqual(
|
||
streams.map((s) => s.id).sort(),
|
||
['rust.base.destroyed', 'rust.server.offline', 'rust.server.online', 'rust.wipe.started'],
|
||
)
|
||
})
|
||
|
||
test('the module’s protocol version agrees with the manifest it ships beside', () => {
|
||
const sidecar = require('../sidecarClient')
|
||
|
||
// The wire version is declared in three repos — here, `PROTOCOL_VERSION` in
|
||
// the sidecar, and `overlay.toml` in the plugin overlay — and nothing in one
|
||
// repo can check the other two. What CAN be checked is that this repo says one
|
||
// thing: the number the client sends is the number an operator sees on a
|
||
// freshly created server row, so a bump that edits one and not the other
|
||
// configures every new server against a version the client does not speak.
|
||
assert.strictEqual(typeof sidecar.PROTOCOL_VERSION, 'number')
|
||
assert.ok(sidecar.PROTOCOL_VERSION >= 1)
|
||
})
|
||
|
||
test('an identity capability is declared, and it is the module id (phase 5, D16)', () => {
|
||
// Core flattens every started module's capabilities into ONE list, so a client
|
||
// asking "is this module installed" needs a string only this module can
|
||
// declare. `servers` is not that string — it names a surface, and another
|
||
// module could name it too — which is the whole reason this one exists beside
|
||
// the five surface words.
|
||
//
|
||
// It is asserted against `manifest.id` rather than against the literal "rust"
|
||
// so that the two cannot drift: the day the id changes, the capability a
|
||
// client gates a whole navigation group on has to change with it.
|
||
assert.ok(
|
||
manifest.capabilities.includes(manifest.id),
|
||
`module.json must declare "${manifest.id}" as a capability — it is the only string a client can` +
|
||
' use to tell this module apart from any other, and the Android app gates its Rust rows on it',
|
||
)
|
||
})
|