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); }); }); });