import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { ref, type Ref } from 'vue' import { useHumanChallenge } from '../useHumanChallenge' // Mock global fetch const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) function makeChallengeData(overrides: Record = {}) { return { type: 'bitcoin_trivia', label: 'Bitcoin Trivia', prompt: 'Who is Satoshi Nakamoto?', roundNumber: 1, timeoutMs: 10000, scoring: 'factual', choices: [], ...overrides, } } describe('useHumanChallenge', () => { let fightId: Ref let myBotId: Ref beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() fightId = ref('fight-1') as Ref myBotId = ref('bot-1') mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({}), }) }) afterEach(() => { vi.useRealTimers() }) it('applyChallenge sets challenge state correctly', () => { const hc = useHumanChallenge(fightId, myBotId) const data = makeChallengeData({ roundNumber: 3, choices: ['A', 'B', 'C'] }) hc.applyChallenge(data) expect(hc.humanChallenge.value).not.toBeNull() expect(hc.humanChallenge.value!.type).toBe('bitcoin_trivia') expect(hc.humanChallenge.value!.prompt).toBe('Who is Satoshi Nakamoto?') expect(hc.humanChallenge.value!.roundNumber).toBe(3) expect(hc.humanChallenge.value!.scoring).toBe('factual') expect(hc.humanChoices.value).toEqual(['A', 'B', 'C']) expect(hc.humanAnswer.value).toBe('') expect(hc.humanSubmitted.value).toBe(false) expect(hc.humanTimer.value).toBeGreaterThan(0) }) it('applyChallenge deduplicates same round', () => { const hc = useHumanChallenge(fightId, myBotId) const data = makeChallengeData({ roundNumber: 1 }) hc.applyChallenge(data) const firstChallenge = hc.humanChallenge.value // Apply same round again — should be no-op hc.applyChallenge(data) expect(hc.humanChallenge.value).toBe(firstChallenge) }) it('applyChallenge allows different round numbers', () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData({ roundNumber: 1 })) expect(hc.humanChallenge.value!.roundNumber).toBe(1) hc.applyChallenge(makeChallengeData({ roundNumber: 2, prompt: 'New prompt' })) expect(hc.humanChallenge.value!.roundNumber).toBe(2) expect(hc.humanChallenge.value!.prompt).toBe('New prompt') }) it('timer counts down each second', () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData({ timeoutMs: 8000 })) const initialTimer = hc.humanTimer.value vi.advanceTimersByTime(1000) expect(hc.humanTimer.value).toBe(initialTimer - 1) vi.advanceTimersByTime(1000) expect(hc.humanTimer.value).toBe(initialTimer - 2) }) it('timeout clears challenge state and submits timeout', async () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData({ timeoutMs: 6000 })) // Advance past all timer ticks until timer reaches 0 const timerVal = hc.humanTimer.value vi.advanceTimersByTime(timerVal * 1000) expect(hc.humanSubmitted.value).toBe(true) // submitTimeout should have been called — verify the fetch await vi.runAllTimersAsync() expect(mockFetch).toHaveBeenCalledWith( '/api/fights/fight-1/respond/bot-1', expect.objectContaining({ method: 'POST', body: JSON.stringify({ answer: '', timeout: true }), }), ) }) it('submitHumanAnswer sends answer to API', async () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData()) hc.humanAnswer.value = 'A cypherpunk legend' await hc.submitHumanAnswer() expect(hc.humanSubmitted.value).toBe(true) expect(mockFetch).toHaveBeenCalledWith( '/api/fights/fight-1/respond/bot-1', expect.objectContaining({ method: 'POST', body: JSON.stringify({ answer: 'A cypherpunk legend' }), }), ) }) it('submitHumanAnswer does not submit empty answer', async () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData()) hc.humanAnswer.value = ' ' await hc.submitHumanAnswer() expect(hc.humanSubmitted.value).toBe(false) expect(mockFetch).not.toHaveBeenCalled() }) it('submitHumanAnswer does not submit without botId', async () => { myBotId.value = null const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData()) hc.humanAnswer.value = 'test' await hc.submitHumanAnswer() expect(hc.humanSubmitted.value).toBe(false) expect(mockFetch).not.toHaveBeenCalled() }) it('submitChoice sets answer and submits', () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData({ choices: ['21M', '42M', '100M'] })) hc.submitChoice('21M') expect(hc.humanAnswer.value).toBe('21M') expect(hc.humanSubmitted.value).toBe(true) expect(mockFetch).toHaveBeenCalledWith( '/api/fights/fight-1/respond/bot-1', expect.objectContaining({ method: 'POST', body: JSON.stringify({ answer: '21M' }), }), ) }) it('submitChoice prevents double-tap', () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData({ choices: ['A', 'B'] })) hc.submitChoice('A') mockFetch.mockClear() // Second tap should be ignored hc.submitChoice('B') expect(hc.humanAnswer.value).toBe('A') expect(mockFetch).not.toHaveBeenCalled() }) it('cooldown prevents immediate resubmission', () => { const hc = useHumanChallenge(fightId, myBotId) hc.startCooldown(3) expect(hc.roundCooldown.value).toBe(3) vi.advanceTimersByTime(1000) expect(hc.roundCooldown.value).toBe(2) vi.advanceTimersByTime(1000) expect(hc.roundCooldown.value).toBe(1) vi.advanceTimersByTime(1000) expect(hc.roundCooldown.value).toBe(0) }) it('cooldown applies pending challenge when finished', () => { const hc = useHumanChallenge(fightId, myBotId) const pendingData = makeChallengeData({ roundNumber: 5 }) // Queue a pending challenge during cooldown hc.startCooldown(2) hc.pendingChallengeData.value = { data: pendingData, receivedAt: Date.now() } // Advance past cooldown vi.advanceTimersByTime(2000) expect(hc.roundCooldown.value).toBe(0) expect(hc.humanChallenge.value).not.toBeNull() expect(hc.humanChallenge.value!.roundNumber).toBe(5) expect(hc.pendingChallengeData.value).toBeNull() }) it('resetState clears all state', () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData({ choices: ['X'] })) hc.humanAnswer.value = 'test answer' hc.resetState() expect(hc.humanChallenge.value).toBeNull() expect(hc.humanAnswer.value).toBe('') expect(hc.humanSubmitted.value).toBe(false) expect(hc.humanChoices.value).toEqual([]) expect(hc.roundCooldown.value).toBe(0) expect(hc.pendingChallengeData.value).toBeNull() expect(hc.entrancePlaying.value).toBe(false) expect(hc.animatingRound.value).toBe(false) }) it('handleSSEChallenge queues when entrance is playing', () => { const hc = useHumanChallenge(fightId, myBotId) hc.entrancePlaying.value = true const data = makeChallengeData({ roundNumber: 1 }) hc.handleSSEChallenge(data) // Should be queued, not applied expect(hc.humanChallenge.value).toBeNull() expect(hc.pendingChallengeData.value).not.toBeNull() expect(hc.pendingChallengeData.value!.data).toEqual(data) }) it('handleSSEChallenge applies immediately when not blocked', () => { const hc = useHumanChallenge(fightId, myBotId) const data = makeChallengeData({ roundNumber: 1 }) hc.handleSSEChallenge(data) expect(hc.humanChallenge.value).not.toBeNull() expect(hc.humanChallenge.value!.roundNumber).toBe(1) }) it('setEntrancePlaying applies pending challenge when entrance ends', () => { const hc = useHumanChallenge(fightId, myBotId) hc.entrancePlaying.value = true const data = makeChallengeData({ roundNumber: 2 }) hc.pendingChallengeData.value = { data, receivedAt: Date.now() } hc.setEntrancePlaying(false) expect(hc.entrancePlaying.value).toBe(false) expect(hc.humanChallenge.value).not.toBeNull() expect(hc.humanChallenge.value!.roundNumber).toBe(2) expect(hc.pendingChallengeData.value).toBeNull() }) it('applyChallenge guarantees minimum display time', () => { const hc = useHumanChallenge(fightId, myBotId) // remainingMs of 2000 is below MIN_DISPLAY_MS (5000), so it should clamp up hc.applyChallenge(makeChallengeData({ remainingMs: 2000 })) // Timer should be at least 5 seconds (MIN_DISPLAY_MS / 1000) expect(hc.humanTimer.value).toBeGreaterThanOrEqual(5) expect(hc.humanChallenge.value!.remainingMs).toBeGreaterThanOrEqual(5000) }) it('stopHumanPolling clears all interval handles', () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData()) hc.startCooldown(5) // This should not throw and should clean up intervals hc.stopHumanPolling() // Advancing timers should not change state const timerVal = hc.humanTimer.value const cooldownVal = hc.roundCooldown.value vi.advanceTimersByTime(5000) expect(hc.humanTimer.value).toBe(timerVal) expect(hc.roundCooldown.value).toBe(cooldownVal) }) it('clearChallenge removes challenge but preserves other state', () => { const hc = useHumanChallenge(fightId, myBotId) hc.applyChallenge(makeChallengeData({ choices: ['A', 'B'] })) hc.startCooldown(3) hc.clearChallenge() expect(hc.humanChallenge.value).toBeNull() expect(hc.humanSubmitted.value).toBe(false) expect(hc.pendingChallengeData.value).toBeNull() // Cooldown should still be running (clearChallenge doesn't touch it) expect(hc.roundCooldown.value).toBe(3) }) })