Files
botfights/frontend/src/composables/__tests__/useHumanChallenge.test.ts
T
2026-04-11 19:46:37 +01:00

314 lines
9.9 KiB
TypeScript

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<string, unknown> = {}) {
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<string>
let myBotId: Ref<string | null>
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
fightId = ref('fight-1') as Ref<string>
myBotId = ref<string | null>('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)
})
})