diff --git a/server/src/engine/answers.test.ts b/server/src/engine/answers.test.ts index 46ef785..a413b53 100644 --- a/server/src/engine/answers.test.ts +++ b/server/src/engine/answers.test.ts @@ -120,4 +120,28 @@ describe('checkAnswer', () => { // 'a' length is 1, so containment shouldn't trigger (requires >= 2) expect(score).toBeLessThanOrEqual(0.8) }) + + it('special characters in answer', () => { + expect(checkAnswer('C++', ['C++'])).toBe(1.0) + expect(checkAnswer('c++', ['C++'])).toBe(1.0) + expect(checkAnswer('$100', ['$100'])).toBe(1.0) + expect(checkAnswer('42%', ['42'])).toBe(1.0) + }) + + it('very long answer still matches if correct keyword present', () => { + const longAnswer = 'Well, after much deliberation and careful consideration of all the facts, ' + + 'weighing the evidence both for and against, consulting multiple sources, and thinking deeply ' + + 'about the philosophical implications, I believe the answer you are looking for is Paris, ' + + 'which is of course the beautiful capital of France.' + expect(checkAnswer(longAnswer, ['Paris'])).toBe(1.0) + }) + + it('very long answer with no match returns 0', () => { + const longWrong = 'A'.repeat(2000) + ' banana ' + 'B'.repeat(2000) + expect(checkAnswer(longWrong, ['Paris'])).toBe(0) + }) + + it('whitespace-only answer returns 0', () => { + expect(checkAnswer('\t\n \r', ['Paris'])).toBe(0) + }) }) diff --git a/server/src/engine/challenges.test.ts b/server/src/engine/challenges.test.ts index a7b2531..11c8463 100644 --- a/server/src/engine/challenges.test.ts +++ b/server/src/engine/challenges.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { pickChallenge, getAllChallengeTypes, getAnswerPool } from './challenges.js' +import { pickChallenge, pickRankedChallenge, getAllChallengeTypes, getAnswerPool } from './challenges.js' describe('pickChallenge', () => { it('returns a valid challenge', () => { @@ -66,6 +66,64 @@ describe('pickChallenge', () => { // With 50 tries, choices should appear in more than one order expect(orders.size).toBeGreaterThan(1) }) + + it('distribution: ~70% factual, ~30% creative over many picks', () => { + let factual = 0 + let creative = 0 + const runs = 1000 + for (let i = 0; i < runs; i++) { + const c = pickChallenge(new Set(), null) + if (c.scoring === 'factual') factual++ + else creative++ + } + const factualPct = factual / runs + // Allow ±10% tolerance due to randomness + expect(factualPct).toBeGreaterThan(0.55) + expect(factualPct).toBeLessThan(0.85) + }) + + it('True/False auto-generation for boolean answers', () => { + // Pick many challenges, find ones with answers = ['true'] or ['false'] + let foundTFWithChoices = false + for (let i = 0; i < 500; i++) { + const c = pickChallenge(new Set(), null) + if (c.answers?.length === 1 && ['true', 'false'].includes(c.answers[0].toLowerCase())) { + expect(c.choices).toBeTruthy() + expect(c.choices!.length).toBe(2) + expect(c.choices!.sort()).toEqual(['False', 'True']) + foundTFWithChoices = true + } + } + expect(foundTFWithChoices).toBe(true) + }) +}) + +describe('pickRankedChallenge', () => { + it('never returns choices', () => { + for (let i = 0; i < 100; i++) { + const c = pickRankedChallenge(new Set()) + expect(c.choices).toBeUndefined() + } + }) + + it('returns valid challenge structure', () => { + const c = pickRankedChallenge(new Set()) + expect(c.type).toBeTruthy() + expect(c.prompt).toBeTruthy() + expect(c.timeout_ms).toBeGreaterThan(0) + expect(c.baseDamage).toBeGreaterThan(0) + }) + + it('avoids used types', () => { + const types = getAllChallengeTypes() + const used = new Set(types.slice(0, -1)) + let gotRemaining = false + for (let i = 0; i < 50; i++) { + const c = pickRankedChallenge(used) + if (c.type === types[types.length - 1]) gotRemaining = true + } + expect(gotRemaining).toBe(true) + }) }) describe('getAllChallengeTypes', () => { diff --git a/server/src/engine/mock.test.ts b/server/src/engine/mock.test.ts index 79f34b2..bb55711 100644 --- a/server/src/engine/mock.test.ts +++ b/server/src/engine/mock.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { mockResponse } from './mock.js' -import { pickChallenge } from './challenges.js' +import { pickChallenge, getAllChallengeTypes } from './challenges.js' describe('mockResponse', () => { it('returns a valid response structure', () => { @@ -101,4 +101,47 @@ describe('mockResponse', () => { expect(resp.timeMs).toBeGreaterThan(0) } }) + + it('produces valid responses for all 16 challenge types', () => { + const types = getAllChallengeTypes() + expect(types.length).toBe(16) + + for (const type of types) { + // Force pick a challenge of this type by using all other types + const otherTypes = new Set(types.filter(t => t !== type)) + const challenge = pickChallenge(otherTypes, null) + // May not get exact type if creative/factual split causes fallback, but should still work + const resp = mockResponse(challenge, 'confident', 1500) + expect(resp).toHaveProperty('answer') + expect(resp).toHaveProperty('timeMs') + expect(resp.timeMs).toBeGreaterThan(0) + } + }) + + it('answer is empty string on timeout', () => { + // Run many times with very low elo to trigger timeouts + let foundTimeout = false + for (let i = 0; i < 200; i++) { + const challenge = pickChallenge(new Set(), null) + const resp = mockResponse(challenge, 'clueless', 500) + if (resp.timedOut) { + expect(resp.answer).toBe('') + foundTimeout = true + } + } + expect(foundTimeout).toBe(true) + }) + + it('answer is empty string on error', () => { + let foundError = false + for (let i = 0; i < 200; i++) { + const challenge = pickChallenge(new Set(), null) + const resp = mockResponse(challenge, 'clueless', 500) + if (resp.error) { + expect(resp.answer).toBe('') + foundError = true + } + } + expect(foundError).toBe(true) + }) })