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:
@@ -52,6 +52,9 @@ jobs:
|
|||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: client/package-lock.json
|
||||||
- name: Install client deps
|
- name: Install client deps
|
||||||
run: npm ci --prefix client
|
run: npm ci --prefix client
|
||||||
|
- name: Run client tests
|
||||||
|
# Pure-logic unit tests on Node's built-in runner (no browser/DOM).
|
||||||
|
run: npm test --prefix client
|
||||||
- name: Build client
|
- name: Build client
|
||||||
run: npm run build --prefix client
|
run: npm run build --prefix client
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ jobs:
|
|||||||
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
||||||
server/test/*.test.js
|
server/test/*.test.js
|
||||||
|
|
||||||
|
- name: Generate client test coverage (LCOV)
|
||||||
|
# The client's pure-logic modules (lib/, api/, data/) are plain ESM with no
|
||||||
|
# browser/DOM deps, so they run on the same built-in runner. Run from the
|
||||||
|
# repo root so the `SF:` paths come out as `client/src/...`. No `npm ci`:
|
||||||
|
# the tested modules import only relative files + Node built-ins.
|
||||||
|
run: |
|
||||||
|
mkdir -p client/coverage
|
||||||
|
node --test --experimental-test-coverage \
|
||||||
|
--test-reporter=spec --test-reporter-destination=stdout \
|
||||||
|
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
|
||||||
|
client/test/*.test.js
|
||||||
|
|
||||||
- name: Run SonarQube scan
|
- name: Run SonarQube scan
|
||||||
uses: sonarsource/sonarqube-scan-action@v4
|
uses: sonarsource/sonarqube-scan-action@v4
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "^2.27.2",
|
"@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), '')
|
||||||
|
})
|
||||||
@@ -10,18 +10,19 @@ sonar.projectName=runic gateway website
|
|||||||
sonar.sources=server/src,client/src,bot/src
|
sonar.sources=server/src,client/src,bot/src
|
||||||
|
|
||||||
# Test code is analysed separately from sources so coverage/metrics attribute
|
# Test code is analysed separately from sources so coverage/metrics attribute
|
||||||
# correctly. Only the server has a test suite today.
|
# correctly. The server suite and the client's pure-logic unit tests both run on
|
||||||
sonar.tests=server/test
|
# Node's built-in test runner (no browser/DOM), so they live side by side here.
|
||||||
sonar.test.inclusions=server/test/**/*.test.js
|
sonar.tests=server/test,client/test
|
||||||
|
sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js
|
||||||
|
|
||||||
# Coverage. The sonarqube.yml workflow runs the server suite with Node's built-in
|
# Coverage. The sonarqube.yml workflow runs both suites with Node's built-in
|
||||||
# test-coverage and writes an LCOV report here BEFORE the scan runs; without it
|
# test-coverage and writes an LCOV report for each BEFORE the scan runs; without
|
||||||
# the dashboard shows 0% (the scanner never executes tests itself). The SF: paths
|
# them the dashboard shows 0% (the scanner never executes tests itself). The SF:
|
||||||
# in the report are repo-root-relative (server/src/...) so the scanner resolves
|
# paths in each report are repo-root-relative (server/src/... , client/src/...) so
|
||||||
# them against the project base dir.
|
# the scanner resolves them against the project base dir.
|
||||||
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info
|
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info
|
||||||
|
|
||||||
# Never analyse dependencies, build output, generated specs, or runtime dirs.
|
# Never analyse dependencies, build output, generated specs, or runtime dirs.
|
||||||
sonar.exclusions=**/node_modules/**,client/dist/**,client/public/**,server/swagger/**,server/logs/**,server/uploads/**,server/coverage/**,**/*.min.js
|
sonar.exclusions=**/node_modules/**,client/dist/**,client/public/**,server/swagger/**,server/logs/**,server/uploads/**,server/coverage/**,client/coverage/**,**/*.min.js
|
||||||
|
|
||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|||||||
Reference in New Issue
Block a user