Files
website/client/test/heroLayout.test.js
wtclaude 886a504152
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 9m36s
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>
2026-07-21 00:38:53 -05:00

82 lines
4.3 KiB
JavaScript

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')
})