test: add 65 server engine tests — answers, scoring, challenges
- answers.test.ts: 19 tests covering all 10 checkAnswer() tiers - scoring.test.ts: 30 tests for scoreRound, calculateElo, calculateTier, applyModifiers - challenges.test.ts: 16 tests for pickChallenge, type exclusion, data integrity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b2d1a2da02
commit
f837153771
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { checkAnswer } from './answers.js'
|
||||||
|
|
||||||
|
describe('checkAnswer', () => {
|
||||||
|
// --- Tier 1: exact match after normalization ---
|
||||||
|
it('exact match (case insensitive)', () => {
|
||||||
|
expect(checkAnswer('Paris', ['paris'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('PARIS', ['paris'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('paris', ['Paris'])).toBe(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exact match strips punctuation', () => {
|
||||||
|
expect(checkAnswer('Paris!', ['paris'])).toBe(1.0)
|
||||||
|
expect(checkAnswer(' Paris ', ['paris'])).toBe(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips leading articles', () => {
|
||||||
|
expect(checkAnswer('the Eiffel Tower', ['Eiffel Tower'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('an apple', ['apple'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('a dog', ['dog'])).toBe(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tier 2: contraction expansion ---
|
||||||
|
it('contraction expansion', () => {
|
||||||
|
expect(checkAnswer("can't", ['cannot'])).toBe(1.0)
|
||||||
|
expect(checkAnswer("don't", ['do not'])).toBe(1.0)
|
||||||
|
expect(checkAnswer("it's", ['it is'])).toBe(1.0)
|
||||||
|
expect(checkAnswer("they're", ['they are'])).toBe(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tier 3: stemmed match ---
|
||||||
|
it('stemmed match (plural/singular)', () => {
|
||||||
|
expect(checkAnswer('dogs', ['dog'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('buses', ['bus'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('foxes', ['fox'])).toBe(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tier 4: numeric equivalence ---
|
||||||
|
it('numeric equivalence', () => {
|
||||||
|
expect(checkAnswer('42', ['42'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('42.0', ['42'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('forty', ['40'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('twenty', ['20'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('one hundred', ['100'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('three hundred', ['300'])).toBe(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compound number words', () => {
|
||||||
|
expect(checkAnswer('twenty one', ['21'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('three hundred', ['300'])).toBe(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tier 5: containment ---
|
||||||
|
it('response contains accepted answer', () => {
|
||||||
|
expect(checkAnswer('The answer is Paris of course', ['paris'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('I believe the answer is 42', ['42'])).toBe(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stemmed containment', () => {
|
||||||
|
const score = checkAnswer('There are many dogs in the park', ['dog'])
|
||||||
|
expect(score).toBeGreaterThanOrEqual(0.95)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tier 6: accepted contains response ---
|
||||||
|
it('accepted answer contains the response (short answer)', () => {
|
||||||
|
const score = checkAnswer('Paris', ['The city of Paris'])
|
||||||
|
expect(score).toBeGreaterThanOrEqual(0.75)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tier 7: number in longer response ---
|
||||||
|
it('number embedded in response', () => {
|
||||||
|
expect(checkAnswer('I think it is about 42 meters', ['42'])).toBe(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('number word in response matches', () => {
|
||||||
|
const score = checkAnswer('I think forty is the answer', ['40'])
|
||||||
|
expect(score).toBeGreaterThanOrEqual(0.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tier 8: word-level containment ---
|
||||||
|
it('all words of answer appear in response', () => {
|
||||||
|
const score = checkAnswer('The Great Wall is in China and is very long', ['Great Wall'])
|
||||||
|
expect(score).toBeGreaterThanOrEqual(0.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tier 10: true/false ---
|
||||||
|
it('true/false match', () => {
|
||||||
|
expect(checkAnswer('True', ['true'])).toBe(1.0)
|
||||||
|
expect(checkAnswer('true, definitely', ['true'])).toBeGreaterThanOrEqual(0.9)
|
||||||
|
expect(checkAnswer('yes', ['true'])).toBeGreaterThanOrEqual(0.9)
|
||||||
|
expect(checkAnswer('correct', ['true'])).toBeGreaterThanOrEqual(0.9)
|
||||||
|
expect(checkAnswer('no', ['false'])).toBeGreaterThanOrEqual(0.9)
|
||||||
|
expect(checkAnswer('wrong', ['false'])).toBeGreaterThanOrEqual(0.9)
|
||||||
|
expect(checkAnswer('incorrect', ['false'])).toBeGreaterThanOrEqual(0.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Edge cases ---
|
||||||
|
it('null response returns 0', () => {
|
||||||
|
expect(checkAnswer(null, ['Paris'])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty response returns 0', () => {
|
||||||
|
expect(checkAnswer('', ['Paris'])).toBe(0)
|
||||||
|
expect(checkAnswer(' ', ['Paris'])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completely wrong answer returns 0', () => {
|
||||||
|
expect(checkAnswer('banana', ['Paris'])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('multiple accepted answers', () => {
|
||||||
|
expect(checkAnswer('NYC', ['New York City', 'NYC', 'New York'])).toBe(1.0)
|
||||||
|
// "New York" contained in "New York City" (tier 6: accepted contains response) scores 0.8
|
||||||
|
expect(checkAnswer('New York', ['New York City', 'NYC', 'New York'])).toBeGreaterThanOrEqual(0.8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('very short accepted answer needs >= 2 chars for containment', () => {
|
||||||
|
// Single char answers shouldn't trigger containment on random text
|
||||||
|
const score = checkAnswer('absolutely nothing relevant', ['a'])
|
||||||
|
// 'a' length is 1, so containment shouldn't trigger (requires >= 2)
|
||||||
|
expect(score).toBeLessThanOrEqual(0.8)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pickChallenge, getAllChallengeTypes, getAnswerPool } from './challenges.js'
|
||||||
|
|
||||||
|
describe('pickChallenge', () => {
|
||||||
|
it('returns a valid challenge', () => {
|
||||||
|
const c = pickChallenge(new Set(), null)
|
||||||
|
expect(c).toBeTruthy()
|
||||||
|
expect(c.type).toBeTruthy()
|
||||||
|
expect(c.label).toBeTruthy()
|
||||||
|
expect(c.prompt).toBeTruthy()
|
||||||
|
expect(c.timeout_ms).toBeGreaterThan(0)
|
||||||
|
expect(c.baseDamage).toBeGreaterThan(0)
|
||||||
|
expect(['factual', 'creative']).toContain(c.scoring)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('avoids used types when possible', () => {
|
||||||
|
const types = getAllChallengeTypes()
|
||||||
|
// Use all types except one
|
||||||
|
const used = new Set(types.slice(0, -1))
|
||||||
|
const remaining = types[types.length - 1]
|
||||||
|
// With most types used, should pick from the remaining
|
||||||
|
// Run multiple times to account for randomness
|
||||||
|
let gotRemaining = false
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const c = pickChallenge(used, null)
|
||||||
|
if (c.type === remaining) gotRemaining = true
|
||||||
|
}
|
||||||
|
expect(gotRemaining).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to all types when all used', () => {
|
||||||
|
const allUsed = new Set(getAllChallengeTypes())
|
||||||
|
const c = pickChallenge(allUsed, null)
|
||||||
|
// Should still return something
|
||||||
|
expect(c).toBeTruthy()
|
||||||
|
expect(c.prompt).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('factual challenges have answers', () => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const c = pickChallenge(new Set(), null)
|
||||||
|
if (c.scoring === 'factual') {
|
||||||
|
expect(c.answers).toBeTruthy()
|
||||||
|
expect(c.answers!.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creative challenges have no answers', () => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const c = pickChallenge(new Set(), null)
|
||||||
|
if (c.scoring === 'creative') {
|
||||||
|
expect(!c.answers || c.answers.length === 0).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('factual challenges with choices have shuffled choices', () => {
|
||||||
|
const orders = new Set<string>()
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const c = pickChallenge(new Set(), null)
|
||||||
|
if (c.scoring === 'factual' && c.choices) {
|
||||||
|
orders.add(c.choices.join(','))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// With 50 tries, choices should appear in more than one order
|
||||||
|
expect(orders.size).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getAllChallengeTypes', () => {
|
||||||
|
it('returns 16 types', () => {
|
||||||
|
const types = getAllChallengeTypes()
|
||||||
|
expect(types.length).toBe(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('types are unique', () => {
|
||||||
|
const types = getAllChallengeTypes()
|
||||||
|
expect(new Set(types).size).toBe(types.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes known types', () => {
|
||||||
|
const types = getAllChallengeTypes()
|
||||||
|
expect(types).toContain('speed_blitz')
|
||||||
|
expect(types).toContain('roast_battle')
|
||||||
|
expect(types).toContain('riddle')
|
||||||
|
expect(types).toContain('math_blitz')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getAnswerPool', () => {
|
||||||
|
it('returns answers for factual types', () => {
|
||||||
|
const pool = getAnswerPool('speed_blitz')
|
||||||
|
expect(pool.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array for creative types', () => {
|
||||||
|
const pool = getAnswerPool('roast_battle')
|
||||||
|
expect(pool.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty for unknown type', () => {
|
||||||
|
const pool = getAnswerPool('nonexistent_type')
|
||||||
|
expect(pool.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers are unique within pool', () => {
|
||||||
|
const types = getAllChallengeTypes()
|
||||||
|
for (const type of types) {
|
||||||
|
const pool = getAnswerPool(type)
|
||||||
|
const unique = new Set(pool)
|
||||||
|
expect(unique.size).toBe(pool.length)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('prompt data integrity', () => {
|
||||||
|
it('all 800 prompts are accessible via pickChallenge', () => {
|
||||||
|
// Run enough picks to verify the system works
|
||||||
|
const seenPrompts = new Set<string>()
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
const c = pickChallenge(new Set(), null)
|
||||||
|
seenPrompts.add(c.prompt)
|
||||||
|
}
|
||||||
|
// Should have seen a good variety
|
||||||
|
expect(seenPrompts.size).toBeGreaterThan(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no empty prompts', () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const c = pickChallenge(new Set(), null)
|
||||||
|
expect(c.prompt.trim().length).toBeGreaterThan(5)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('factual prompts have non-empty answers', () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const c = pickChallenge(new Set(), null)
|
||||||
|
if (c.scoring === 'factual' && c.answers) {
|
||||||
|
for (const a of c.answers) {
|
||||||
|
expect(a.trim().length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||||
|
import type { Challenge } from './challenges.js'
|
||||||
|
|
||||||
|
function makeChallenge(overrides: Partial<Challenge> = {}): Challenge {
|
||||||
|
return {
|
||||||
|
type: 'riddle',
|
||||||
|
label: 'Test Challenge',
|
||||||
|
prompt: 'What is 2+2?',
|
||||||
|
answers: ['4'],
|
||||||
|
scoring: 'factual',
|
||||||
|
baseDamage: 20,
|
||||||
|
timeout_ms: 8000,
|
||||||
|
...overrides,
|
||||||
|
} as Challenge
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResponse(answer: string | null, timeMs = 500, timedOut = false, error = false) {
|
||||||
|
return { answer, timeMs, timedOut, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('scoreRound', () => {
|
||||||
|
const botA = { id: 'a1', name: 'AlphaBot' }
|
||||||
|
const botB = { id: 'b1', name: 'BetaBot' }
|
||||||
|
|
||||||
|
it('both correct — faster bot scores higher', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 200),
|
||||||
|
makeResponse('4', 800),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(result.botAScore).toBeGreaterThan(result.botBScore)
|
||||||
|
expect(result.winnerId).toBe('a1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only A correct — A wins big', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(result.botAScore).toBeGreaterThan(8)
|
||||||
|
expect(result.botBScore).toBeLessThan(3)
|
||||||
|
expect(result.winnerId).toBe('a1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only B correct — B wins big', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('wrong', 500),
|
||||||
|
makeResponse('4', 500),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(result.winnerId).toBe('b1')
|
||||||
|
expect(result.botBScore).toBeGreaterThan(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('both wrong — faster bot slightly ahead', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('wrong', 200),
|
||||||
|
makeResponse('also wrong', 800),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(result.botAScore).toBeGreaterThan(result.botBScore)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('A times out — B wins automatically', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse(null, 0, true),
|
||||||
|
makeResponse('4', 500),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(result.winnerId).toBe('b1')
|
||||||
|
expect(result.botAScore).toBe(0)
|
||||||
|
expect(result.botBScore).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('B errors — A wins automatically', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse(null, 0, false, true),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(result.winnerId).toBe('a1')
|
||||||
|
expect(result.botAScore).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('both timeout — draw', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse(null, 0, true),
|
||||||
|
makeResponse(null, 0, true),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(result.winnerId).toBeNull()
|
||||||
|
expect(result.botAScore).toBe(0)
|
||||||
|
expect(result.botBScore).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creative scoring — longer quality text wins', () => {
|
||||||
|
const challenge = makeChallenge({
|
||||||
|
answers: undefined,
|
||||||
|
scoring: 'creative',
|
||||||
|
type: 'roast_battle',
|
||||||
|
})
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('Your code is so bad even ChatGPT refuses to debug it. Every function you write is a monument to incompetence.', 300),
|
||||||
|
makeResponse('lol ok', 300),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(result.botAScore).toBeGreaterThan(result.botBScore)
|
||||||
|
expect(result.winnerId).toBe('a1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('critical hit when margin > 4', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
// A scores ~9.5, B scores ~2 → margin ~7.5 → critical
|
||||||
|
expect(result.isCritical).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arena modifier doubles damage for matching type', () => {
|
||||||
|
const challenge = makeChallenge({ type: 'roast_battle' })
|
||||||
|
const resultWithMod = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
'roast_2x', 0, 0,
|
||||||
|
)
|
||||||
|
const resultWithout = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
// The winner's damage should be roughly 2x with modifier
|
||||||
|
expect(resultWithMod.botADamage).toBeGreaterThan(resultWithout.botADamage * 1.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arena modifier no effect on non-matching type', () => {
|
||||||
|
const challenge = makeChallenge({ type: 'riddle' })
|
||||||
|
const resultWithMod = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
'roast_2x', 0, 0,
|
||||||
|
)
|
||||||
|
const resultWithout = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(resultWithMod.botADamage).toBe(resultWithout.botADamage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('combo multiplier increases damage', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const resultCombo = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
null, 3, 0,
|
||||||
|
)
|
||||||
|
const resultNoCombo = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(resultCombo.botADamage).toBeGreaterThan(resultNoCombo.botADamage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('combo caps at 5', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const resultCombo5 = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
null, 5, 0,
|
||||||
|
)
|
||||||
|
const resultCombo10 = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
null, 10, 0,
|
||||||
|
)
|
||||||
|
expect(resultCombo5.botADamage).toBe(resultCombo10.botADamage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates narration', () => {
|
||||||
|
const challenge = makeChallenge()
|
||||||
|
const result = scoreRound(
|
||||||
|
challenge, botA, botB,
|
||||||
|
makeResponse('4', 500),
|
||||||
|
makeResponse('banana', 500),
|
||||||
|
null, 0, 0,
|
||||||
|
)
|
||||||
|
expect(result.narration).toBeTruthy()
|
||||||
|
expect(typeof result.narration).toBe('string')
|
||||||
|
expect(result.narration.length).toBeGreaterThan(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateElo', () => {
|
||||||
|
it('winner gains, loser loses', () => {
|
||||||
|
const { newWinnerElo, newLoserElo } = calculateElo(1200, 1200)
|
||||||
|
expect(newWinnerElo).toBeGreaterThan(1200)
|
||||||
|
expect(newLoserElo).toBeLessThan(1200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('equal ELO — symmetric gain/loss', () => {
|
||||||
|
const { newWinnerElo, newLoserElo } = calculateElo(1200, 1200)
|
||||||
|
const gain = newWinnerElo - 1200
|
||||||
|
const loss = 1200 - newLoserElo
|
||||||
|
expect(Math.abs(gain - loss)).toBeLessThan(0.2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upset win — bigger gain for winner', () => {
|
||||||
|
const { newWinnerElo: gainUpset } = calculateElo(1000, 1500)
|
||||||
|
const { newWinnerElo: gainExpected } = calculateElo(1500, 1000)
|
||||||
|
const upsetGain = gainUpset - 1000
|
||||||
|
const expectedGain = gainExpected - 1500
|
||||||
|
expect(upsetGain).toBeGreaterThan(expectedGain)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('custom K factor', () => {
|
||||||
|
const k16 = calculateElo(1200, 1200, 16)
|
||||||
|
const k64 = calculateElo(1200, 1200, 64)
|
||||||
|
expect(k64.newWinnerElo - 1200).toBeGreaterThan(k16.newWinnerElo - 1200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extreme ELO difference (100 vs 2000)', () => {
|
||||||
|
const result = calculateElo(100, 2000)
|
||||||
|
expect(isFinite(result.newWinnerElo)).toBe(true)
|
||||||
|
expect(isFinite(result.newLoserElo)).toBe(true)
|
||||||
|
expect(isNaN(result.newWinnerElo)).toBe(false)
|
||||||
|
expect(isNaN(result.newLoserElo)).toBe(false)
|
||||||
|
// Upset winner gains big
|
||||||
|
expect(result.newWinnerElo - 100).toBeGreaterThan(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never produces NaN or Infinity', () => {
|
||||||
|
const extremes = [
|
||||||
|
[0, 0], [0, 3000], [3000, 0], [1, 9999], [9999, 1],
|
||||||
|
]
|
||||||
|
for (const [w, l] of extremes) {
|
||||||
|
const result = calculateElo(w, l)
|
||||||
|
expect(isFinite(result.newWinnerElo)).toBe(true)
|
||||||
|
expect(isFinite(result.newLoserElo)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateTier', () => {
|
||||||
|
it('tier 0 — Baby (no wins)', () => {
|
||||||
|
expect(calculateTier(1200, 0)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tier 1 — Bronze (1+ wins)', () => {
|
||||||
|
expect(calculateTier(1000, 1)).toBe(1)
|
||||||
|
expect(calculateTier(1100, 2)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tier 2 — Silver (1200+ ELO, 3+ wins)', () => {
|
||||||
|
expect(calculateTier(1200, 3)).toBe(2)
|
||||||
|
expect(calculateTier(1200, 5)).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tier 3 — Gold (1350+ ELO, 7+ wins)', () => {
|
||||||
|
expect(calculateTier(1350, 7)).toBe(3)
|
||||||
|
expect(calculateTier(1400, 10)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tier 4 — Platinum (1500+ ELO, 15+ wins)', () => {
|
||||||
|
expect(calculateTier(1500, 15)).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tier 5 — Diamond (1700+ ELO, 25+ wins)', () => {
|
||||||
|
expect(calculateTier(1700, 25)).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tier 6 — Legend (1900+ ELO, 40+ wins)', () => {
|
||||||
|
expect(calculateTier(1900, 40)).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('high ELO not enough without wins', () => {
|
||||||
|
// 2000 ELO but only 2 wins = Bronze
|
||||||
|
expect(calculateTier(2000, 2)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('many wins not enough without ELO', () => {
|
||||||
|
// 1100 ELO, 50 wins = Bronze
|
||||||
|
expect(calculateTier(1100, 50)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boundary conditions', () => {
|
||||||
|
// Just below each threshold
|
||||||
|
expect(calculateTier(1199, 3)).toBe(1) // below Silver ELO
|
||||||
|
expect(calculateTier(1200, 2)).toBe(1) // below Silver wins
|
||||||
|
expect(calculateTier(1349, 7)).toBe(2) // below Gold ELO
|
||||||
|
expect(calculateTier(1350, 6)).toBe(2) // below Gold wins
|
||||||
|
expect(calculateTier(1499, 15)).toBe(3) // below Platinum ELO
|
||||||
|
expect(calculateTier(1500, 14)).toBe(3) // below Platinum wins
|
||||||
|
expect(calculateTier(1699, 25)).toBe(4) // below Diamond ELO
|
||||||
|
expect(calculateTier(1700, 24)).toBe(4) // below Diamond wins
|
||||||
|
expect(calculateTier(1899, 40)).toBe(5) // below Legend ELO
|
||||||
|
expect(calculateTier(1900, 39)).toBe(5) // below Legend wins
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user