The whole-rig walk (ServUO + sidecar + website) against a real two-phase event.
- **A WS reconnect would have orphaned every live resource.** The backfill
replays the last several `server.hello` frames in order — this rig saw three,
each with a different `bootId` — so every replayed frame reads as a restart,
and the intermediate ones compare a resource stamped with the CURRENT boot
against a boot that ended hours ago. The row is then `orphaned`: a live crier
line core will never take down again, lost to nothing worse than the website
reconnecting. Gated on `!fromBackfill`, the rule the engagement fan-out and
the SSE broadcast beside it already state. The website-was-down case is not
missed — core asks every module at its own boot.
- **The shard explains its refusals and the run log dropped the explanation.**
A 403 body reads `{"reason":"admin write plane disabled"}`; `legError` looks
for `data.message`, finds nothing, and reports "sidecar responded 403". For a
staff member clicking a button that is survivable. For an event that ran at
four in the morning the run log is the only place anyone will learn why.
- **The "not retried" clause explained the wrong thing on a permanent status.**
A 403 will not succeed on any attempt, so telling an operator it was not
retried "because a repeat would announce twice" points them at a policy
decision instead of at the switch they have to flip. The clause is now added
only where a retry was genuinely given up, and 403/404 join the statuses the
keyed verbs treat as terminal.
Co-Authored-By: Claude <noreply@anthropic.com>
448 lines
22 KiB
JavaScript
448 lines
22 KiB
JavaScript
// module-uo's event verbs, wave 1 (EVENTS_PLAN.md Phase 9).
|
|
//
|
|
// The declarations are data plus three `perform()`s, so most of this suite is
|
|
// about the *shapes* core will check and the failure paths a live rig cannot be
|
|
// made to produce on demand — a sidecar that answers 409, a shard that restarts
|
|
// between two steps, a crier line one character over the cap.
|
|
//
|
|
// **The first test is the one the whole phase rests on.** Every other property
|
|
// here — "a broadcast is sent once", "a failed post is retried" — is a claim
|
|
// about what the MODULE decided, and the module only gets to decide when its
|
|
// client answers before core's dispatch deadline. Assert the relationship, not
|
|
// the numbers, or the day someone tunes one of them the suite stays green while
|
|
// the behaviour inverts.
|
|
|
|
const { test, beforeEach, afterEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const uoLinkClient = require('../utils/uoLinkClient')
|
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
|
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
|
require('./_setup')
|
|
const actions = require('../config/uoEventActions')
|
|
|
|
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
|
|
|
let calls
|
|
const saved = {}
|
|
|
|
beforeEach(() => {
|
|
calls = { broadcast: [], crier: [], crierDel: [], news: [], newsDel: [] }
|
|
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
|
|
saved[name] = uoLinkClient[name]
|
|
}
|
|
saved.getSafe = uoLinkConfig.getSafe
|
|
saved.listRegions = shardAtlas.listRegions
|
|
saved.listLandmarks = shardAtlas.listLandmarks
|
|
saved.searchCreatures = shardAtlas.searchCreatures
|
|
|
|
uoLinkClient.adminBroadcast = async (b) => { calls.broadcast.push(b); return { ok: true, status: 200 } }
|
|
uoLinkClient.postTownCrier = async (b) => { calls.crier.push(b); return { ok: true, status: 200 } }
|
|
uoLinkClient.deleteTownCrier = async (id) => { calls.crierDel.push(id); return { ok: true, status: 200 } }
|
|
uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } }
|
|
uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } }
|
|
uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' })
|
|
})
|
|
|
|
afterEach(() => {
|
|
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
|
|
uoLinkClient[name] = saved[name]
|
|
}
|
|
uoLinkConfig.getSafe = saved.getSafe
|
|
shardAtlas.listRegions = saved.listRegions
|
|
shardAtlas.listLandmarks = saved.listLandmarks
|
|
shardAtlas.searchCreatures = saved.searchCreatures
|
|
})
|
|
|
|
// ── The rule everything else depends on ────────────────────────────────────
|
|
|
|
test('every action outlives the sidecar client, so the module classifies its own failures', () => {
|
|
// `dispatch.classify()` answers `retry` for a budget timeout unconditionally
|
|
// and never asks the action. If core's deadline can fire before the client
|
|
// gives up, `retry: false` below is unreachable and a broadcast is retried.
|
|
for (const action of actions.ACTIONS) {
|
|
assert.ok(
|
|
action.budgetMs > uoLinkClient.TIMEOUT_MS,
|
|
`${action.id} budgetMs (${action.budgetMs}) must exceed uoLinkClient.TIMEOUT_MS (${uoLinkClient.TIMEOUT_MS})`,
|
|
)
|
|
}
|
|
})
|
|
|
|
// ── The declarations, against the checks core will run ─────────────────────
|
|
|
|
test('the declarations satisfy the shape core validates them with', () => {
|
|
const RISKS = ['notify', 'inspect', 'change', 'irreversible']
|
|
const REVERSIBLE = ['none', 'self', 'ledger', 'override']
|
|
const PARAM_TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
|
|
|
|
for (const a of actions.ACTIONS) {
|
|
assert.ok(a.id.startsWith('uo.'), `${a.id} must be namespaced to this module`)
|
|
assert.ok(a.label && a.description, `${a.id} needs a label and a description`)
|
|
assert.ok(RISKS.includes(a.risk), `${a.id} has an unknown risk class`)
|
|
assert.ok(REVERSIBLE.includes(a.reversible), `${a.id} has an unknown reversible class`)
|
|
assert.equal(typeof a.perform, 'function')
|
|
|
|
// `revert` is required iff ledger, and forbidden otherwise — a revert on a
|
|
// non-ledgering action is an undo core will never call.
|
|
assert.equal(
|
|
typeof a.revert === 'function',
|
|
a.reversible === 'ledger',
|
|
`${a.id} revert() must be present exactly when reversible is 'ledger'`,
|
|
)
|
|
// `reconcile` is optional, but only meaningful where something is ledgered.
|
|
if (a.reconcile !== undefined) {
|
|
assert.equal(typeof a.reconcile, 'function')
|
|
assert.ok(a.reversible === 'ledger' || a.reversible === 'override', `${a.id} reconciles but ledgers nothing`)
|
|
}
|
|
if (a.cost !== undefined) assert.equal(typeof a.cost, 'function')
|
|
|
|
const names = new Set()
|
|
for (const p of a.params) {
|
|
assert.ok(!names.has(p.name), `${a.id} declares ${p.name} twice`)
|
|
names.add(p.name)
|
|
assert.ok(PARAM_TYPES.includes(p.type), `${a.id}.${p.name} has an unsupported type "${p.type}"`)
|
|
// Required on every param including the optional ones: it is the authoring
|
|
// placeholder, and an unattended world write typed into a blank box is how
|
|
// a typo gets scheduled.
|
|
assert.ok(
|
|
p.example !== undefined && p.example !== null && p.example !== '',
|
|
`${a.id}.${p.name} needs an example`,
|
|
)
|
|
assert.ok(p.description, `${a.id}.${p.name} needs a description`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('a broadcast spends the one budget dimension the module declares', () => {
|
|
const declared = new Set(actions.BUDGETS.map((b) => b.id))
|
|
assert.deepEqual([...declared], ['uo.broadcasts'])
|
|
for (const b of actions.BUDGETS) {
|
|
assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced')
|
|
assert.ok(b.label && b.unit, 'a dimension is rendered as a label and a unit beside a number')
|
|
}
|
|
|
|
// Every dimension a cost names must be one the module declared, or core is
|
|
// asked to bound something nothing defines.
|
|
const cost = byId('uo.broadcast').cost({})
|
|
assert.deepEqual(cost, { 'uo.broadcasts': 1 })
|
|
for (const id of Object.keys(cost)) assert.ok(declared.has(id), `${id} is spent but never declared`)
|
|
|
|
// The keyed verbs deliberately spend nothing: a repeat REPLACES under the same
|
|
// id, so there is no runaway for a cap to bound.
|
|
assert.equal(byId('uo.towncrier.post').cost, undefined)
|
|
assert.equal(byId('uo.news.post').cost, undefined)
|
|
})
|
|
|
|
// ── uo.broadcast: attempted exactly once ───────────────────────────────────
|
|
|
|
test('a broadcast is never retried, whatever the sidecar says', async () => {
|
|
const broadcast = byId('uo.broadcast')
|
|
// Every failure this transport can produce: no route to the sidecar, a data
|
|
// refusal, a bad token, a protocol mismatch, a shard that is not connected and
|
|
// a shard that timed out. The last two are genuinely transient, and this is
|
|
// the trade being taken knowingly — a lost announcement is cheaper than one
|
|
// delivered twice to everyone online.
|
|
for (const status of [0, 400, 401, 403, 409, 503, 504]) {
|
|
uoLinkClient.adminBroadcast = async () => ({ ok: false, status, error: `status ${status}` })
|
|
const result = await broadcast.perform({ runId: 7, params: { text: 'hear ye' }, verify: false })
|
|
assert.equal(result.ok, false)
|
|
assert.equal(result.retry, false, `a ${status} must not be retried`)
|
|
// The clause belongs only where a retry was genuinely given up. On a
|
|
// permanent status it would explain the wrong thing.
|
|
if (!actions.PERMANENT_STATUSES.has(status)) {
|
|
assert.match(result.error, /announce twice/, 'a discarded retry must say why')
|
|
} else {
|
|
assert.doesNotMatch(result.error, /announce twice/, `a ${status} was never retryable`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test("the shard's own words reach the run log, not just a status code", async () => {
|
|
// **The rig found this.** The sidecar refuses a broadcast with
|
|
// `{"reason":"admin write plane disabled"}` and `legError` looks for
|
|
// `data.message`, so the run console read "sidecar responded 403" for a cause
|
|
// the shard had already explained in a sentence. A staff member clicking a
|
|
// button knows what they switched off; an event that ran at four in the morning
|
|
// leaves the run log as the only place anyone will learn why.
|
|
uoLinkClient.adminBroadcast = async () => ({
|
|
ok: false,
|
|
status: 403,
|
|
data: { kind: 'admin.error', reason: 'admin write plane disabled' },
|
|
error: 'sidecar responded 403',
|
|
})
|
|
const result = await byId('uo.broadcast').perform({ runId: 1, params: { text: 'hear ye' }, verify: false })
|
|
assert.match(result.error, /admin write plane disabled/)
|
|
// And NOT the double-announce clause: a 403 will not succeed on any attempt, so
|
|
// pointing an operator at a policy decision misdirects them away from the
|
|
// switch they actually have to flip.
|
|
assert.doesNotMatch(result.error, /announce twice/)
|
|
assert.equal(result.retry, false)
|
|
})
|
|
|
|
test('a permanent refusal of a keyed verb is not retried either', async () => {
|
|
// Same distinction on the other side: the keyed verbs DO retry a transient, and
|
|
// must not burn three attempts on a refusal that cannot change.
|
|
uoLinkClient.postTownCrier = async () => ({ ok: false, status: 403, data: { reason: 'admin write plane disabled' } })
|
|
const result = await byId('uo.towncrier.post').perform({
|
|
runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines: 'hear ye' }, verify: false,
|
|
})
|
|
assert.equal(result.retry, false)
|
|
assert.match(result.error, /admin write plane disabled/)
|
|
})
|
|
|
|
test('a broadcast names its run in the shard audit, not a staff member', async () => {
|
|
await byId('uo.broadcast').perform({ runId: 42, params: { text: 'hear ye', hue: 1153 }, verify: false })
|
|
assert.equal(calls.broadcast.length, 1)
|
|
assert.equal(calls.broadcast[0].actor, 'event:42')
|
|
assert.equal(calls.broadcast[0].hue, 1153)
|
|
})
|
|
|
|
test('an over-long broadcast is refused by the DRY RUN, before anything is sent', async () => {
|
|
const broadcast = byId('uo.broadcast')
|
|
const text = 'x'.repeat(actions.MAX_BROADCAST_LEN + 1)
|
|
|
|
const dry = await broadcast.perform({ runId: 1, params: { text }, verify: true })
|
|
assert.equal(dry.ok, false)
|
|
assert.equal(dry.retry, false)
|
|
assert.match(dry.error, new RegExp(String(actions.MAX_BROADCAST_LEN)))
|
|
|
|
const live = await broadcast.perform({ runId: 1, params: { text }, verify: false })
|
|
assert.equal(live.ok, false)
|
|
assert.deepEqual(calls.broadcast, [], 'nothing may reach the shard once the cap is breached')
|
|
})
|
|
|
|
test('a dry run sends nothing at all', async () => {
|
|
for (const action of actions.ACTIONS) {
|
|
const params = {}
|
|
for (const p of action.params) if (p.required) params[p.name] = p.example
|
|
const result = await action.perform({ runId: 1, stepId: 1, idempotencyKey: 'k'.repeat(40), params, verify: true })
|
|
assert.equal(result.ok, true, `${action.id} refused its own example params`)
|
|
assert.equal(result.resources, undefined, `${action.id} reported a resource it never created`)
|
|
}
|
|
assert.deepEqual(
|
|
[calls.broadcast.length, calls.crier.length, calls.news.length],
|
|
[0, 0, 0],
|
|
'a dry run reached the shard',
|
|
)
|
|
})
|
|
|
|
// ── The keyed verbs: one id, stable across a retry ─────────────────────────
|
|
|
|
test('the crier and the news gump post under a run-stable id a retry replaces', async () => {
|
|
const key = 'a1b2c3'.padEnd(40, '0')
|
|
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
|
|
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
|
|
|
|
assert.equal(calls.crier.length, 2)
|
|
assert.equal(calls.crier[0].id, calls.crier[1].id, 'a retry must replace, not stack')
|
|
assert.equal(calls.crier[0].id, `evt-${key}`)
|
|
// The sidecar's own cap on the id column.
|
|
assert.ok(calls.crier[0].id.length <= 64)
|
|
})
|
|
|
|
test('an event article cannot collide with a website post in the news gump', async () => {
|
|
// `newsGump.js` posts site articles under the bare post id and re-pushes that
|
|
// whole set on every reconnect. An event article numbered into the same space
|
|
// would silently be a collision with a post, in whichever direction wrote last.
|
|
await byId('uo.news.post').perform({
|
|
runId: 9,
|
|
idempotencyKey: 'f'.repeat(40),
|
|
params: { title: 'The Fair', body: 'Merchants gather.' },
|
|
verify: false,
|
|
})
|
|
assert.equal(calls.news.length, 1)
|
|
assert.doesNotMatch(calls.news[0].id, /^\d+$/, 'an event article must not be numbered like a post')
|
|
assert.match(calls.news[0].id, /^evt-/)
|
|
assert.match(calls.news[0].body, /<CENTER>The Fair<\/CENTER>/)
|
|
assert.equal(calls.news[0].announce, true, 'announce defaults on, as the gump does')
|
|
})
|
|
|
|
test('the keyed verbs DO retry, because a repeat replaces', async () => {
|
|
for (const [id, stub] of [['uo.towncrier.post', 'postTownCrier'], ['uo.news.post', 'postNews']]) {
|
|
const params = { lines: 'hear ye', title: 'The Fair', body: 'Merchants gather.' }
|
|
// The announce leg's own classification of this transport, reused rather
|
|
// than re-decided: a config or data problem is terminal, the rest transient.
|
|
for (const [status, retry] of [[400, false], [401, false], [403, false], [409, false], [503, true], [504, true], [0, true]]) {
|
|
uoLinkClient[stub] = async () => ({ ok: false, status, error: `status ${status}` })
|
|
const result = await byId(id).perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params, verify: false })
|
|
assert.equal(result.ok, false)
|
|
assert.equal(result.retry, retry, `${id} misclassified a ${status}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('a crier post is refused before it is sent when it is not eight short lines', async () => {
|
|
const crier = byId('uo.towncrier.post')
|
|
const cases = [
|
|
['', /empty/],
|
|
[' \n ', /empty/],
|
|
[Array.from({ length: actions.MAX_CRIER_LINES + 1 }, (_, i) => `line ${i}`).join('\n'), /criers carry/],
|
|
['x'.repeat(actions.MAX_CRIER_LINE_LEN + 1), /capped at/],
|
|
]
|
|
for (const [lines, expected] of cases) {
|
|
const result = await crier.perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines }, verify: false })
|
|
assert.equal(result.ok, false)
|
|
assert.equal(result.retry, false, 'a badly shaped message is just as badly shaped next minute')
|
|
assert.match(result.error, expected)
|
|
}
|
|
assert.deepEqual(calls.crier, [])
|
|
})
|
|
|
|
test('blank lines are dropped rather than counted against the cap', () => {
|
|
// A textarea an operator has pressed enter in twice still holds two lines.
|
|
const parsed = actions.crierLines('hear ye\n\n \nseek the herald\n')
|
|
assert.equal(parsed.ok, true)
|
|
assert.deepEqual(parsed.lines, ['hear ye', 'seek the herald'])
|
|
})
|
|
|
|
test('a crier duration is taken in minutes and bounded at the sidecar cap', async () => {
|
|
const crier = byId('uo.towncrier.post')
|
|
const base = { runId: 1, idempotencyKey: 'k'.repeat(40), verify: false }
|
|
|
|
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 90 } })
|
|
assert.equal(calls.crier[0].durationSec, 5400)
|
|
|
|
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 60 * 48 } })
|
|
assert.equal(calls.crier[1].durationSec, 86400, 'a duration past the sidecar cap is clamped, not refused')
|
|
|
|
// Left out entirely, so the sidecar applies its own default rather than the
|
|
// module inventing one.
|
|
await crier.perform({ ...base, params: { lines: 'hear ye' } })
|
|
assert.equal(calls.crier[2].durationSec, undefined)
|
|
|
|
const bad = await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 'soon' } })
|
|
assert.equal(bad.ok, false)
|
|
assert.equal(bad.retry, false)
|
|
})
|
|
|
|
// ── Giving it back ─────────────────────────────────────────────────────────
|
|
|
|
test('a resource that is already gone is a successful revert', async () => {
|
|
// §L: "gone, and that is fine". A crier line whose duration ran out is a 404,
|
|
// and it is the outcome teardown wanted.
|
|
uoLinkClient.deleteTownCrier = async () => ({ ok: false, status: 404 })
|
|
uoLinkClient.deleteNews = async () => ({ ok: false, status: 404 })
|
|
|
|
for (const id of ['uo.towncrier.post', 'uo.news.post']) {
|
|
const result = await byId(id).revert({ runId: 1, resources: [{ kind: 'x', ref: 'evt-1' }] })
|
|
assert.equal(result.ok, true)
|
|
assert.ok(!result.failed || !result.failed.length)
|
|
}
|
|
})
|
|
|
|
test('a revert names the resources that did not come back', async () => {
|
|
uoLinkClient.deleteTownCrier = async (id) => {
|
|
calls.crierDel.push(id)
|
|
return id === 'evt-bad' ? { ok: false, status: 503 } : { ok: true, status: 200 }
|
|
}
|
|
const result = await byId('uo.towncrier.post').revert({
|
|
runId: 1,
|
|
resources: [{ ref: 'evt-ok' }, { ref: 'evt-bad' }],
|
|
})
|
|
// `ok: true` with a `failed` list, not `ok: false`: the group was worked, and
|
|
// one member of it is outstanding. Core keeps the row and tries it again.
|
|
assert.equal(result.ok, true)
|
|
assert.deepEqual(result.failed, ['evt-bad'])
|
|
assert.deepEqual(calls.crierDel, ['evt-ok', 'evt-bad'], 'one failure must not stop the group')
|
|
})
|
|
|
|
// ── reconcile: the boot stamp ──────────────────────────────────────────────
|
|
|
|
test('a resource stamped with the current boot is still in force', async () => {
|
|
const resources = [
|
|
{ kind: 'towncrier', ref: 'evt-a', payload: { bootId: 'boot-1' } },
|
|
{ kind: 'towncrier', ref: 'evt-b', payload: { bootId: 'boot-0' } },
|
|
]
|
|
const result = await actions.reconcileByBootId({ resources })
|
|
assert.equal(result.ok, true)
|
|
// Only the row from the boot that is still running. Core orphans the other —
|
|
// which is the honest sentence: it vanished while nobody was looking, rather
|
|
// than core having put it back.
|
|
assert.deepEqual(result.inForce, ['evt-a'])
|
|
})
|
|
|
|
test('a resource with no stamp is reported in force, because "I do not know" is not "it is gone"', async () => {
|
|
const result = await actions.reconcileByBootId({
|
|
resources: [{ ref: 'evt-old', payload: null }, { ref: 'evt-older', payload: {} }],
|
|
})
|
|
assert.deepEqual(result.inForce, ['evt-old', 'evt-older'])
|
|
})
|
|
|
|
test('with no shard boot to compare against, reconcile declines rather than orphaning everything', async () => {
|
|
uoLinkConfig.getSafe = async () => ({ bootId: null })
|
|
const result = await actions.reconcileByBootId({ resources: [{ ref: 'evt-a', payload: { bootId: 'boot-1' } }] })
|
|
// Core treats anything that is not an explicit answer as unanswered and leaves
|
|
// the ledger alone. An `ok: true, inForce: []` here would abandon every live row
|
|
// on a website that came up before its sidecar did.
|
|
assert.equal(result.ok, false)
|
|
})
|
|
|
|
test('a write with an unreadable config still happens, and simply carries no stamp', async () => {
|
|
uoLinkConfig.getSafe = async () => { throw new Error('pool is down') }
|
|
const result = await byId('uo.towncrier.post').perform({
|
|
runId: 1,
|
|
idempotencyKey: 'k'.repeat(40),
|
|
params: { lines: 'hear ye' },
|
|
verify: false,
|
|
})
|
|
assert.equal(result.ok, true, 'a config read must not fail a world write')
|
|
assert.equal(result.resources[0].payload.bootId, null)
|
|
})
|
|
|
|
// ── Option sources ─────────────────────────────────────────────────────────
|
|
|
|
const source = (id) => actions.OPTION_SOURCES.find((s) => s.id === id)
|
|
|
|
test('every option source is namespaced and answers', () => {
|
|
for (const s of actions.OPTION_SOURCES) {
|
|
assert.ok(s.id.startsWith('uo.options.'), `${s.id} must be namespaced`)
|
|
assert.ok(s.label && s.description)
|
|
assert.equal(typeof s.resolve, 'function')
|
|
}
|
|
})
|
|
|
|
test('a place is named by its facet, because two facets both have a Britain', async () => {
|
|
shardAtlas.listRegions = async () => [
|
|
{ facet: 'Felucca', name: 'Britain' },
|
|
{ facet: 'Trammel', name: 'Britain' },
|
|
]
|
|
const options = await source('uo.options.regions').resolve()
|
|
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'two different places must not share a value')
|
|
assert.deepEqual(options[0], { value: 'Felucca/Britain', label: 'Britain', group: 'Felucca' })
|
|
})
|
|
|
|
test('a landmark groups by the atlas grouping where it has one, the facet otherwise', async () => {
|
|
shardAtlas.listLandmarks = async () => [
|
|
{ facet: 'Felucca', name: 'Despise', group: 'Dungeons' },
|
|
{ facet: 'Felucca', name: 'Cove', group: null },
|
|
]
|
|
const options = await source('uo.options.landmarks').resolve()
|
|
assert.deepEqual(options.map((o) => o.group), ['Dungeons', 'Felucca'])
|
|
})
|
|
|
|
test('a creature needs no qualifier — the slug is the same type wherever it spawns', async () => {
|
|
shardAtlas.searchCreatures = async ({ limit }) => {
|
|
assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for')
|
|
return { creatures: [{ slug: 'orc-brute', name: 'Orc Brute' }] }
|
|
}
|
|
assert.deepEqual(await source('uo.options.creatures').resolve(), [
|
|
{ value: 'orc-brute', label: 'Orc Brute' },
|
|
])
|
|
})
|
|
|
|
test('an atlas larger than the dropdown bound is truncated and said so', async () => {
|
|
const { ctx } = require('./_setup')
|
|
shardAtlas.listRegions = async () =>
|
|
Array.from({ length: actions.MAX_OPTIONS + 5 }, (_, i) => ({ facet: 'Felucca', name: `Region ${i}` }))
|
|
const options = await source('uo.options.regions').resolve()
|
|
assert.equal(options.length, actions.MAX_OPTIONS)
|
|
// Silently serving 2000 of 2005 is the defect the bound would otherwise
|
|
// introduce: an author cannot find the landmark they are looking for and
|
|
// nothing anywhere says why.
|
|
const warned = ctx.logs
|
|
.filter((l) => l.namespace === 'uo-events')
|
|
.flatMap((l) => l.log.warn.calls)
|
|
.some(([message]) => /truncated/.test(message))
|
|
assert.ok(warned, 'a truncated source must leave a log line naming itself')
|
|
})
|