diff --git a/packages/leaderboard/CONFIGURATION.md b/packages/leaderboard/CONFIGURATION.md index 9491832..29c8adb 100644 --- a/packages/leaderboard/CONFIGURATION.md +++ b/packages/leaderboard/CONFIGURATION.md @@ -103,7 +103,8 @@ mounted elements. | `format` | `time` \| `number` \| `plain` | score display preset (below) | 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 @@ -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 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/ - array/string overrides are read from properties. + rebuild — changing the `score-order`/`format` attributes does this + automatically). `score-order` and `format` are read from attributes; the + function/array/string overrides are read from properties. diff --git a/packages/leaderboard/leaderboard-element.js b/packages/leaderboard/leaderboard-element.js index 761529a..7364d5d 100644 --- a/packages/leaderboard/leaderboard-element.js +++ b/packages/leaderboard/leaderboard-element.js @@ -1,15 +1,23 @@ -import { WebComponent } from 'web-component-base' +import { WebComponent, html } from 'web-component-base' import { LeaderBoardService } from './leader-board.js' +import { DURATIONS } from './leaderboard-read.js' /** * `` — a custom element that lets a developer compose the * leaderboard UI declaratively in HTML instead of wiring it in JavaScript. * - * Built on `web-component-base` (WebComponent base class + lifecycle hooks). It - * extends WCB for the custom-element scaffolding (onInit/onDestroy, connect - * handling) and delegates all inner DOM — the duration tabs and the ranked - * list — to the existing LeaderBoardService, which already builds and manages - * that DOM (including in-place tab swaps). + * Built on `web-component-base` (WCB) the idiomatic way: the view is a pure + * `html` template over a precomputed view-state object, WCB's own + * `attributeChangedCallback` feeds observed attributes into `this.props` and + * the `onChanges` hook, and updates go through the base class's `render()`. + * 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 * 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 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 { static get observedAttributes() { 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() { + this._connected = true 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() } + 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`Leaderboard not configured.` + return html` +
+

${str(this.props.title)}

+
+ ${view.tabs.map(tab => html` + + `)} +
+
+ ${view.list.rows + ? html` +
+ ${view.list.rows.map(row => html` +
+
#${row.index}
+
${row.name}
+
${row.score}
+
+ `)} +
` + : html`${view.list.message}`} +
+
+ ` + } + // Per-element override properties (public): adapter, formatScore, qualifies, - // labels, emptyMessages, loadingText, errorText, anonymousName. Each falls back - // to the shared configureLeaderboard() value, then the package default. Set - // them before the element connects (or clear `_svc` to force a rebuild). + // labels, tooltips, emptyMessages, loadingText, errorText, anonymousName. + // Each falls back to the shared configureLeaderboard() value, then the + // 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() { if (this._svc) return this._svc const adapter = this.adapter || sharedConfig.adapter if (!adapter) return null const formatScore = this.formatScore - || resolveFormat(this.getAttribute('format')) + || resolveFormat(str(this.props.format)) || sharedConfig.formatScore || resolveFormat(sharedConfig.format) || String this._svc = new LeaderBoardService({ adapter, - scoreOrder: this.getAttribute('score-order') || sharedConfig.scoreOrder || 'asc', + scoreOrder: str(this.props.scoreOrder) || sharedConfig.scoreOrder || 'asc', formatScore, qualifies: this.qualifies || sharedConfig.qualifies, // 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 - * `duration` attribute; later mounts preserve the service's remembered - * duration (so switching category keeps the selected tab) unless a duration - * is passed explicitly. + * (Re)mount the board. The first mount honors the author's `duration` + * attribute; later mounts keep the selected duration (so switching category + * keeps the selected tab) unless a duration is passed explicitly. */ _mount(durationArg) { - if (!this.isConnected) return + if (!this._connected) return const service = this._service() if (!service) { - this.replaceChildren(this._message('Leaderboard not configured.')) + this._view = { board: false } + this._paint() return } + const duration = durationArg + ?? this._activeDuration + ?? (str(this.props.duration) || 'today') + this._activeDuration = duration + this._load(service, duration) + } - let duration = durationArg - if (duration === undefined && !this._mounted) { - duration = this.getAttribute('duration') || undefined + _selectTab(id) { + this._activeDuration = id + 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 - service.render(this.getAttribute('category') || '', this.getAttribute('title') || '', duration) - .then(el => { - if (this._token !== token) return - this.replaceChildren(el) - this._mounted = true - }) - .catch(() => { - if (this._token !== token) return - this.replaceChildren(this._message('Leaderboard unavailable right now.')) - }) + /** + * Render the current view state through WCB. WCB's render() replaces the + * whole subtree (no diffing yet), which would drop focus from a clicked + * duration tab — the one behavior the base class can't preserve for us — so + * focus is handed to the replacement tab explicitly. + */ + _paint() { + const focused = document.activeElement + const focusedTab = focused && this.contains(focused) ? focused.dataset.duration : undefined + this.render() + if (focusedTab) this.querySelector(`button[data-duration="${focusedTab}"]`)?.focus() } /** @@ -151,12 +284,6 @@ export class CozyLeaderboard extends WebComponent { const service = this._service() if (service) return service.submit(entry) } - - _message(text) { - const em = document.createElement('em') - em.innerText = text - return em - } } if (!customElements.get('cozy-leaderboard')) { diff --git a/packages/leaderboard/leaderboard-read.js b/packages/leaderboard/leaderboard-read.js index b1cefc7..5d00fcf 100644 --- a/packages/leaderboard/leaderboard-read.js +++ b/packages/leaderboard/leaderboard-read.js @@ -14,9 +14,10 @@ const DAY_MS = 24 * 60 * 60 * 1000 * The four time windows are ROLLING: each shows entries from the last `ms` * 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 - * that spells out the window. + * that spells out the window. Exported so view layers (e.g. the + * `` element) can render the tab bar themselves. */ -const DURATIONS = [ +export const DURATIONS = [ { id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' }, { id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 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' } - _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 } - _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 } - _emptyMessage() { + /** One empty-state message, picked at random. */ + emptyMessage() { 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 + * `` element) use this instead of {@link render}. + * @param {String} category + * @param {String} durationId - a DURATIONS id ('today' | 'week' | 'month' | 'all') + * @returns {Promise} + */ + 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 * the rolling cutoff (entries with `time_stamp >= since`); `null` means @@ -123,7 +146,7 @@ export class LeaderBoardReader { wrapper.style.margin = '0 auto' const heading = document.createElement('h3') - heading.innerText = title + heading.textContent = title heading.style.borderBottom = '1px solid #c0c0c0' heading.style.paddingBottom = '10px' wrapper.append(heading) @@ -147,9 +170,9 @@ export class LeaderBoardReader { DURATIONS.forEach(d => { const tab = document.createElement('button') - tab.innerText = this._label(d) + tab.textContent = this.label(d) tab.type = 'button' - tab.setAttribute('title', this._tooltip(d)) + tab.setAttribute('title', this.tooltip(d)) this._styleTab(tab, d.id === duration) tab.onclick = () => activate(d.id) tabs[d.id] = tab @@ -186,7 +209,7 @@ export class LeaderBoardReader { listWrapper.innerHTML = '' const loading = document.createElement('em') - loading.innerText = this.loadingText + loading.textContent = this.loadingText listWrapper.append(loading) try { @@ -197,7 +220,7 @@ export class LeaderBoardReader { if (this._loadToken !== token) return listWrapper.innerHTML = '' const message = document.createElement('em') - message.innerText = this.errorText + message.textContent = this.errorText listWrapper.append(message) } } @@ -207,7 +230,7 @@ export class LeaderBoardReader { if (!rows || !rows.length) { const message = document.createElement('em') - message.innerText = this._emptyMessage() + message.textContent = this.emptyMessage() listWrapper.append(message) return } @@ -222,7 +245,7 @@ export class LeaderBoardReader { item.style.display = 'flex' const indexElement = document.createElement('div') - indexElement.innerText = `#${i++}` + indexElement.textContent = `#${i++}` const nameElement = document.createElement('div') const name = data.name || this.anonymousName @@ -237,7 +260,7 @@ export class LeaderBoardReader { nameElement.style.flex = '1' const scoreElement = document.createElement('div') - scoreElement.innerText = this.formatScore(data.score) + scoreElement.textContent = this.formatScore(data.score) item.append(indexElement, nameElement, scoreElement) list.append(item) diff --git a/packages/leaderboard/test/leaderboard-element.test.js b/packages/leaderboard/test/leaderboard-element.test.js new file mode 100644 index 0000000..0e40c69 --- /dev/null +++ b/packages/leaderboard/test/leaderboard-element.test.js @@ -0,0 +1,431 @@ +// @ts-check +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import { configureLeaderboard } from '../leaderboard-element.js' + +/** + * Characterization tests for : 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() + }) +})