brackt/app/services/__tests__/bracket-simulator.test.ts
Chris Parsons e2b178221a
Add oxlint linting setup with zero errors (#194)
* Add oxlint and fix all lint errors

- Install oxlint, add .oxlintrc.json with rules for TypeScript/React
- Add npm run lint / lint:fix scripts
- Add Claude PostToolUse hook to run oxlint on every edited file
- Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array
- Fix no-array-index-key (use stable keys or suppress positional cases)
- Fix exhaustive-deps missing dependency in useEffect
- Promote exhaustive-deps and no-array-index-key to errors
- Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix no-explicit-any warnings and upgrade tsconfig to ES2023

- Replace all `any` types with proper types or `unknown` across ~20 files
- Add typed socket payload interfaces in draft route and useDraftSocket
- Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch)
- Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed
- Fix cascading type errors uncovered by removing any: Map.get narrowing,
  participant relation types, ChartDataPoint, Partial<NewSeason> indexing
- Add ParticipantResultWithParticipant type to participant-result model
- Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult)
- Fix duplicate getQPStandings import in sportsSeasonId.server.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Promote no-explicit-any to error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00

280 lines
9.5 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
simulateBracket,
simulateBracketSync,
getExpectedCounts,
type TeamForSimulation,
type ProbabilityDistribution,
} from '../bracket-simulator';
describe('bracket-simulator', () => {
describe('simulateBracketSync', () => {
it('simulates 8-team bracket with equal ratings', () => {
const teams: TeamForSimulation[] = [
{ participantId: '1', elo: 1500 },
{ participantId: '2', elo: 1500 },
{ participantId: '3', elo: 1500 },
{ participantId: '4', elo: 1500 },
{ participantId: '5', elo: 1500 },
{ participantId: '6', elo: 1500 },
{ participantId: '7', elo: 1500 },
{ participantId: '8', elo: 1500 },
];
const results = simulateBracketSync(teams, 'nhl-8', 10000);
expect(results.size).toBe(8);
// With equal ratings, each team should have roughly equal probability
results.forEach((distribution, _participantId) => {
// Sum of probabilities should be 1.0
const sum = distribution.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(1.0, 1);
// Each team should win championship ~12.5% of the time (1/8)
expect(distribution[0]).toBeGreaterThan(0.08);
expect(distribution[0]).toBeLessThan(0.17);
});
});
it('gives higher win probability to stronger team', () => {
const teams: TeamForSimulation[] = [
{ participantId: 'strong', elo: 1700 }, // Much stronger
{ participantId: '2', elo: 1500 },
{ participantId: '3', elo: 1500 },
{ participantId: '4', elo: 1500 },
{ participantId: '5', elo: 1500 },
{ participantId: '6', elo: 1500 },
{ participantId: '7', elo: 1500 },
{ participantId: 'weak', elo: 1300 }, // Much weaker
];
const results = simulateBracketSync(teams, 'nhl-8', 10000);
const strongDist = results.get('strong')!;
const weakDist = results.get('weak')!;
// Strong team should have higher championship probability
expect(strongDist[0]).toBeGreaterThan(weakDist[0]);
expect(strongDist[0]).toBeGreaterThan(0.2); // Should win >20% of the time
// Weak team should have lower championship probability
expect(weakDist[0]).toBeLessThan(0.1); // Should win <10% of the time
});
it('handles extreme Elo differences', () => {
const teams: TeamForSimulation[] = [
{ participantId: 'champion', elo: 1900 }, // Dominant
{ participantId: '2', elo: 1400 },
{ participantId: '3', elo: 1400 },
{ participantId: '4', elo: 1400 },
{ participantId: '5', elo: 1400 },
{ participantId: '6', elo: 1400 },
{ participantId: '7', elo: 1400 },
{ participantId: '8', elo: 1400 },
];
const results = simulateBracketSync(teams, 'nhl-8', 10000);
const championDist = results.get('champion')!;
// Dominant team should win very often
expect(championDist[0]).toBeGreaterThan(0.6); // >60% championship probability
});
it('ensures probabilities sum to 1.0 for each team', () => {
const teams: TeamForSimulation[] = [
{ participantId: '1', elo: 1650 },
{ participantId: '2', elo: 1600 },
{ participantId: '3', elo: 1550 },
{ participantId: '4', elo: 1500 },
{ participantId: '5', elo: 1450 },
{ participantId: '6', elo: 1400 },
{ participantId: '7', elo: 1350 },
{ participantId: '8', elo: 1300 },
];
const results = simulateBracketSync(teams, 'nhl-8', 5000);
results.forEach((distribution, _participantId) => {
const sum = distribution.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(1.0, 1);
});
});
it('throws error for wrong number of teams', () => {
const teams: TeamForSimulation[] = [
{ participantId: '1', elo: 1500 },
{ participantId: '2', elo: 1500 },
];
expect(() => {
simulateBracketSync(teams, 'nhl-8', 1000);
}).toThrow('requires exactly 8 teams');
});
it('throws error for invalid simulation count', () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500,
}));
expect(() => {
simulateBracketSync(teams, 'nhl-8', 0);
}).toThrow('must be positive');
expect(() => {
simulateBracketSync(teams, 'nhl-8', -100);
}).toThrow('must be positive');
});
it('produces consistent results with same random seed', () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500 + i * 50,
}));
// Run with small sample size for speed
const results1 = simulateBracketSync(teams, 'nhl-8', 1000);
const results2 = simulateBracketSync(teams, 'nhl-8', 1000);
// Results won't be identical due to randomness, but should be in same ballpark
const dist1 = results1.get('1')!;
const dist2 = results2.get('1')!;
// Championship probabilities should be within 10% of each other
expect(Math.abs(dist1[0] - dist2[0])).toBeLessThan(0.1);
});
it('returns valid probability distribution structure', () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500,
}));
const results = simulateBracketSync(teams, 'nhl-8', 1000);
results.forEach((distribution, _participantId) => {
// Should be array of 8 numbers
expect(distribution).toHaveLength(8);
// All probabilities should be between 0 and 1
distribution.forEach(prob => {
expect(prob).toBeGreaterThanOrEqual(0);
expect(prob).toBeLessThanOrEqual(1);
});
});
});
});
describe('simulateBracket (async)', () => {
it('simulates bracket asynchronously', async () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500,
}));
const results = await simulateBracket(teams, 'nhl-8', 5000);
expect(results.size).toBe(8);
results.forEach((distribution) => {
const sum = distribution.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(1.0, 1);
});
});
it('calls progress callback', async () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500,
}));
const progressCalls: Array<{ current: number; total: number }> = [];
await simulateBracket(teams, 'nhl-8', 25000, (current, total) => {
progressCalls.push({ current, total });
});
// Should have progress updates at 10k, 20k, and 25k
expect(progressCalls.length).toBeGreaterThan(0);
expect(progressCalls[progressCalls.length - 1].current).toBe(25000);
expect(progressCalls[progressCalls.length - 1].total).toBe(25000);
});
});
describe('getExpectedCounts', () => {
it('converts probabilities to expected counts', () => {
const distribution: ProbabilityDistribution = [
0.25, // 25% 1st
0.20, // 20% 2nd
0.15, // 15% 3rd
0.10, // 10% 4th
0.10, // 10% 5th
0.10, // 10% 6th
0.05, // 5% 7th
0.05, // 5% 8th
];
const counts = getExpectedCounts(distribution, 10000);
expect(counts[1]).toBe(2500);
expect(counts[2]).toBe(2000);
expect(counts[3]).toBe(1500);
expect(counts[4]).toBe(1000);
expect(counts[5]).toBe(1000);
expect(counts[6]).toBe(1000);
expect(counts[7]).toBe(500);
expect(counts[8]).toBe(500);
});
it('rounds to nearest integer', () => {
const distribution: ProbabilityDistribution = [
0.123, 0.123, 0.123, 0.123, 0.123, 0.123, 0.131, 0.131,
];
const counts = getExpectedCounts(distribution, 1000);
// Should round 123 and 131
expect(counts[1]).toBe(123);
expect(counts[7]).toBe(131);
});
});
describe('integration: realistic NHL scenario', () => {
it('simulates NHL playoff bracket with realistic Elo ratings', async () => {
// Based on plan: Colorado (1648), NY Islanders (1324), etc.
const teams: TeamForSimulation[] = [
{ participantId: 'COL', elo: 1648 }, // Colorado (strongest)
{ participantId: 'FLA', elo: 1620 }, // Florida
{ participantId: 'VGK', elo: 1615 }, // Vegas
{ participantId: 'TBL', elo: 1590 }, // Tampa Bay
{ participantId: 'NJD', elo: 1565 }, // New Jersey
{ participantId: 'TOR', elo: 1510 }, // Toronto
{ participantId: 'NYR', elo: 1470 }, // NY Rangers
{ participantId: 'NYI', elo: 1324 }, // NY Islanders (weakest)
];
const results = await simulateBracket(teams, 'nhl-8', 10000);
const colDist = results.get('COL')!;
const nyiDist = results.get('NYI')!;
// Colorado should have highest championship probability
expect(colDist[0]).toBeGreaterThan(0.15); // >15%
// NY Islanders should have lowest championship probability
expect(nyiDist[0]).toBeLessThan(0.08); // <8%
// Colorado should be more likely to win than NYI
expect(colDist[0]).toBeGreaterThan(nyiDist[0]);
// Probabilities should sum to 1
const colSum = colDist.reduce((acc, p) => acc + p, 0);
const nyiSum = nyiDist.reduce((acc, p) => acc + p, 0);
expect(colSum).toBeCloseTo(1.0, 1);
expect(nyiSum).toBeCloseTo(1.0, 1);
});
});
});