test: add odds calculation and retro-moves coverage — 50 tests
Odds: eloProbability, calculateOdds, fractional/American display, payout calculation, bet validation (30 tests). Retro moves: RETRO_MOVES data, lookupMove, scoreRetroResponse, generateRetroChallenge, mock response generation (20 tests). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2c83858115
commit
42d79487a1
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* Odds calculation coverage tests — Phase 8.3
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
eloProbability,
|
||||
@@ -9,66 +12,80 @@ import {
|
||||
} from './odds.js'
|
||||
|
||||
describe('eloProbability', () => {
|
||||
it('equal ELO gives 50/50', () => {
|
||||
it('equal ratings → 50%', () => {
|
||||
expect(eloProbability(1200, 1200)).toBeCloseTo(0.5, 5)
|
||||
})
|
||||
|
||||
it('higher ELO favored', () => {
|
||||
expect(eloProbability(1400, 1200)).toBeGreaterThan(0.7)
|
||||
it('400 point advantage → ~91%', () => {
|
||||
const prob = eloProbability(1600, 1200)
|
||||
expect(prob).toBeGreaterThan(0.9)
|
||||
expect(prob).toBeLessThan(0.92)
|
||||
})
|
||||
|
||||
it('lower ELO underdog', () => {
|
||||
expect(eloProbability(1000, 1400)).toBeLessThan(0.15)
|
||||
it('400 point disadvantage → ~9%', () => {
|
||||
const prob = eloProbability(1200, 1600)
|
||||
expect(prob).toBeGreaterThan(0.08)
|
||||
expect(prob).toBeLessThan(0.1)
|
||||
})
|
||||
|
||||
it('probabilities are complementary', () => {
|
||||
const pA = eloProbability(1300, 1100)
|
||||
const pB = eloProbability(1100, 1300)
|
||||
expect(pA + pB).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
it('extreme ELO difference stays finite', () => {
|
||||
const p = eloProbability(100, 2500)
|
||||
it('always between 0 and 1', () => {
|
||||
for (const [a, b] of [[0, 0], [0, 5000], [5000, 0], [1200, 1200]]) {
|
||||
const p = eloProbability(a, b)
|
||||
expect(p).toBeGreaterThan(0)
|
||||
expect(p).toBeLessThan(0.001)
|
||||
expect(isFinite(p)).toBe(true)
|
||||
expect(p).toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateOdds', () => {
|
||||
it('equal ELO gives near-even odds', () => {
|
||||
it('equal ELO → ~50/50 odds', () => {
|
||||
const odds = calculateOdds(1200, 1200)
|
||||
expect(odds.botAWinProb).toBeCloseTo(0.5, 1)
|
||||
expect(odds.botBWinProb).toBeCloseTo(0.5, 1)
|
||||
expect(odds.botADecimalOdds).toBeCloseTo(2.0, 0)
|
||||
expect(odds.spread).toBe(0)
|
||||
})
|
||||
|
||||
it('house edge reduces payout below fair odds', () => {
|
||||
const odds = calculateOdds(1200, 1200, { houseEdge: 0.05 })
|
||||
it('probabilities sum to 1', () => {
|
||||
const odds = calculateOdds(1500, 1200)
|
||||
expect(odds.botAWinProb + odds.botBWinProb).toBeCloseTo(1, 2)
|
||||
})
|
||||
|
||||
it('higher ELO gets higher probability', () => {
|
||||
const odds = calculateOdds(1500, 1200)
|
||||
expect(odds.botAWinProb).toBeGreaterThan(odds.botBWinProb)
|
||||
})
|
||||
|
||||
it('payout multiplier < fair odds (house edge)', () => {
|
||||
const odds = calculateOdds(1200, 1200)
|
||||
expect(odds.botAPayoutMultiplier).toBeLessThan(odds.botADecimalOdds)
|
||||
expect(odds.botBPayoutMultiplier).toBeLessThan(odds.botBDecimalOdds)
|
||||
})
|
||||
|
||||
it('favorite has lower payout multiplier', () => {
|
||||
const odds = calculateOdds(1500, 1200)
|
||||
expect(odds.botAPayoutMultiplier).toBeLessThan(odds.botBPayoutMultiplier)
|
||||
})
|
||||
|
||||
it('streak adjusts probability', () => {
|
||||
it('streak adjustment shifts probability', () => {
|
||||
const noStreak = calculateOdds(1200, 1200)
|
||||
const withStreak = calculateOdds(1200, 1200, { streakA: 5 })
|
||||
const withStreak = calculateOdds(1200, 1200, { streakA: 5, streakB: 0 })
|
||||
expect(withStreak.botAWinProb).toBeGreaterThan(noStreak.botAWinProb)
|
||||
})
|
||||
|
||||
it('probabilities always sum to 1', () => {
|
||||
const odds = calculateOdds(1600, 1000, { streakA: 3, streakB: 1 })
|
||||
expect(odds.botAWinProb + odds.botBWinProb).toBeCloseTo(1, 2)
|
||||
it('recent form adjustment shifts probability', () => {
|
||||
const noForm = calculateOdds(1200, 1200)
|
||||
const withForm = calculateOdds(1200, 1200, {
|
||||
recentWinRateA: 0.9,
|
||||
recentWinRateB: 0.1,
|
||||
})
|
||||
expect(withForm.botAWinProb).toBeGreaterThan(noForm.botAWinProb)
|
||||
})
|
||||
|
||||
it('probabilities capped between 2% and 98%', () => {
|
||||
const odds = calculateOdds(3000, 500, { streakA: 5 })
|
||||
expect(odds.botAWinProb).toBeLessThanOrEqual(0.98)
|
||||
expect(odds.botBWinProb).toBeGreaterThanOrEqual(0.02)
|
||||
it('custom house edge changes payouts', () => {
|
||||
const low = calculateOdds(1200, 1200, { houseEdge: 0.01 })
|
||||
const high = calculateOdds(1200, 1200, { houseEdge: 0.10 })
|
||||
expect(low.botAPayoutMultiplier).toBeGreaterThan(high.botAPayoutMultiplier)
|
||||
})
|
||||
|
||||
it('probability clamped to 0.02-0.98', () => {
|
||||
const extreme = calculateOdds(5000, 0, { streakA: 5, streakB: 0 })
|
||||
expect(extreme.botAWinProb).toBeLessThanOrEqual(0.98)
|
||||
expect(extreme.botBWinProb).toBeGreaterThanOrEqual(0.02)
|
||||
})
|
||||
|
||||
it('spread reflects ELO difference', () => {
|
||||
@@ -78,74 +95,87 @@ describe('calculateOdds', () => {
|
||||
})
|
||||
|
||||
describe('oddsToFractional', () => {
|
||||
it('even money', () => {
|
||||
it('2.0 → "1/1" (even money)', () => {
|
||||
expect(oddsToFractional(2.0)).toBe('1/1')
|
||||
})
|
||||
|
||||
it('3/1', () => {
|
||||
expect(oddsToFractional(4.0)).toBe('3/1')
|
||||
it('3.0 → "2/1"', () => {
|
||||
expect(oddsToFractional(3.0)).toBe('2/1')
|
||||
})
|
||||
|
||||
it('1/2', () => {
|
||||
it('1.5 → "1/2"', () => {
|
||||
expect(oddsToFractional(1.5)).toBe('1/2')
|
||||
})
|
||||
|
||||
it('decimal ≤ 1 → "0/1"', () => {
|
||||
expect(oddsToFractional(1.0)).toBe('0/1')
|
||||
expect(oddsToFractional(0.5)).toBe('0/1')
|
||||
})
|
||||
|
||||
it('odd decimal uses /100 fallback', () => {
|
||||
const result = oddsToFractional(1.123)
|
||||
expect(result).toMatch(/\//)
|
||||
})
|
||||
})
|
||||
|
||||
describe('oddsToAmerican', () => {
|
||||
it('underdog shows positive', () => {
|
||||
it('2.0 → "+100"', () => {
|
||||
expect(oddsToAmerican(2.0)).toBe('+100')
|
||||
})
|
||||
|
||||
it('3.0 → "+200"', () => {
|
||||
expect(oddsToAmerican(3.0)).toBe('+200')
|
||||
})
|
||||
|
||||
it('favorite shows negative', () => {
|
||||
it('1.5 → "-200"', () => {
|
||||
expect(oddsToAmerican(1.5)).toBe('-200')
|
||||
})
|
||||
|
||||
it('even money', () => {
|
||||
expect(oddsToAmerican(2.0)).toBe('+100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePayout', () => {
|
||||
it('100 sats at 2x = 200', () => {
|
||||
expect(calculatePayout(100, 2.0)).toBe(200)
|
||||
it('1000 sats × 2.0 = 2000 sats', () => {
|
||||
expect(calculatePayout(1000, 2.0)).toBe(2000)
|
||||
})
|
||||
|
||||
it('floors to integer sats', () => {
|
||||
expect(calculatePayout(100, 1.94)).toBe(194)
|
||||
it('floors fractional sats', () => {
|
||||
expect(calculatePayout(100, 1.5)).toBe(150)
|
||||
expect(calculatePayout(333, 1.5)).toBe(499)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateBet', () => {
|
||||
it('valid bet', () => {
|
||||
it('valid bet within range', () => {
|
||||
expect(validateBet(1000).valid).toBe(true)
|
||||
})
|
||||
|
||||
it('below minimum', () => {
|
||||
it('rejects zero', () => {
|
||||
expect(validateBet(0).valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects negative', () => {
|
||||
const r = validateBet(-1)
|
||||
expect(r.valid).toBe(false)
|
||||
expect(r.error).toContain('positive')
|
||||
})
|
||||
|
||||
it('rejects non-integer', () => {
|
||||
expect(validateBet(1.5).valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects below minimum', () => {
|
||||
const r = validateBet(50)
|
||||
expect(r.valid).toBe(false)
|
||||
expect(r.error).toContain('Minimum')
|
||||
})
|
||||
|
||||
it('above maximum', () => {
|
||||
it('rejects above maximum', () => {
|
||||
const r = validateBet(200_000)
|
||||
expect(r.valid).toBe(false)
|
||||
expect(r.error).toContain('Maximum')
|
||||
})
|
||||
|
||||
it('zero is invalid', () => {
|
||||
expect(validateBet(0).valid).toBe(false)
|
||||
})
|
||||
|
||||
it('negative is invalid', () => {
|
||||
expect(validateBet(-100).valid).toBe(false)
|
||||
})
|
||||
|
||||
it('non-integer is invalid', () => {
|
||||
expect(validateBet(100.5).valid).toBe(false)
|
||||
})
|
||||
|
||||
it('custom limits', () => {
|
||||
expect(validateBet(50, 10, 100).valid).toBe(true)
|
||||
expect(validateBet(5, 10, 100).valid).toBe(false)
|
||||
it('custom min/max', () => {
|
||||
expect(validateBet(5, 1, 10).valid).toBe(true)
|
||||
expect(validateBet(15, 1, 10).valid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Retro moves coverage tests — Phase 8.3
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
RETRO_MOVES,
|
||||
lookupMove,
|
||||
scoreRetroResponse,
|
||||
generateRetroChallenge,
|
||||
generateMockRetroResponse,
|
||||
getRetroMoveInputs,
|
||||
} from './retro-moves.js'
|
||||
|
||||
describe('RETRO_MOVES data', () => {
|
||||
it('has at least 20 moves', () => {
|
||||
expect(RETRO_MOVES.length).toBeGreaterThanOrEqual(20)
|
||||
})
|
||||
|
||||
it('all tiers represented', () => {
|
||||
const tiers = new Set(RETRO_MOVES.map(m => m.tier))
|
||||
expect(tiers.has('basic')).toBe(true)
|
||||
expect(tiers.has('standard')).toBe(true)
|
||||
expect(tiers.has('super')).toBe(true)
|
||||
expect(tiers.has('ultra')).toBe(true)
|
||||
})
|
||||
|
||||
it('all moves have positive damage', () => {
|
||||
for (const m of RETRO_MOVES) {
|
||||
expect(m.damage).toBeGreaterThan(0)
|
||||
expect(m.input.length).toBeGreaterThan(0)
|
||||
expect(m.name.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('lookupMove', () => {
|
||||
it('finds basic move by input', () => {
|
||||
const move = lookupMove('A')
|
||||
expect(move).not.toBeNull()
|
||||
expect(move!.name).toBe('Jab')
|
||||
})
|
||||
|
||||
it('finds super move', () => {
|
||||
const move = lookupMove('↓→↓→+A')
|
||||
expect(move).not.toBeNull()
|
||||
expect(move!.name).toBe('Hadouken')
|
||||
})
|
||||
|
||||
it('returns null for invalid input', () => {
|
||||
expect(lookupMove('XYZZY')).toBeNull()
|
||||
})
|
||||
|
||||
it('case insensitive', () => {
|
||||
const move = lookupMove('a')
|
||||
expect(move).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoreRetroResponse', () => {
|
||||
it('null answer → zero score', () => {
|
||||
const result = scoreRetroResponse(null, [])
|
||||
expect(result.totalDamage).toBe(0)
|
||||
expect(result.moves).toHaveLength(0)
|
||||
expect(result.score).toBe(0)
|
||||
})
|
||||
|
||||
it('single known move scores its base damage', () => {
|
||||
const result = scoreRetroResponse('A', ['A'])
|
||||
expect(result.moves).toHaveLength(1)
|
||||
expect(result.moves[0].name).toBe('Jab')
|
||||
expect(result.moves[0].discovered).toBe(false)
|
||||
expect(result.totalDamage).toBe(5) // Jab = 5
|
||||
})
|
||||
|
||||
it('discovered move gets 1.5x damage', () => {
|
||||
const result = scoreRetroResponse('↓→↓→+A', []) // Hadouken not in known
|
||||
expect(result.moves[0].discovered).toBe(true)
|
||||
expect(result.moves[0].damage).toBe(Math.round(22 * 1.5)) // 33
|
||||
})
|
||||
|
||||
it('pipe-separated combo parses correctly', () => {
|
||||
const result = scoreRetroResponse('A | B | →+A', ['A', 'B', '→+A'])
|
||||
expect(result.moves).toHaveLength(3)
|
||||
expect(result.totalDamage).toBe(5 + 6 + 8) // 19
|
||||
})
|
||||
|
||||
it('invalid move in combo → 0 damage for that move', () => {
|
||||
// canonicalize extracts buttons from text, so use pure symbols that won't match
|
||||
const result = scoreRetroResponse('A | ??? | B', ['A', 'B'])
|
||||
expect(result.moves[1].name).toBeNull()
|
||||
expect(result.moves[1].damage).toBe(0)
|
||||
expect(result.totalDamage).toBe(5 + 0 + 6)
|
||||
})
|
||||
|
||||
it('max 3 moves parsed', () => {
|
||||
const result = scoreRetroResponse('A | B | →+A | ←+B', ['A', 'B', '→+A', '←+B'])
|
||||
expect(result.moves).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('score capped at 10', () => {
|
||||
// 3 super moves with discovery = huge damage
|
||||
const result = scoreRetroResponse('↓→↓→+A | ←↓→+B | ↑↑↓↓+A', [])
|
||||
expect(result.score).toBeLessThanOrEqual(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateRetroChallenge', () => {
|
||||
it('returns valid challenge object', () => {
|
||||
const ch = generateRetroChallenge()
|
||||
expect(ch.type).toBe('retro_mode')
|
||||
expect(ch.scoring).toBe('factual')
|
||||
expect(ch.timeout_ms).toBe(12000)
|
||||
expect(ch.prompt).toContain('ARCADE ROUND')
|
||||
expect(ch.answers!.length).toBeGreaterThanOrEqual(7) // 4 basics + 3-5 standards
|
||||
})
|
||||
|
||||
it('challenge prompt lists known moves', () => {
|
||||
const ch = generateRetroChallenge()
|
||||
expect(ch.prompt).toContain('dmg')
|
||||
expect(ch.prompt).toContain('combo1 | combo2 | combo3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateMockRetroResponse', () => {
|
||||
it('returns pipe-separated moves', () => {
|
||||
const resp = generateMockRetroResponse(1200)
|
||||
expect(resp).toContain('|')
|
||||
const parts = resp.split('|')
|
||||
expect(parts.length).toBe(3)
|
||||
})
|
||||
|
||||
it('low ELO bot may produce gibberish', () => {
|
||||
let hasGibberish = false
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const resp = generateMockRetroResponse(800)
|
||||
const parts = resp.split('|').map(s => s.trim())
|
||||
for (const p of parts) {
|
||||
if (!lookupMove(p)) hasGibberish = true
|
||||
}
|
||||
}
|
||||
// Low ELO should sometimes produce invalid moves
|
||||
expect(hasGibberish).toBe(true)
|
||||
})
|
||||
|
||||
it('high ELO bot uses mostly valid moves', () => {
|
||||
let validCount = 0
|
||||
let totalCount = 0
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const resp = generateMockRetroResponse(2000)
|
||||
const parts = resp.split('|').map(s => s.trim())
|
||||
for (const p of parts) {
|
||||
totalCount++
|
||||
if (lookupMove(p)) validCount++
|
||||
}
|
||||
}
|
||||
expect(validCount / totalCount).toBeGreaterThan(0.7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRetroMoveInputs', () => {
|
||||
it('returns all move inputs', () => {
|
||||
const inputs = getRetroMoveInputs()
|
||||
expect(inputs.length).toBe(RETRO_MOVES.length)
|
||||
expect(inputs.includes('A')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user