refactor(leaderboard-element): use web-component-base APIs

This commit is contained in:
ayo 2026-07-04 14:39:15 +02:00
parent c2ffb0d7f6
commit 26277986a0
4 changed files with 653 additions and 70 deletions

View file

@ -103,7 +103,8 @@ mounted elements.
| `format` | `time` \| `number` \| `plain` | score display preset (below) | | `format` | `time` \| `number` \| `plain` | score display preset (below) |
Attributes are reactive: change `category`/`title` at runtime and the board Attributes are reactive: change `category`/`title` at runtime and the board
re-renders, keeping the selected duration tab. re-renders, keeping the selected duration tab. Changing `score-order`/`format`
rebuilds the element's service so the new order/preset takes effect.
### `format` presets ### `format` presets
@ -230,5 +231,6 @@ The package computes the `day`/`week`/`month` bucket keys from `time_stamp`
The service is cached per element, so set overrides **before** the element The service is cached per element, so set overrides **before** the element
connects (or set the property and clear the element's `_svc` to force a connects (or set the property and clear the element's `_svc` to force a
rebuild). `score-order` and `format` are read from attributes; the function/ rebuild — changing the `score-order`/`format` attributes does this
array/string overrides are read from properties. automatically). `score-order` and `format` are read from attributes; the
function/array/string overrides are read from properties.

View file

@ -1,15 +1,23 @@
import { WebComponent } from 'web-component-base' import { WebComponent, html } from 'web-component-base'
import { LeaderBoardService } from './leader-board.js' import { LeaderBoardService } from './leader-board.js'
import { DURATIONS } from './leaderboard-read.js'
/** /**
* `<cozy-leaderboard>` a custom element that lets a developer compose the * `<cozy-leaderboard>` a custom element that lets a developer compose the
* leaderboard UI declaratively in HTML instead of wiring it in JavaScript. * leaderboard UI declaratively in HTML instead of wiring it in JavaScript.
* *
* Built on `web-component-base` (WebComponent base class + lifecycle hooks). It * Built on `web-component-base` (WCB) the idiomatic way: the view is a pure
* extends WCB for the custom-element scaffolding (onInit/onDestroy, connect * `html` template over a precomputed view-state object, WCB's own
* handling) and delegates all inner DOM the duration tabs and the ranked * `attributeChangedCallback` feeds observed attributes into `this.props` and
* list to the existing LeaderBoardService, which already builds and manages * the `onChanges` hook, and updates go through the base class's `render()`.
* that DOM (including in-place tab swaps). * Data access and user-facing strings stay in {@link LeaderBoardService} /
* LeaderBoardReader the element only turns query results into templates.
*
* `static props` is deliberately NOT used: WCB 4.1.2 writes each prop's
* default onto the element as an attribute inside the constructor, which the
* custom-elements spec forbids during synchronous construction it breaks
* `document.createElement('cozy-leaderboard')` entirely. Plain
* `observedAttributes` + `onChanges` gives the same reactivity without that.
* *
* Composition lives in HTML attributes; the storage backend (adapter) is set * Composition lives in HTML attributes; the storage backend (adapter) is set
* once in JS via configureLeaderboard(), because env-var config can't live in * once in JS via configureLeaderboard(), because env-var config can't live in
@ -53,49 +61,134 @@ const prettyTime = ms => {
const FORMATTERS = { time: prettyTime } const FORMATTERS = { time: prettyTime }
const resolveFormat = name => FORMATTERS[name] const resolveFormat = name => FORMATTERS[name]
// WCB coerces an empty attribute value to a non-string (`'' -> true`), so
// observed values are read through this guard before use.
const str = value => (typeof value === 'string' ? value : '')
// Inline styles (as WCB `html` style objects) — the same visual output the
// LeaderBoardReader produces for the imperative JS composition path.
const STYLES = {
wrapper: { maxWidth: '270px', margin: '0 auto' },
heading: { borderBottom: '1px solid #c0c0c0', paddingBottom: '10px' },
tabBar: { display: 'flex', justifyContent: 'center', gap: '8px', marginBottom: '10px' },
list: { listStyle: 'none', textAlign: 'left' },
row: { display: 'flex' },
name: {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
padding: '0 5px',
fontWeight: 'bold',
fontStyle: 'italic',
flex: '1'
}
}
const tabStyle = active => ({
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
fontSize: '0.85em',
color: active ? '#ffffff' : '#999999',
fontWeight: active ? 'bold' : 'normal',
borderBottom: active ? '2px solid orange' : '2px solid transparent'
})
export class CozyLeaderboard extends WebComponent { export class CozyLeaderboard extends WebComponent {
static get observedAttributes() { static get observedAttributes() {
return ['category', 'title', 'duration', 'score-order', 'format'] return ['category', 'title', 'duration', 'score-order', 'format']
} }
// WCB lifecycle: register/unregister so configureLeaderboard() can re-render. // View state the template renders from. Either { board: false } (not
// configured) or { board: true, tabs, active, list } where list is
// { rows: [{ index, name, score }] } or { message } (loading/empty/error).
_view = { board: false }
// Selected duration window; survives category changes and re-connects.
// null until the board first mounts, so the `duration` attribute is honored.
_activeDuration = null
_connected = false
_token = 0
// WCB lifecycle: connect mounts the board, disconnect unregisters. The
// instances set lets configureLeaderboard() re-mount live elements.
onInit() { onInit() {
this._connected = true
instances.add(this) instances.add(this)
}
onDestroy() {
instances.delete(this)
}
// Attributes are read directly (getAttribute) rather than through WCB's typed
// props proxy, so optional/empty values never trip its type enforcement.
attributeChangedCallback(name, previousValue, currentValue) {
if (previousValue === currentValue || !this.isConnected) return
this._mount(name === 'duration' ? (currentValue || undefined) : undefined)
}
// WCB calls render() on connect; we treat it as "(re)mount the board".
render() {
this._mount() this._mount()
} }
onDestroy() {
this._connected = false
instances.delete(this)
}
/**
* WCB change hook `property` is the attribute (kebab-case) name. A title
* change needs no re-query: the heading reads `this.props.title`, so WCB's
* own render already updated it. score-order/format changes rebuild the
* service so the new config actually takes effect.
*/
onChanges({ property, currentValue }) {
if (!this._connected || property === 'title') return
if (property === 'score-order' || property === 'format') this._svc = null
this._mount(property === 'duration' ? (str(currentValue) || undefined) : undefined)
}
get template() {
const view = this._view
if (!view.board) return html`<em>Leaderboard not configured.</em>`
return html`
<div style=${STYLES.wrapper}>
<h3 style=${STYLES.heading}>${str(this.props.title)}</h3>
<div style=${STYLES.tabBar}>
${view.tabs.map(tab => html`
<button
type="button"
title=${tab.tooltip}
data-duration=${tab.id}
style=${tabStyle(tab.id === view.active)}
onclick=${() => this._selectTab(tab.id)}
>${tab.label}</button>
`)}
</div>
<div>
${view.list.rows
? html`
<div style=${STYLES.list}>
${view.list.rows.map(row => html`
<div style=${STYLES.row}>
<div>#${row.index}</div>
<div title=${row.name} style=${STYLES.name}>${row.name}</div>
<div>${row.score}</div>
</div>
`)}
</div>`
: html`<em>${view.list.message}</em>`}
</div>
</div>
`
}
// Per-element override properties (public): adapter, formatScore, qualifies, // Per-element override properties (public): adapter, formatScore, qualifies,
// labels, emptyMessages, loadingText, errorText, anonymousName. Each falls back // labels, tooltips, emptyMessages, loadingText, errorText, anonymousName.
// to the shared configureLeaderboard() value, then the package default. Set // Each falls back to the shared configureLeaderboard() value, then the
// them before the element connects (or clear `_svc` to force a rebuild). // package default. Set them before the element connects (or clear `_svc` to
// force a rebuild). Rich values stay plain properties — WCB props are
// attribute-backed and only carry serializable primitives.
_service() { _service() {
if (this._svc) return this._svc if (this._svc) return this._svc
const adapter = this.adapter || sharedConfig.adapter const adapter = this.adapter || sharedConfig.adapter
if (!adapter) return null if (!adapter) return null
const formatScore = this.formatScore const formatScore = this.formatScore
|| resolveFormat(this.getAttribute('format')) || resolveFormat(str(this.props.format))
|| sharedConfig.formatScore || sharedConfig.formatScore
|| resolveFormat(sharedConfig.format) || resolveFormat(sharedConfig.format)
|| String || String
this._svc = new LeaderBoardService({ this._svc = new LeaderBoardService({
adapter, adapter,
scoreOrder: this.getAttribute('score-order') || sharedConfig.scoreOrder || 'asc', scoreOrder: str(this.props.scoreOrder) || sharedConfig.scoreOrder || 'asc',
formatScore, formatScore,
qualifies: this.qualifies || sharedConfig.qualifies, qualifies: this.qualifies || sharedConfig.qualifies,
// User-facing strings — pass through so apps localize without touching the package. // User-facing strings — pass through so apps localize without touching the package.
@ -110,36 +203,76 @@ export class CozyLeaderboard extends WebComponent {
} }
/** /**
* (Re)render the board. The first successful mount honors the author's * (Re)mount the board. The first mount honors the author's `duration`
* `duration` attribute; later mounts preserve the service's remembered * attribute; later mounts keep the selected duration (so switching category
* duration (so switching category keeps the selected tab) unless a duration * keeps the selected tab) unless a duration is passed explicitly.
* is passed explicitly.
*/ */
_mount(durationArg) { _mount(durationArg) {
if (!this.isConnected) return if (!this._connected) return
const service = this._service() const service = this._service()
if (!service) { if (!service) {
this.replaceChildren(this._message('Leaderboard not configured.')) this._view = { board: false }
this._paint()
return return
} }
const duration = durationArg
?? this._activeDuration
?? (str(this.props.duration) || 'today')
this._activeDuration = duration
this._load(service, duration)
}
let duration = durationArg _selectTab(id) {
if (duration === undefined && !this._mounted) { this._activeDuration = id
duration = this.getAttribute('duration') || undefined this._load(this._service(), id)
}
/**
* Query one duration window and project the result into view state: a
* loading message immediately, then rows / a random empty message / the
* error text. The token guards against a stale response (quick tab or
* category switches) overwriting a newer one.
*/
async _load(service, durationId) {
const reader = service.reader
const tabs = DURATIONS.map(d => ({ id: d.id, label: reader.label(d), tooltip: reader.tooltip(d) }))
const board = list => ({ board: true, tabs, active: durationId, list })
const token = ++this._token
this._view = board({ message: reader.loadingText })
this._paint()
let list
try {
const rows = await reader.list(str(this.props.category), durationId)
list = (rows && rows.length)
? {
rows: rows.map((row, index) => ({
index: index + 1,
name: row.name || reader.anonymousName,
score: reader.formatScore(row.score)
}))
}
: { message: reader.emptyMessage() }
} catch {
list = { message: reader.errorText }
} }
if (token !== this._token) return
this._view = board(list)
this._paint()
}
const token = (this._token || 0) + 1 /**
this._token = token * Render the current view state through WCB. WCB's render() replaces the
service.render(this.getAttribute('category') || '', this.getAttribute('title') || '', duration) * whole subtree (no diffing yet), which would drop focus from a clicked
.then(el => { * duration tab the one behavior the base class can't preserve for us so
if (this._token !== token) return * focus is handed to the replacement tab explicitly.
this.replaceChildren(el) */
this._mounted = true _paint() {
}) const focused = document.activeElement
.catch(() => { const focusedTab = focused && this.contains(focused) ? focused.dataset.duration : undefined
if (this._token !== token) return this.render()
this.replaceChildren(this._message('Leaderboard unavailable right now.')) if (focusedTab) this.querySelector(`button[data-duration="${focusedTab}"]`)?.focus()
})
} }
/** /**
@ -151,12 +284,6 @@ export class CozyLeaderboard extends WebComponent {
const service = this._service() const service = this._service()
if (service) return service.submit(entry) if (service) return service.submit(entry)
} }
_message(text) {
const em = document.createElement('em')
em.innerText = text
return em
}
} }
if (!customElements.get('cozy-leaderboard')) { if (!customElements.get('cozy-leaderboard')) {

View file

@ -14,9 +14,10 @@ const DAY_MS = 24 * 60 * 60 * 1000
* The four time windows are ROLLING: each shows entries from the last `ms` * The four time windows are ROLLING: each shows entries from the last `ms`
* milliseconds (strictly nested 24h 7d 30d all), sorted by score. * milliseconds (strictly nested 24h 7d 30d all), sorted by score.
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip * `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
* that spells out the window. * that spells out the window. Exported so view layers (e.g. the
* `<cozy-leaderboard>` element) can render the tab bar themselves.
*/ */
const DURATIONS = [ export const DURATIONS = [
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' }, { id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' }, { id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' },
{ id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' }, { id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
@ -75,18 +76,40 @@ export class LeaderBoardReader {
this.anonymousName = options.anonymousName || 'Anonymous' this.anonymousName = options.anonymousName || 'Anonymous'
} }
_label(duration) { /**
* Display label for a duration window (override-aware).
* @param {{ id: String, label: String }} duration - a DURATIONS entry
*/
label(duration) {
return this.labels[duration.id] || duration.label return this.labels[duration.id] || duration.label
} }
_tooltip(duration) { /**
* Hover tooltip for a duration window (override-aware).
* @param {{ id: String, title: String }} duration - a DURATIONS entry
*/
tooltip(duration) {
return this.tooltips[duration.id] || duration.title return this.tooltips[duration.id] || duration.title
} }
_emptyMessage() { /** One empty-state message, picked at random. */
emptyMessage() {
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)] return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)]
} }
/**
* Data-level query: the ranked entries for a category and duration window,
* without any DOM. View layers that render themselves (e.g. the
* `<cozy-leaderboard>` element) use this instead of {@link render}.
* @param {String} category
* @param {String} durationId - a DURATIONS id ('today' | 'week' | 'month' | 'all')
* @returns {Promise<Object[]>}
*/
async list(category, durationId) {
const duration = DURATIONS.find(d => d.id === durationId)
return this.adapter.listScores(this._descriptor(category, duration))
}
/** /**
* Backend-neutral query descriptor for a category and time window. `since` is * Backend-neutral query descriptor for a category and time window. `since` is
* the rolling cutoff (entries with `time_stamp >= since`); `null` means * the rolling cutoff (entries with `time_stamp >= since`); `null` means
@ -123,7 +146,7 @@ export class LeaderBoardReader {
wrapper.style.margin = '0 auto' wrapper.style.margin = '0 auto'
const heading = document.createElement('h3') const heading = document.createElement('h3')
heading.innerText = title heading.textContent = title
heading.style.borderBottom = '1px solid #c0c0c0' heading.style.borderBottom = '1px solid #c0c0c0'
heading.style.paddingBottom = '10px' heading.style.paddingBottom = '10px'
wrapper.append(heading) wrapper.append(heading)
@ -147,9 +170,9 @@ export class LeaderBoardReader {
DURATIONS.forEach(d => { DURATIONS.forEach(d => {
const tab = document.createElement('button') const tab = document.createElement('button')
tab.innerText = this._label(d) tab.textContent = this.label(d)
tab.type = 'button' tab.type = 'button'
tab.setAttribute('title', this._tooltip(d)) tab.setAttribute('title', this.tooltip(d))
this._styleTab(tab, d.id === duration) this._styleTab(tab, d.id === duration)
tab.onclick = () => activate(d.id) tab.onclick = () => activate(d.id)
tabs[d.id] = tab tabs[d.id] = tab
@ -186,7 +209,7 @@ export class LeaderBoardReader {
listWrapper.innerHTML = '' listWrapper.innerHTML = ''
const loading = document.createElement('em') const loading = document.createElement('em')
loading.innerText = this.loadingText loading.textContent = this.loadingText
listWrapper.append(loading) listWrapper.append(loading)
try { try {
@ -197,7 +220,7 @@ export class LeaderBoardReader {
if (this._loadToken !== token) return if (this._loadToken !== token) return
listWrapper.innerHTML = '' listWrapper.innerHTML = ''
const message = document.createElement('em') const message = document.createElement('em')
message.innerText = this.errorText message.textContent = this.errorText
listWrapper.append(message) listWrapper.append(message)
} }
} }
@ -207,7 +230,7 @@ export class LeaderBoardReader {
if (!rows || !rows.length) { if (!rows || !rows.length) {
const message = document.createElement('em') const message = document.createElement('em')
message.innerText = this._emptyMessage() message.textContent = this.emptyMessage()
listWrapper.append(message) listWrapper.append(message)
return return
} }
@ -222,7 +245,7 @@ export class LeaderBoardReader {
item.style.display = 'flex' item.style.display = 'flex'
const indexElement = document.createElement('div') const indexElement = document.createElement('div')
indexElement.innerText = `#${i++}` indexElement.textContent = `#${i++}`
const nameElement = document.createElement('div') const nameElement = document.createElement('div')
const name = data.name || this.anonymousName const name = data.name || this.anonymousName
@ -237,7 +260,7 @@ export class LeaderBoardReader {
nameElement.style.flex = '1' nameElement.style.flex = '1'
const scoreElement = document.createElement('div') const scoreElement = document.createElement('div')
scoreElement.innerText = this.formatScore(data.score) scoreElement.textContent = this.formatScore(data.score)
item.append(indexElement, nameElement, scoreElement) item.append(indexElement, nameElement, scoreElement)
list.append(item) list.append(item)

View file

@ -0,0 +1,431 @@
// @ts-check
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { configureLeaderboard } from '../leaderboard-element.js'
/**
* Characterization tests for <cozy-leaderboard>: every externally observable
* behavior of the element, written against the element's public surface only
* (attributes, properties, produced DOM, adapter calls) so the implementation
* can be refactored while this suite stays green.
*
* Two behaviors are deliberately NOT pinned here:
* - entry names render via innerHTML today (an XSS hazard); only the text is
* asserted, so a safe text rendering also passes.
* - score-order / format attribute CHANGES after the first mount are silently
* ignored today (the per-element service caches its config); honoring them
* is an allowed improvement.
*/
const DEFAULT_EMPTY_MESSAGES = [
'Be the first to enter the leader board!',
'No scores yet — claim the top spot!',
'This board is wide open. Conquer it!',
'No champions here yet. Will it be you?',
'Blank slate — set the score to beat!',
'Nobody\'s here yet. Be the first!',
'The top spot is up for grabs. Take it!',
'Empty board. Time to make your mark!'
]
const makeAdapter = (rows = [], overrides = {}) => ({
listScores: vi.fn(async () => rows),
addScore: vi.fn(async () => {}),
archive: vi.fn(async () => {}),
getConfig: vi.fn(async () => undefined),
...overrides
})
// Reset every shared-config key; `undefined` falls through all fallback chains.
const resetSharedConfig = () => configureLeaderboard({
adapter: undefined,
scoreOrder: undefined,
format: undefined,
formatScore: undefined,
qualifies: undefined,
labels: undefined,
tooltips: undefined,
emptyMessages: undefined,
loadingText: undefined,
errorText: undefined,
anonymousName: undefined
})
/**
* Create a disconnected element, apply attributes and per-element override
* properties (they must be set before connect), then connect it the
* documented composition flow.
*/
const mount = (attrs = {}, props = {}) => {
const el = /** @type {any} */ (document.createElement('cozy-leaderboard'))
Object.entries(attrs).forEach(([name, value]) => el.setAttribute(name, value))
Object.assign(el, props)
document.body.append(el)
return el
}
const tabButtons = el => [...el.querySelectorAll('button')]
const tabByLabel = (el, label) => tabButtons(el).find(b => b.textContent === label)
const rowsText = el => el.textContent
beforeEach(() => resetSharedConfig())
afterEach(() => { document.body.innerHTML = '' })
describe('unconfigured state', () => {
it('renders the not-configured message when no adapter exists anywhere', async () => {
const el = mount({ category: 'beginner', title: 'Best Times' })
await vi.waitFor(() => {
const em = el.querySelector('em')
expect(em).toBeTruthy()
expect(em.textContent).toBe('Leaderboard not configured.')
})
})
it('mounts the board when configureLeaderboard() supplies an adapter later', async () => {
const el = mount({ category: 'beginner', title: 'Best Times' })
await vi.waitFor(() => expect(el.textContent).toContain('Leaderboard not configured.'))
const adapter = makeAdapter([{ name: 'Ada', score: 3 }])
configureLeaderboard({ adapter })
await vi.waitFor(() => {
expect(el.querySelector('h3')).toBeTruthy()
expect(rowsText(el)).toContain('Ada')
})
})
})
describe('board structure', () => {
it('renders heading, four duration tabs with tooltips, and ranked rows', async () => {
const adapter = makeAdapter([
{ name: 'Ada', score: 3 },
{ name: 'Bo', score: 5 }
])
const el = mount({ category: 'beginner', title: 'Best Times' }, { adapter })
await vi.waitFor(() => expect(rowsText(el)).toContain('Ada'))
const heading = el.querySelector('h3')
expect(heading.textContent).toBe('Best Times')
const tabs = tabButtons(el)
expect(tabs.map(t => t.textContent)).toEqual(['Today', 'Week', 'Month', 'All Time'])
expect(tabs.map(t => t.getAttribute('title'))).toEqual([
'Last 24 hours', 'Last 7 days', 'Last 30 days', 'All time'
])
tabs.forEach(t => expect(t.type).toBe('button'))
// Ranked rows: index, name (with hover title), formatted score.
expect(rowsText(el)).toContain('#1')
expect(rowsText(el)).toContain('#2')
expect(rowsText(el)).toContain('3')
expect(rowsText(el)).toContain('5')
const nameCells = [...el.querySelectorAll('[title="Ada"], [title="Bo"]')]
expect(nameCells).toHaveLength(2)
})
it('shows the loading text while the query is in flight', async () => {
let resolve
const adapter = makeAdapter([], {
listScores: vi.fn(() => new Promise(r => { resolve = r }))
})
const el = mount({ category: 'beginner', title: 'Best Times' }, { adapter })
await vi.waitFor(() => expect(rowsText(el)).toContain('Loading…'))
expect(el.querySelector('h3')).toBeTruthy() // heading + tabs render before data
resolve([{ name: 'Ada', score: 3 }])
await vi.waitFor(() => expect(rowsText(el)).toContain('Ada'))
expect(rowsText(el)).not.toContain('Loading…')
})
it('marks the active tab and leaves the others inactive', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best Times' }, { adapter })
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
const today = tabByLabel(el, 'Today')
const week = tabByLabel(el, 'Week')
expect(today.style.fontWeight).toBe('bold')
expect(today.style.borderBottom).toContain('orange')
expect(week.style.fontWeight).toBe('normal')
expect(week.style.borderBottom).toContain('transparent')
})
})
describe('queries', () => {
it('queries the \'today\' rolling window by default, limited to 10', async () => {
const adapter = makeAdapter([])
mount({ category: 'beginner', title: 'Best Times' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
const q = adapter.listScores.mock.calls[0][0]
expect(q.category).toBe('beginner')
expect(q.order).toBe('asc')
expect(q.limit).toBe(10)
expect(q.since).toBeInstanceOf(Date)
const dayMs = 24 * 60 * 60 * 1000
expect(Math.abs(Date.now() - dayMs - q.since.getTime())).toBeLessThan(5000)
})
it('honors the duration attribute on first mount (all → no time filter)', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best', duration: 'all' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
expect(adapter.listScores.mock.calls[0][0].since).toBeNull()
await vi.waitFor(() =>
expect(tabByLabel(el, 'All Time').style.fontWeight).toBe('bold'))
})
it('passes score-order=\'desc\' through to the query', async () => {
const adapter = makeAdapter([])
mount({ category: 'beginner', title: 'Best', 'score-order': 'desc' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
expect(adapter.listScores.mock.calls[0][0].order).toBe('desc')
})
it('re-queries the clicked tab window in place', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
tabByLabel(el, 'Week').click()
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
const q = adapter.listScores.mock.calls[1][0]
const weekMs = 7 * 24 * 60 * 60 * 1000
expect(Math.abs(Date.now() - weekMs - q.since.getTime())).toBeLessThan(5000)
await vi.waitFor(() =>
expect(tabByLabel(el, 'Week').style.fontWeight).toBe('bold'))
expect(tabByLabel(el, 'Today').style.fontWeight).toBe('normal')
})
it('keeps focus on the clicked tab', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
const week = tabByLabel(el, 'Week')
week.focus()
week.click()
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
expect(document.activeElement?.textContent).toBe('Week')
})
})
describe('attribute changes after mount', () => {
it('category change re-queries with the new category and keeps the selected tab', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
tabByLabel(el, 'Week').click()
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
el.setAttribute('category', 'expert')
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(3))
const q = adapter.listScores.mock.calls[2][0]
expect(q.category).toBe('expert')
const weekMs = 7 * 24 * 60 * 60 * 1000
expect(Math.abs(Date.now() - weekMs - q.since.getTime())).toBeLessThan(5000)
await vi.waitFor(() =>
expect(tabByLabel(el, 'Week').style.fontWeight).toBe('bold'))
})
it('title change updates the heading', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(el.querySelector('h3')).toBeTruthy())
el.setAttribute('title', 'Best Times (Expert)')
await vi.waitFor(() =>
expect(el.querySelector('h3').textContent).toBe('Best Times (Expert)'))
})
it('duration change switches the window', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
el.setAttribute('duration', 'month')
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
const q = adapter.listScores.mock.calls[1][0]
const monthMs = 30 * 24 * 60 * 60 * 1000
expect(Math.abs(Date.now() - monthMs - q.since.getTime())).toBeLessThan(5000)
await vi.waitFor(() =>
expect(tabByLabel(el, 'Month').style.fontWeight).toBe('bold'))
})
it('a stale query result never overwrites a newer one', async () => {
const deferred = {}
const adapter = makeAdapter([], {
listScores: vi.fn(({ category }) => new Promise(r => { deferred[category] = r }))
})
const el = mount({ category: 'aaa', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
el.setAttribute('category', 'bbb')
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
deferred.bbb([{ name: 'NewRow', score: 2 }])
await vi.waitFor(() => expect(rowsText(el)).toContain('NewRow'))
deferred.aaa([{ name: 'StaleRow', score: 1 }]) // stale response arrives late
await new Promise(r => setTimeout(r, 20))
expect(rowsText(el)).not.toContain('StaleRow')
expect(rowsText(el)).toContain('NewRow')
})
})
describe('empty and error states', () => {
it('shows one of the empty-state messages when there are no rows', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => {
const messages = [...el.querySelectorAll('em')].map(em => em.textContent)
expect(messages.some(m => DEFAULT_EMPTY_MESSAGES.includes(m))).toBe(true)
})
})
it('turns a failing query into the error message', async () => {
const adapter = makeAdapter([], {
listScores: vi.fn(async () => { throw new Error('backend down') })
})
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() =>
expect(rowsText(el)).toContain('Leaderboard unavailable right now.'))
expect(el.querySelector('h3')).toBeTruthy() // heading + tabs survive the failure
})
})
describe('formatting and user-facing strings', () => {
it('format=\'time\' renders scores with the pretty-time formatter', async () => {
const adapter = makeAdapter([
{ name: 'Ada', score: 4200 },
{ name: 'Bo', score: 61300 }
])
const el = mount({ category: 'b', title: 'T', format: 'time' }, { adapter })
await vi.waitFor(() => {
expect(rowsText(el)).toContain('04.2')
expect(rowsText(el)).toContain('01:01.3')
})
})
it('a per-element formatScore property wins over the format attribute', async () => {
const adapter = makeAdapter([{ name: 'Ada', score: 4200 }])
const el = mount(
{ category: 'b', title: 'T', format: 'time' },
{ adapter, formatScore: v => `${v}pts` }
)
await vi.waitFor(() => expect(rowsText(el)).toContain('4200pts'))
})
it('falls back to shared-config format from configureLeaderboard()', async () => {
const adapter = makeAdapter([{ name: 'Ada', score: 4200 }])
configureLeaderboard({ adapter, format: 'time' })
const el = mount({ category: 'b', title: 'T' })
await vi.waitFor(() => expect(rowsText(el)).toContain('04.2'))
})
it('honors per-element string overrides (loadingText, errorText, anonymousName)', async () => {
let reject
const adapter = makeAdapter([], {
listScores: vi.fn(() => new Promise((_, rj) => { reject = rj }))
})
const el = mount({ category: 'b', title: 'T' }, {
adapter,
loadingText: 'Hold on…',
errorText: 'Nope.',
anonymousName: 'Mystery Player'
})
await vi.waitFor(() => expect(rowsText(el)).toContain('Hold on…'))
reject(new Error('x'))
await vi.waitFor(() => expect(rowsText(el)).toContain('Nope.'))
})
it('uses the anonymous name for rows without a name', async () => {
const adapter = makeAdapter([{ score: 9 }])
const el = mount({ category: 'b', title: 'T' }, { adapter, anonymousName: 'Mystery' })
await vi.waitFor(() => expect(rowsText(el)).toContain('Mystery'))
})
it('honors shared-config labels, tooltips and emptyMessages', async () => {
const adapter = makeAdapter([])
configureLeaderboard({
adapter,
labels: { today: 'Heute' },
tooltips: { today: 'Letzte 24 Stunden' },
emptyMessages: ['Nichts hier.']
})
const el = mount({ category: 'b', title: 'T' })
await vi.waitFor(() => {
const tab = tabByLabel(el, 'Heute')
expect(tab).toBeTruthy()
expect(tab.getAttribute('title')).toBe('Letzte 24 Stunden')
expect(rowsText(el)).toContain('Nichts hier.')
})
})
})
describe('submit()', () => {
const entry = () => ({
name: 'Zed',
playerId: 'p1',
score: 42,
category: 'beginner',
time_stamp: new Date('2026-07-03T12:00:00Z'),
status: 'win',
meta: { isMobile: false }
})
it('archives and writes a ranked entry with bucket keys', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'T' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
await el.submit(entry())
expect(adapter.archive).toHaveBeenCalledWith({
playerId: 'p1',
score: 42,
category: 'beginner',
time_stamp: entry().time_stamp,
meta: { isMobile: false }
})
expect(adapter.addScore).toHaveBeenCalledTimes(1)
const [category, doc] = adapter.addScore.mock.calls[0]
expect(category).toBe('beginner')
expect(doc).toMatchObject({
name: 'Zed',
playerId: 'p1',
score: 42,
day: '2026-07-03',
week: '2026-W27',
month: '2026-07'
})
})
it('skips the ranked write when the entry does not qualify', async () => {
const adapter = makeAdapter([], {
getConfig: vi.fn(async () => ({ passingStatus: 'win' }))
})
const el = mount({ category: 'beginner', title: 'T' }, { adapter })
await vi.waitFor(() => expect(adapter.getConfig).toHaveBeenCalled())
await new Promise(r => setTimeout(r, 0)) // let the writer store the config
await el.submit({ ...entry(), status: 'lose' })
expect(adapter.archive).toHaveBeenCalledTimes(1)
expect(adapter.addScore).not.toHaveBeenCalled()
})
it('honors a per-element qualifies override', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'T' }, {
adapter,
qualifies: () => false
})
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
await el.submit(entry())
expect(adapter.addScore).not.toHaveBeenCalled()
})
})