cozy-games/packages/leaderboard/test/leaderboard-element.test.js

431 lines
16 KiB
JavaScript

// @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()
})
})