diff --git a/server/src/engine/human-responses.test.ts b/server/src/engine/human-responses.test.ts new file mode 100644 index 0000000..d4110c0 --- /dev/null +++ b/server/src/engine/human-responses.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { waitForHumanResponse, submitHumanResponse, getPendingChallenge, clearAllPending } from './human-responses.js' +import type { Challenge } from './challenges.js' + +const mockChallenge: Challenge = { + type: 'bitcoin_trivia', + label: 'Bitcoin Trivia', + prompt: 'What year was Bitcoin launched?', + answers: ['2009'], + scoring: 'factual', + timeout_ms: 8000, + difficulty: 'medium', + choices: ['2009', '2010', '2008'], +} + +afterEach(() => { + clearAllPending() +}) + +describe('waitForHumanResponse race condition fix', () => { + it('pending challenge is stored synchronously before promise resolves', () => { + // Call waitForHumanResponse — this must store the pending challenge + // SYNCHRONOUSLY (inside Promise constructor), not in a microtask + const { promise, choices } = waitForHumanResponse('fight-1', 'bot-1', mockChallenge, 1) + + // IMMEDIATELY after, getPendingChallenge must find it + // (no await needed — this proves it's synchronous) + const pending = getPendingChallenge('fight-1', 'bot-1') + expect(pending).not.toBeNull() + expect(pending!.prompt).toBe('What year was Bitcoin launched?') + expect(pending!.choices).toEqual(choices) + + // Clean up by submitting + submitHumanResponse('fight-1', 'bot-1', '2009') + }) + + it('choices are returned from waitForHumanResponse for SSE inclusion', () => { + const { choices } = waitForHumanResponse('fight-2', 'bot-2', mockChallenge, 1) + + // Choices must be non-empty and include the correct answer + expect(choices.length).toBeGreaterThanOrEqual(2) + expect(choices).toContain('2009') + + submitHumanResponse('fight-2', 'bot-2', '2009') + }) +}) + +describe('waitForHumanResponse timeout', () => { + it('times out and resolves with null answer', async () => { + vi.useFakeTimers() + const shortChallenge = { ...mockChallenge, timeout_ms: 100 } + const { promise } = waitForHumanResponse('fight-3', 'bot-3', shortChallenge, 1) + + // Advance past timeout (100ms challenge + 5000ms human extra) + vi.advanceTimersByTime(5200) + const result = await promise + expect(result.timedOut).toBe(true) + expect(result.answer).toBeNull() + vi.useRealTimers() + }) +}) + +describe('submitHumanResponse', () => { + it('accepts submission and resolves promise', async () => { + const { promise } = waitForHumanResponse('fight-4', 'bot-4', mockChallenge, 1) + + const accepted = submitHumanResponse('fight-4', 'bot-4', '2009', 'Bitcoin fixes this') + expect(accepted).toBe(true) + + const result = await promise + expect(result.answer).toBe('2009') + expect(result.trashTalk).toBe('Bitcoin fixes this') + expect(result.timedOut).toBe(false) + }) + + it('returns false for unknown fight/bot', () => { + const accepted = submitHumanResponse('nonexistent', 'nobody', 'answer') + expect(accepted).toBe(false) + }) + + it('truncates long answers to 2000 chars', async () => { + const { promise } = waitForHumanResponse('fight-5', 'bot-5', mockChallenge, 1) + const longAnswer = 'x'.repeat(3000) + submitHumanResponse('fight-5', 'bot-5', longAnswer) + const result = await promise + expect(result.answer!.length).toBe(2000) + }) +})