// @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: '
', score: 1 }])
const reader = new LeaderBoardReader({ adapter: { listScores } })
const el = await reader.render('beginner', 'Best Times')
await vi.waitFor(() =>
expect(el.textContent).toContain('
'))
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)
})
})