test(client): unit-test the pure-logic modules + wire coverage into CI/Sonar
Stand up a client test suite on Node's built-in runner (no vitest/jsdom — the targeted modules are plain ESM with no browser/DOM deps) and cover the meaningful client logic, not presentational components: - api/client.js: the fetch wrapper — always sends the session cookie, maps a non-2xx response to a thrown ApiError (body.message → statusText fallback), resolves an empty body to null, sets Content-Type for JSON but NOT for raw FormData uploads, and builds/encodes query strings + path params. - lib/shardEvents.js: describe() across event kinds (payload vs live frame, actor name→acct→"Someone" fallback, sale pluralization, champ.update branches) and the categoryOf table-consistency check. - data/regionBuckets.js: the presence roll-up, incl. the first-match-wins ordering and the "buckets always reconcile to the total" invariant. - lib/heroLayout.js: parseLayout's version/shape guard and heroBackground's default-vs-custom branch. - lib/format.js: the date/label formatters + relative-time buckets. Wiring so these actually count: - client/package.json gains a `test` script (node --test); - pr-checks.yml runs the client tests as a PR gate; - sonarqube.yml generates a client LCOV report and sonar-project.properties feeds it alongside the server report (SF paths resolve to client/src/...). 43 client tests; coverage on the tested modules: format/regionBuckets/ heroLayout 100%, api client 86%, shardEvents 80%. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
|
||||
142
client/test/apiClient.test.js
Normal file
142
client/test/apiClient.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { api, ApiError } from '../src/api/client.js'
|
||||
|
||||
// Unit-test the fetch wrapper that every API call flows through. The behaviors
|
||||
// that matter to the whole app:
|
||||
// - it always sends the session cookie (credentials: 'include');
|
||||
// - a non-2xx response becomes a thrown ApiError carrying status + a message
|
||||
// (server body.message → statusText → generic), never a silent bad value;
|
||||
// - an empty body resolves to null (not a JSON parse throw);
|
||||
// - JSON bodies get a Content-Type, but a raw FormData upload does NOT (so the
|
||||
// browser can set the multipart boundary);
|
||||
// - query strings and path params are built/encoded correctly.
|
||||
// We drive the real req() by mocking global.fetch and inspecting what it received.
|
||||
|
||||
let calls
|
||||
const realFetch = global.fetch
|
||||
|
||||
// Build a fake Response-ish object req() understands (ok/status/statusText/text()).
|
||||
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText,
|
||||
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
global.fetch = async (url, opts) => {
|
||||
calls.push({ url, opts })
|
||||
return calls.nextReply || reply({ body: { ok: true } })
|
||||
}
|
||||
})
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch
|
||||
})
|
||||
|
||||
// helper to queue the next response
|
||||
function willReply(r) {
|
||||
global.fetch = async (url, opts) => {
|
||||
calls.push({ url, opts })
|
||||
return reply(r)
|
||||
}
|
||||
}
|
||||
|
||||
// ── happy path + cookie + base path ─────────────────────────────────────
|
||||
test('a GET hits the same-origin /api/v1 base, sends cookies, and returns parsed JSON', async () => {
|
||||
willReply({ body: { user: { id: 1 } } })
|
||||
const out = await api.me()
|
||||
assert.equal(calls[0].url, '/api/v1/auth/me')
|
||||
assert.equal(calls[0].opts.credentials, 'include')
|
||||
assert.equal(calls[0].opts.method, 'GET')
|
||||
assert.deepEqual(out, { user: { id: 1 } })
|
||||
})
|
||||
|
||||
// ── error mapping ───────────────────────────────────────────────────────
|
||||
test('a non-ok response throws an ApiError with status and the server message', async () => {
|
||||
willReply({ status: 401, statusText: 'Unauthorized', body: { message: 'Incorrect username or password.' } })
|
||||
await assert.rejects(
|
||||
() => api.login('u', 'bad'),
|
||||
(err) => {
|
||||
assert.ok(err instanceof ApiError)
|
||||
assert.equal(err.status, 401)
|
||||
assert.equal(err.message, 'Incorrect username or password.')
|
||||
assert.deepEqual(err.body, { message: 'Incorrect username or password.' })
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('an error with no JSON message falls back to statusText', async () => {
|
||||
willReply({ status: 503, statusText: 'Service Unavailable', body: '' })
|
||||
await assert.rejects(
|
||||
() => api.status(),
|
||||
(err) => err instanceof ApiError && err.status === 503 && err.message === 'Service Unavailable',
|
||||
)
|
||||
})
|
||||
|
||||
// ── empty body ──────────────────────────────────────────────────────────
|
||||
test('an empty 200 body resolves to null instead of throwing on JSON.parse', async () => {
|
||||
willReply({ status: 200, body: '' })
|
||||
const out = await api.logout()
|
||||
assert.equal(out, null)
|
||||
})
|
||||
|
||||
test('a non-JSON body is returned as the raw text (safeParse tolerates it)', async () => {
|
||||
willReply({ status: 200, body: 'plain text' })
|
||||
const out = await api.me()
|
||||
assert.equal(out, 'plain text')
|
||||
})
|
||||
|
||||
// ── request body encoding ───────────────────────────────────────────────
|
||||
test('a JSON POST serializes the body and sets Content-Type', async () => {
|
||||
willReply({ body: { user: { id: 9 } } })
|
||||
await api.register('newbie', 'pw', { company: '' })
|
||||
const { opts } = calls[0]
|
||||
assert.equal(opts.method, 'POST')
|
||||
assert.equal(opts.headers['Content-Type'], 'application/json')
|
||||
assert.deepEqual(JSON.parse(opts.body), { username: 'newbie', password: 'pw', company: '' })
|
||||
})
|
||||
|
||||
test('a raw FormData upload does NOT set Content-Type and passes the body untouched', async () => {
|
||||
willReply({ body: { url: '/uploads/x.png' } })
|
||||
const fakeFile = { name: 'x.png' }
|
||||
await api.admin.upload(fakeFile)
|
||||
const { opts } = calls[0]
|
||||
assert.equal(opts.method, 'POST')
|
||||
assert.equal(opts.headers['Content-Type'], undefined) // browser sets the multipart boundary
|
||||
assert.ok(opts.body instanceof FormData)
|
||||
})
|
||||
|
||||
// ── query strings + path param encoding ─────────────────────────────────
|
||||
test('wiki() builds a query string only from the params that are set', async () => {
|
||||
willReply({ body: [] })
|
||||
await api.wiki({ category: 'lore', q: 'dragon slayer' })
|
||||
const url = new URL(calls[0].url, 'http://x')
|
||||
assert.equal(url.pathname, '/api/v1/public/wiki')
|
||||
assert.equal(url.searchParams.get('category'), 'lore')
|
||||
assert.equal(url.searchParams.get('q'), 'dragon slayer')
|
||||
assert.equal(url.searchParams.get('tag'), null) // omitted when unset
|
||||
})
|
||||
|
||||
test('wiki() with no options sends no query string at all', async () => {
|
||||
willReply({ body: [] })
|
||||
await api.wiki()
|
||||
assert.equal(calls[0].url, '/api/v1/public/wiki')
|
||||
})
|
||||
|
||||
test('path params are URL-encoded (a token/city with unsafe characters is escaped)', async () => {
|
||||
willReply({ body: {} })
|
||||
await api.shard.governorHistory('Serpent’s Hold', 5)
|
||||
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
|
||||
})
|
||||
|
||||
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
|
||||
willReply({ body: {} })
|
||||
await api.revokeMySession('a b/c')
|
||||
assert.equal(calls[0].opts.method, 'DELETE')
|
||||
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||
})
|
||||
55
client/test/format.test.js
Normal file
55
client/test/format.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { longDate, shortDate, dateTime, monthTile, ago, categoryLabel } from '../src/lib/format.js'
|
||||
|
||||
// Unit-test the shared date/label formatters. Date strings are given with an
|
||||
// explicit local time (no trailing Z) so getMonth/getDate read the same value in
|
||||
// any timezone the test runs in — otherwise a date-only UTC string could shift a
|
||||
// day. The point is to lock the human-facing formats the whole site renders.
|
||||
|
||||
test('longDate renders "Month D, YYYY"', () => {
|
||||
assert.equal(longDate('2026-06-24T12:00:00'), 'June 24, 2026')
|
||||
})
|
||||
|
||||
test('shortDate renders "Mon D"', () => {
|
||||
assert.equal(shortDate('2026-06-24T12:00:00'), 'Jun 24')
|
||||
})
|
||||
|
||||
test('dateTime renders "Mon D HH:MM" with zero-padded time', () => {
|
||||
assert.equal(dateTime('2026-06-24T08:05:00'), 'Jun 24 08:05')
|
||||
})
|
||||
|
||||
test('monthTile returns the uppercased 3-letter month and 2-digit year', () => {
|
||||
assert.deepEqual(monthTile('2026-06-24T12:00:00'), { mon: 'JUN', num: "'26" })
|
||||
})
|
||||
|
||||
test('every formatter returns an empty/placeholder value for a missing or invalid date', () => {
|
||||
for (const bad of [null, undefined, '', 'not-a-date']) {
|
||||
assert.equal(longDate(bad), '')
|
||||
assert.equal(shortDate(bad), '')
|
||||
assert.equal(dateTime(bad), '')
|
||||
assert.equal(ago(bad), '')
|
||||
assert.deepEqual(monthTile(bad), { mon: '—', num: '' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── ago(): relative-time buckets ────────────────────────────────────────
|
||||
test('ago picks the right unit as the gap widens', () => {
|
||||
const now = Date.now()
|
||||
assert.equal(ago(new Date(now - 5 * 1000)), '5s ago')
|
||||
assert.equal(ago(new Date(now - 5 * 60 * 1000)), '5m ago')
|
||||
assert.equal(ago(new Date(now - 3 * 60 * 60 * 1000)), '3h ago')
|
||||
assert.equal(ago(new Date(now - 2 * 24 * 60 * 60 * 1000)), '2d ago')
|
||||
})
|
||||
|
||||
test('ago floors to at least 1s (never "0s ago" or a negative)', () => {
|
||||
assert.equal(ago(new Date(Date.now())), '1s ago')
|
||||
assert.equal(ago(new Date(Date.now() + 5000)), '1s ago') // a slightly-future timestamp
|
||||
})
|
||||
|
||||
// ── categoryLabel(): known map + passthrough ────────────────────────────
|
||||
test('categoryLabel maps known db categories and passes unknown ones through', () => {
|
||||
assert.equal(categoryLabel('five_on_friday'), 'Five on Friday')
|
||||
assert.equal(categoryLabel('newsletter'), 'Newsletter')
|
||||
assert.equal(categoryLabel('mystery_category'), 'mystery_category') // unknown → itself
|
||||
})
|
||||
81
client/test/heroLayout.test.js
Normal file
81
client/test/heroLayout.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
heroBgStack,
|
||||
buildOverlay,
|
||||
heroBackground,
|
||||
parseLayout,
|
||||
defaultLayout,
|
||||
DEFAULT_HERO_IMAGE,
|
||||
} from '../src/lib/heroLayout.js'
|
||||
|
||||
// Unit-test the hero-layout helpers shared by the public portal and the admin
|
||||
// editor. The high-value logic: parseLayout must reject anything malformed or of
|
||||
// the wrong version (a bad stored value must never render as a broken hero), and
|
||||
// heroBackground must take the untouched-default branch only when there is no
|
||||
// custom image.
|
||||
|
||||
// ── parseLayout: the version/shape guard ────────────────────────────────
|
||||
test('parseLayout accepts a well-formed v1 layout', () => {
|
||||
const layout = { version: 1, elements: [] }
|
||||
assert.deepEqual(parseLayout(JSON.stringify(layout)), layout)
|
||||
})
|
||||
|
||||
test('parseLayout returns null for missing, malformed, wrong-version, or wrong-shape input', () => {
|
||||
assert.equal(parseLayout(null), null)
|
||||
assert.equal(parseLayout(''), null)
|
||||
assert.equal(parseLayout('{ not json'), null)
|
||||
assert.equal(parseLayout(JSON.stringify({ version: 2, elements: [] })), null) // wrong version
|
||||
assert.equal(parseLayout(JSON.stringify({ version: 1, elements: 'nope' })), null) // elements not an array
|
||||
assert.equal(parseLayout(JSON.stringify({ version: 1 })), null) // no elements
|
||||
})
|
||||
|
||||
// ── heroBackground: default vs custom branch ────────────────────────────
|
||||
test('heroBackground uses the contained-emblem default stack only when default AND no custom image', () => {
|
||||
const style = heroBackground({ background: {} }, { isDefault: true })
|
||||
assert.match(style.backgroundImage, /runic-emblem\.png/)
|
||||
assert.equal(style.backgroundSize, 'cover, min(74vh, 640px, 86vw)') // the two-layer default size
|
||||
})
|
||||
|
||||
test('heroBackground threads a per-instance defaultImage into the default stack', () => {
|
||||
const style = heroBackground({ background: {} }, { isDefault: true, defaultImage: '/brand/hero.jpg' })
|
||||
assert.match(style.backgroundImage, /\/brand\/hero\.jpg/)
|
||||
})
|
||||
|
||||
test('heroBackground composes overlay + custom image once a custom image is set', () => {
|
||||
const style = heroBackground(
|
||||
{ background: { image_url: '/uploads/hero.png', position_x: 'right', position_y: 'top', size: 'contain' }, overlay: { opacity: 0.5 } },
|
||||
{ isDefault: true }, // still leaves the default branch because a custom image_url is present
|
||||
)
|
||||
assert.match(style.backgroundImage, /\/uploads\/hero\.png/)
|
||||
assert.match(style.backgroundImage, /rgba\(11,15,20,0\.5\)/) // overlay opacity threaded in
|
||||
assert.equal(style.backgroundPosition, 'right top')
|
||||
assert.equal(style.backgroundSize, 'contain')
|
||||
})
|
||||
|
||||
test('heroBackground defaults the overlay opacity to 0.72 when unset', () => {
|
||||
const style = heroBackground({ background: { image_url: '/x.png' } }, {})
|
||||
assert.match(style.backgroundImage, /rgba\(11,15,20,0\.72\)/)
|
||||
})
|
||||
|
||||
// ── smaller helpers ─────────────────────────────────────────────────────
|
||||
test('heroBgStack falls back to the default emblem when no image is given', () => {
|
||||
assert.match(heroBgStack(), new RegExp(DEFAULT_HERO_IMAGE.replace(/[/.]/g, '\\$&')))
|
||||
assert.match(heroBgStack('/custom.png'), /\/custom\.png/)
|
||||
})
|
||||
|
||||
test('buildOverlay scales both gradient stops from the opacity', () => {
|
||||
const overlay = buildOverlay(0.8)
|
||||
assert.match(overlay, /rgba\(11,15,20,0.12\)/) // 0.8 * 0.15 top stop
|
||||
assert.match(overlay, /rgba\(11,15,20,0.8\)/) // bottom stop
|
||||
})
|
||||
|
||||
// ── defaultLayout: the fallback hero ────────────────────────────────────
|
||||
test('defaultLayout builds a valid v1 layout that threads the teaser and shard name', () => {
|
||||
const layout = defaultLayout('Come play with us', 'My Shard')
|
||||
assert.equal(parseLayout(JSON.stringify(layout)) !== null, true, 'the default is itself a valid layout')
|
||||
assert.equal(layout.version, 1)
|
||||
const textLines = layout.elements.find((e) => e.id === 'default-text').props.lines
|
||||
assert.ok(textLines.some((l) => l.text === 'My Shard'), 'shard name rendered as the h1')
|
||||
assert.ok(textLines.some((l) => l.text === 'Come play with us'), 'teaser threaded in')
|
||||
})
|
||||
60
client/test/regionBuckets.test.js
Normal file
60
client/test/regionBuckets.test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
|
||||
|
||||
// Unit-test the presence.online region roll-up for the "Players Online" widget.
|
||||
// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
|
||||
// total — anything unmatched lands in Wilderness — so the widget can never show
|
||||
// a sum that disagrees with the headline online count.
|
||||
|
||||
test('bucketize groups named regions into their buckets', () => {
|
||||
const { rows, total } = bucketize({
|
||||
'Britain': 4,
|
||||
'Moonglow': 2,
|
||||
'Despise': 3,
|
||||
'Green Acres House 12': 1, // not a town/dungeon name → Housing
|
||||
})
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||
assert.equal(byId.britain, 4)
|
||||
assert.equal(byId.towns, 2)
|
||||
assert.equal(byId.dungeons, 3)
|
||||
assert.equal(byId.housing, 1)
|
||||
assert.equal(total, 10)
|
||||
})
|
||||
|
||||
test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
|
||||
// The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
|
||||
// region whose name starts with a town name is bucketed as Towns. Pinning this
|
||||
// documents the ordering dependency for anyone retuning BUCKETS.
|
||||
const { rows } = bucketize({ 'Trinsic House 12': 1 })
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||
assert.equal(byId.towns, 1)
|
||||
assert.equal(byId.housing, undefined) // empty bucket dropped
|
||||
})
|
||||
|
||||
test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
|
||||
const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
|
||||
const wilderness = rows.find((r) => r.id === 'wilderness')
|
||||
assert.equal(wilderness.count, 7)
|
||||
assert.equal(total, 7)
|
||||
// The reconciliation guarantee: the buckets sum to the total, exactly.
|
||||
assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
|
||||
})
|
||||
|
||||
test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
|
||||
const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
|
||||
assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
|
||||
})
|
||||
|
||||
test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
|
||||
assert.deepEqual(bucketize({}), { rows: [], total: 0 })
|
||||
assert.deepEqual(bucketize(), { rows: [], total: 0 })
|
||||
const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
|
||||
assert.equal(total, 3) // '3' → 3, 'oops' → 0
|
||||
})
|
||||
|
||||
test('the last bucket is the catch-all (its match accepts anything)', () => {
|
||||
const last = BUCKETS[BUCKETS.length - 1]
|
||||
assert.equal(last.id, 'wilderness')
|
||||
assert.equal(last.match('literally anything'), true)
|
||||
})
|
||||
74
client/test/shardEvents.test.js
Normal file
74
client/test/shardEvents.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
|
||||
|
||||
// Unit-test the shared shard-event formatter — the single place that decides how
|
||||
// each event kind reads and which filter category it belongs to. These strings
|
||||
// are user-facing on the public Shard page, the Activity feed, and the admin
|
||||
// live feed, so a regression here is visible everywhere at once.
|
||||
|
||||
// ── describe(): works on both stored (.payload) and live (top-level) frames ──
|
||||
test('describe reads fields from .payload when present, else the top level', () => {
|
||||
const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
|
||||
const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
|
||||
assert.equal(describe(stored), 'Ada completed “The Cavern”')
|
||||
assert.equal(describe(live), 'Ada completed “The Cavern”')
|
||||
})
|
||||
|
||||
test('describe resolves an actor from name → acct → "Someone"', () => {
|
||||
assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
|
||||
assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
|
||||
assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
|
||||
assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
|
||||
})
|
||||
|
||||
test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
|
||||
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
|
||||
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
|
||||
})
|
||||
|
||||
test('describe includes the killer only when present (optional clause)', () => {
|
||||
assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
|
||||
assert.equal(
|
||||
describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
|
||||
'Ada was slain by Orc',
|
||||
)
|
||||
})
|
||||
|
||||
test('describe champ.update branches on status and boss state', () => {
|
||||
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
|
||||
assert.equal(
|
||||
describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
|
||||
'Rikktor is active — level 3',
|
||||
)
|
||||
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
|
||||
})
|
||||
|
||||
test('describe falls back to the raw kind for an unknown event', () => {
|
||||
assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
|
||||
})
|
||||
|
||||
// ── categoryOf(): membership + catch-all ────────────────────────────────
|
||||
test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
|
||||
assert.equal(categoryOf('player.death'), 'pvp')
|
||||
assert.equal(categoryOf('skill.gain'), 'progress')
|
||||
assert.equal(categoryOf('house.decay'), 'world')
|
||||
assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
|
||||
assert.equal(categoryOf('totally.unknown'), 'other')
|
||||
})
|
||||
|
||||
test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
|
||||
for (const cat of CATEGORIES) {
|
||||
if (!cat.kinds) continue
|
||||
for (const kind of cat.kinds) {
|
||||
assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── kindLabel(): badge text ─────────────────────────────────────────────
|
||||
test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
|
||||
assert.equal(kindLabel('player.death'), 'player death')
|
||||
assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
|
||||
assert.equal(kindLabel(null), '')
|
||||
})
|
||||
Reference in New Issue
Block a user