123 lines
5.4 KiB
JavaScript
123 lines
5.4 KiB
JavaScript
// @ts-check
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
import { readFileSync } from 'node:fs'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { dirname, join } from 'node:path'
|
|
|
|
import { LeaderBoardReader } from '../leaderboard-read.js'
|
|
import { LeaderBoardWriter } from '../leaderboard-write.js'
|
|
import { LeaderBoardService } from '../leader-board.js'
|
|
|
|
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const source = (file) => readFileSync(join(pkgDir, file), 'utf8')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
|
|
.replace(/\/\/.*$/gm, '') // strip line comments
|
|
|
|
describe('read/write surface separation — imports', () => {
|
|
it('the READ module pulls in no write-path code', () => {
|
|
const code = source('leaderboard-read.js')
|
|
// no import of the write module or its unique dependency (bucket keys)
|
|
expect(code).not.toMatch(/from\s+['"][^'"]*leaderboard-write/)
|
|
expect(code).not.toMatch(/date-bucket|buckets/)
|
|
// no write operations
|
|
expect(code).not.toMatch(/\bsubmit\b|\baddScore\b|\barchive\b/)
|
|
})
|
|
|
|
it('the WRITE module pulls in no read/render code', () => {
|
|
const code = source('leaderboard-write.js')
|
|
expect(code).not.toMatch(/from\s+['"][^'"]*leaderboard-read/)
|
|
// no DOM / rendering / listing
|
|
expect(code).not.toMatch(/\bdocument\b|\brender\b|\blistScores\b|createElement/)
|
|
})
|
|
})
|
|
|
|
describe('read surface — usable standalone (no writes)', () => {
|
|
it('renders a ranked list via listScores and never calls write methods', async () => {
|
|
const listScores = vi.fn(async () => [{ name: 'Ada', score: 10 }, { name: 'Bo', score: 20 }])
|
|
// A deliberately read-only adapter: no addScore/archive at all.
|
|
const adapter = { listScores }
|
|
const reader = new LeaderBoardReader({ adapter, scoreOrder: 'asc', formatScore: String })
|
|
|
|
const el = await reader.render('beginner', 'Best Times')
|
|
expect(el.querySelector('h3')).toBeTruthy() // heading rendered
|
|
expect(el.querySelectorAll('button')).toHaveLength(4) // four duration tabs
|
|
expect(listScores).toHaveBeenCalledTimes(1) // 'today' window loaded once
|
|
|
|
// The list fills asynchronously — wait for the rows to appear (names render as text).
|
|
await vi.waitFor(() => {
|
|
expect(el.textContent).toContain('Ada')
|
|
expect(el.textContent).toContain('Bo')
|
|
})
|
|
|
|
// Reader exposes no write API.
|
|
expect(typeof (/** @type {any} */ (reader).submit)).toBe('undefined')
|
|
})
|
|
|
|
it('renders entry names as text, never as HTML (names are player-controlled)', async () => {
|
|
const listScores = vi.fn(async () => [{ name: '<img src=x onerror=alert(1)>', score: 1 }])
|
|
const reader = new LeaderBoardReader({ adapter: { listScores } })
|
|
|
|
const el = await reader.render('beginner', 'Best Times')
|
|
await vi.waitFor(() =>
|
|
expect(el.textContent).toContain('<img src=x onerror=alert(1)>'))
|
|
expect(el.querySelector('img')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('write surface — usable standalone with an injected instance', () => {
|
|
it('submits a completed game through the injected adapter (archive + ranked write)', async () => {
|
|
const archive = vi.fn(async () => {})
|
|
const addScore = vi.fn(async () => {})
|
|
// Injected, write-capable adapter (no listScores/render needed).
|
|
const adapter = { archive, addScore }
|
|
const writer = new LeaderBoardWriter({ adapter })
|
|
|
|
await writer.submit({
|
|
name: 'Ada', playerId: 'p1', score: 42, category: 'beginner', time_stamp: Date.UTC(2026, 6, 3)
|
|
})
|
|
|
|
expect(archive).toHaveBeenCalledTimes(1)
|
|
expect(addScore).toHaveBeenCalledTimes(1)
|
|
const [category, doc] = addScore.mock.calls[0]
|
|
expect(category).toBe('beginner')
|
|
expect(doc).toMatchObject({ name: 'Ada', playerId: 'p1', score: 42 })
|
|
// denormalized bucket keys are computed on the write side
|
|
expect(doc).toEqual(expect.objectContaining({ day: expect.any(String), week: expect.any(String), month: expect.any(String) }))
|
|
|
|
// Writer exposes no read/render API.
|
|
expect(typeof (/** @type {any} */ (writer).render)).toBe('undefined')
|
|
})
|
|
|
|
it('honors an explicit qualifies gate (skips the ranked write, still archives)', async () => {
|
|
const archive = vi.fn(async () => {})
|
|
const addScore = vi.fn(async () => {})
|
|
const writer = new LeaderBoardWriter({ adapter: { archive, addScore }, qualifies: () => false })
|
|
await writer.submit({ playerId: 'p1', score: 1, category: 'beginner', time_stamp: 0 })
|
|
expect(archive).toHaveBeenCalledTimes(1)
|
|
expect(addScore).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('facade — combined surface unchanged (regression)', () => {
|
|
it('LeaderBoardService delegates render (read) and submit (write)', async () => {
|
|
const listScores = vi.fn(async () => [{ name: 'Ada', score: 10 }])
|
|
const archive = vi.fn(async () => {})
|
|
const addScore = vi.fn(async () => {})
|
|
const adapter = { listScores, archive, addScore }
|
|
const service = new LeaderBoardService({ adapter, formatScore: String })
|
|
|
|
// read
|
|
const el = await service.render('beginner', 'Best')
|
|
expect(el.querySelector('h3')).toBeTruthy()
|
|
expect(listScores).toHaveBeenCalled()
|
|
await vi.waitFor(() => expect(el.textContent).toContain('Ada'))
|
|
|
|
// write
|
|
await service.submit({ name: 'Ada', playerId: 'p1', score: 5, category: 'beginner', time_stamp: 0 })
|
|
expect(addScore).toHaveBeenCalledTimes(1)
|
|
|
|
// composed surfaces are reachable
|
|
expect(service.reader).toBeInstanceOf(LeaderBoardReader)
|
|
expect(service.writer).toBeInstanceOf(LeaderBoardWriter)
|
|
})
|
|
})
|