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>
56 lines
2.5 KiB
JavaScript
56 lines
2.5 KiB
JavaScript
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
|
|
})
|