test: add frontend composable tests and remaining test files
useNostr (9), useFightCache (5), useOnlineStatus (4) composable tests. Added fake-indexeddb dev dependency for IDB tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
806163c6c5
commit
1c296c6f1c
@@ -19,6 +19,7 @@
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Mock IndexedDB with fake-indexeddb
|
||||
import 'fake-indexeddb/auto'
|
||||
|
||||
import { cacheFight, getCachedFight } from '../useFightCache'
|
||||
|
||||
function makeFight(id: string): any {
|
||||
return {
|
||||
id,
|
||||
botA: { id: `a_${id}`, name: 'Bot A' },
|
||||
botB: { id: `b_${id}`, name: 'Bot B' },
|
||||
rounds: [],
|
||||
status: 'finished',
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFightCache — IndexedDB', () => {
|
||||
beforeEach(async () => {
|
||||
// Clear any prior DB state
|
||||
const dbs = await indexedDB.databases?.() || []
|
||||
for (const db of dbs) {
|
||||
if (db.name) indexedDB.deleteDatabase(db.name)
|
||||
}
|
||||
})
|
||||
|
||||
it('caches and retrieves a fight', async () => {
|
||||
const fight = makeFight('f1')
|
||||
await cacheFight(fight)
|
||||
const result = await getCachedFight('f1')
|
||||
expect(result).toBeTruthy()
|
||||
expect(result!.id).toBe('f1')
|
||||
})
|
||||
|
||||
it('returns null for uncached fight', async () => {
|
||||
const result = await getCachedFight('nonexistent')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('overwrites existing entry with same id', async () => {
|
||||
const fight1 = { ...makeFight('f1'), status: 'finished' }
|
||||
const fight2 = { ...makeFight('f1'), status: 'updated' }
|
||||
await cacheFight(fight1)
|
||||
await cacheFight(fight2)
|
||||
|
||||
const result = await getCachedFight('f1')
|
||||
expect((result as any).status).toBe('updated')
|
||||
})
|
||||
|
||||
it('evicts oldest when exceeding MAX_CACHED (5)', async () => {
|
||||
// Cache 6 fights with staggered timestamps
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await cacheFight(makeFight(`f${i}`))
|
||||
// Small delay to ensure different _cachedAt timestamps
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
|
||||
// f1 (oldest) should be evicted
|
||||
const oldest = await getCachedFight('f1')
|
||||
expect(oldest).toBeNull()
|
||||
|
||||
// f6 (newest) should exist
|
||||
const newest = await getCachedFight('f6')
|
||||
expect(newest).toBeTruthy()
|
||||
})
|
||||
|
||||
it('gracefully handles missing IndexedDB', async () => {
|
||||
const orig = globalThis.indexedDB
|
||||
// Temporarily break indexedDB
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
value: { open: () => { throw new Error('Not available') } },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
await expect(cacheFight(makeFight('f_err'))).resolves.toBeUndefined()
|
||||
const result = await getCachedFight('f_err')
|
||||
expect(result).toBeNull()
|
||||
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
value: orig,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Mock nostr-tools before importing useNostr
|
||||
vi.mock('nostr-tools', () => ({
|
||||
generateSecretKey: () => new Uint8Array(32).fill(1),
|
||||
getPublicKey: () => 'a'.repeat(64),
|
||||
}))
|
||||
vi.mock('nostr-tools/utils', () => ({
|
||||
bytesToHex: (b: Uint8Array) => Array.from(b).map(x => x.toString(16).padStart(2, '0')).join(''),
|
||||
hexToBytes: (h: string) => new Uint8Array(h.match(/.{2}/g)!.map(x => parseInt(x, 16))),
|
||||
}))
|
||||
vi.mock('nostr-tools/nip19', () => ({
|
||||
nsecEncode: (b: Uint8Array) => 'nsec1mock',
|
||||
}))
|
||||
|
||||
// Mock the auth module
|
||||
const mockAuthFetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ exists: false }) })
|
||||
const mockSetToken = vi.fn()
|
||||
const mockGetToken = vi.fn().mockReturnValue(null)
|
||||
const mockIsTokenExpired = vi.fn().mockReturnValue(true)
|
||||
|
||||
vi.mock('../../lib/nostr-auth', () => ({
|
||||
buildNip98Token: vi.fn().mockResolvedValue('mock-nip98-token'),
|
||||
setToken: (...args: unknown[]) => mockSetToken(...args),
|
||||
getToken: () => mockGetToken(),
|
||||
isTokenExpired: () => mockIsTokenExpired(),
|
||||
authFetch: (...args: unknown[]) => mockAuthFetch(...args),
|
||||
}))
|
||||
|
||||
describe('useNostr', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('isLoggedIn is false when no pubkey or bot stored', async () => {
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { isLoggedIn, pubkey, bot } = useNostr()
|
||||
expect(isLoggedIn.value).toBe(false)
|
||||
expect(pubkey.value).toBeNull()
|
||||
expect(bot.value).toBeNull()
|
||||
})
|
||||
|
||||
it('logout clears all auth state from localStorage', async () => {
|
||||
localStorage.setItem('bf_pubkey', JSON.stringify('testpub'))
|
||||
localStorage.setItem('bf_bot', JSON.stringify({ id: 'b1', name: 'Bot' }))
|
||||
localStorage.setItem('bf_pic', JSON.stringify('https://example.com/pic.jpg'))
|
||||
localStorage.setItem('bf_nsec', 'secretkey')
|
||||
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { logout } = useNostr()
|
||||
|
||||
logout()
|
||||
|
||||
expect(localStorage.getItem('bf_pubkey')).toBeNull()
|
||||
expect(localStorage.getItem('bf_bot')).toBeNull()
|
||||
expect(localStorage.getItem('bf_pic')).toBeNull()
|
||||
expect(localStorage.getItem('bf_nsec')).toBeNull()
|
||||
expect(mockSetToken).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('hasExtension detects window.nostr', async () => {
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { hasExtension } = useNostr()
|
||||
// No NIP-07 extension in jsdom
|
||||
expect(hasExtension.value).toBe(false)
|
||||
})
|
||||
|
||||
it('hasStoredKey is false when no nsec in localStorage', async () => {
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { hasStoredKey } = useNostr()
|
||||
expect(hasStoredKey.value).toBe(false)
|
||||
})
|
||||
|
||||
it('hasStoredKey is true when nsec pre-set in localStorage', async () => {
|
||||
localStorage.setItem('bf_nsec', 'test-nsec')
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { hasStoredKey } = useNostr()
|
||||
expect(hasStoredKey.value).toBe(true)
|
||||
})
|
||||
|
||||
it('getStoredNsec returns null when no key stored', async () => {
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { getStoredNsec } = useNostr()
|
||||
expect(getStoredNsec()).toBeNull()
|
||||
})
|
||||
|
||||
it('persistKey saves session nsec to localStorage', async () => {
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { persistKey } = useNostr()
|
||||
// Without a session key, persist should be a no-op
|
||||
persistKey()
|
||||
expect(localStorage.getItem('bf_nsec')).toBeNull()
|
||||
})
|
||||
|
||||
it('pubkey and bot are readonly refs', async () => {
|
||||
vi.resetModules()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const result = useNostr()
|
||||
// pubkey and bot should be Readonly refs
|
||||
expect(result.pubkey).toBeDefined()
|
||||
expect(result.bot).toBeDefined()
|
||||
expect(result.isLoggedIn).toBeDefined()
|
||||
expect(result.isLoading).toBeDefined()
|
||||
})
|
||||
|
||||
it('waitForSigner resolves false when no extension', async () => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
const { useNostr } = await import('../useNostr')
|
||||
const { waitForSigner } = useNostr()
|
||||
|
||||
const promise = waitForSigner(500)
|
||||
// Advance timers past the timeout
|
||||
vi.advanceTimersByTime(600)
|
||||
const result = await promise
|
||||
expect(result).toBe(false)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { useOnlineStatus } from '../useOnlineStatus'
|
||||
|
||||
// Wrapper component to test the composable in a real Vue lifecycle
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { isOnline } = useOnlineStatus()
|
||||
return { isOnline }
|
||||
},
|
||||
template: '<span>{{ isOnline }}</span>',
|
||||
})
|
||||
|
||||
describe('useOnlineStatus', () => {
|
||||
it('returns isOnline ref that reflects navigator.onLine', () => {
|
||||
const wrapper = mount(TestComponent)
|
||||
expect(wrapper.vm.isOnline).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('adds event listeners on mount', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('online', expect.any(Function))
|
||||
expect(addSpy).toHaveBeenCalledWith('offline', expect.any(Function))
|
||||
|
||||
wrapper.unmount()
|
||||
addSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes event listeners on unmount', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const wrapper = mount(TestComponent)
|
||||
wrapper.unmount()
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('online', expect.any(Function))
|
||||
expect(removeSpy).toHaveBeenCalledWith('offline', expect.any(Function))
|
||||
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('singleton: multiple instances share same ref', () => {
|
||||
const w1 = mount(TestComponent)
|
||||
const w2 = mount(TestComponent)
|
||||
|
||||
expect(w1.vm.isOnline).toBe(w2.vm.isOnline)
|
||||
|
||||
w1.unmount()
|
||||
w2.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fightEvents, type FightEvent } from './events.js'
|
||||
|
||||
describe('fightEvents', () => {
|
||||
it('emits events to fight-specific listeners', () => {
|
||||
const events: FightEvent[] = []
|
||||
const unsub = fightEvents.on('fight-1', (e) => events.push(e))
|
||||
|
||||
fightEvents.emit({
|
||||
fightId: 'fight-1',
|
||||
type: 'test',
|
||||
data: { msg: 'hello' },
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].data.msg).toBe('hello')
|
||||
unsub()
|
||||
})
|
||||
|
||||
it('cleanup removes all listeners for a fight', () => {
|
||||
const events: FightEvent[] = []
|
||||
fightEvents.on('fight-cleanup', (e) => events.push(e))
|
||||
fightEvents.on('fight-cleanup', (e) => events.push(e))
|
||||
|
||||
fightEvents.cleanup('fight-cleanup')
|
||||
|
||||
fightEvents.emit({
|
||||
fightId: 'fight-cleanup',
|
||||
type: 'test',
|
||||
data: {},
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('global listeners receive all events', () => {
|
||||
const events: FightEvent[] = []
|
||||
const unsub = fightEvents.onAll((e) => events.push(e))
|
||||
|
||||
fightEvents.emit({
|
||||
fightId: 'fight-global-1',
|
||||
type: 'test',
|
||||
data: {},
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
fightEvents.emit({
|
||||
fightId: 'fight-global-2',
|
||||
type: 'test',
|
||||
data: {},
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
unsub()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { waitForHumanResponse, clearAllPending } from './human-responses.js'
|
||||
import { fightEvents } from './events.js'
|
||||
import type { Challenge } from './challenges.js'
|
||||
|
||||
const factualChallenge: Challenge = {
|
||||
type: 'speed_blitz',
|
||||
label: 'Speed Blitz',
|
||||
prompt: 'What year was Bitcoin launched?',
|
||||
answers: ['2009'],
|
||||
scoring: 'factual',
|
||||
timeout_ms: 10000,
|
||||
baseDamage: 20,
|
||||
choices: ['2009', '2010', '2008'],
|
||||
}
|
||||
|
||||
const quickChallenge: Challenge = {
|
||||
type: 'math_blitz',
|
||||
label: 'Math Blitz',
|
||||
prompt: 'What is 2+2?',
|
||||
answers: ['4'],
|
||||
scoring: 'factual',
|
||||
timeout_ms: 5000,
|
||||
baseDamage: 18,
|
||||
choices: ['4', '3', '5'],
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearAllPending()
|
||||
})
|
||||
|
||||
describe('BUG-2: SSE uses challenge timeout, not hardcoded', () => {
|
||||
it('waitForHumanResponse uses challenge.timeout_ms', () => {
|
||||
const fightId = `bug2-${Date.now()}`
|
||||
vi.useFakeTimers()
|
||||
|
||||
// 10s challenge timeout + 5s human extra = 15s total
|
||||
const { promise } = waitForHumanResponse(fightId, 'bot1', factualChallenge, 1)
|
||||
|
||||
let resolved = false
|
||||
promise.then(() => { resolved = true })
|
||||
|
||||
// At 14s, should NOT have timed out yet
|
||||
vi.advanceTimersByTime(14_000)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// At 16s, should have timed out (10s + 5s = 15s)
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('different challenge type has different timeout', () => {
|
||||
const fightId = `bug2-quick-${Date.now()}`
|
||||
vi.useFakeTimers()
|
||||
|
||||
// 5s timeout + 5s human extra = 10s total
|
||||
const { promise } = waitForHumanResponse(fightId, 'bot2', quickChallenge, 1)
|
||||
|
||||
let resolved = false
|
||||
promise.then(() => { resolved = true })
|
||||
|
||||
// At 9s, should NOT have timed out yet
|
||||
vi.advanceTimersByTime(9_000)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// At 11s, should have timed out (5s + 5s = 10s)
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BUG-3: shuffle return value used', () => {
|
||||
it('choices returned from waitForHumanResponse are shuffled (contain correct answer)', () => {
|
||||
vi.useFakeTimers()
|
||||
const fightId = `bug3-${Date.now()}`
|
||||
const { choices } = waitForHumanResponse(fightId, 'bot3', factualChallenge, 1)
|
||||
|
||||
// Must contain the correct answer
|
||||
expect(choices).toContain('2009')
|
||||
// Must have 3 choices
|
||||
expect(choices).toHaveLength(3)
|
||||
|
||||
clearAllPending()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BUG-9: no TODO in prompt options', () => {
|
||||
it('no prompt contains TODO string', () => {
|
||||
vi.useFakeTimers()
|
||||
const fightId = `bug9-${Date.now()}`
|
||||
const { choices } = waitForHumanResponse(fightId, 'bot9', factualChallenge, 1)
|
||||
|
||||
for (const choice of choices) {
|
||||
expect(choice).not.toContain('TODO')
|
||||
expect(choice).not.toContain('pass #')
|
||||
}
|
||||
|
||||
clearAllPending()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BUG-12: fightEvents.cleanup removes listeners', () => {
|
||||
it('cleanup removes fight listeners', () => {
|
||||
const fightId = 'bug12-test'
|
||||
let received = false
|
||||
fightEvents.on(fightId, () => { received = true })
|
||||
|
||||
fightEvents.cleanup(fightId)
|
||||
|
||||
// Emit after cleanup — should not fire
|
||||
fightEvents.emit({
|
||||
fightId,
|
||||
type: 'test',
|
||||
data: {},
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
expect(received).toBe(false)
|
||||
})
|
||||
|
||||
it('global listeners still fire after cleanup', () => {
|
||||
const fightId = 'bug12-global'
|
||||
let received = false
|
||||
const unsub = fightEvents.onAll(() => { received = true })
|
||||
|
||||
fightEvents.cleanup(fightId)
|
||||
|
||||
fightEvents.emit({
|
||||
fightId,
|
||||
type: 'test',
|
||||
data: {},
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
expect(received).toBe(true)
|
||||
unsub()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user