* 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>
280 lines
9.5 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|