import { describe, it, expect } from 'vitest'; import { calculateICM, calculateICMFromOdds, icmResultToArray, type ParticipantChips, } from '../icm-calculator'; describe('icm-calculator', () => { describe('calculateICM', () => { it('calculates probabilities for 8 equal participants', () => { const participants: ParticipantChips[] = Array.from({ length: 8 }, (_, i) => ({ participantId: String(i + 1), championshipProbability: 0.125, // Equal 12.5% each })); const results = calculateICM(participants); expect(results.size).toBe(8); // Each participant should have probabilities that sum to 1.0 results.forEach((result) => { const probs = icmResultToArray(result); const sum = probs.reduce((acc, p) => acc + p, 0); expect(sum).toBeCloseTo(1.0, 1); // With equal odds, probabilities vary by placement preference // But should all be reasonable (not 0, not 1) probs.forEach(p => { expect(p).toBeGreaterThan(0); expect(p).toBeLessThan(0.5); }); }); }); it('gives stronger team higher probabilities for better placements', () => { const participants: ParticipantChips[] = [ { participantId: 'strong', championshipProbability: 0.5 }, // 50% { participantId: 'weak', championshipProbability: 0.01 }, // 1% ...Array.from({ length: 6 }, (_, i) => ({ participantId: String(i + 3), championshipProbability: 0.0817, // ~8.17% each })), ]; const results = calculateICM(participants); const strongResult = results.get('strong'); const weakResult = results.get('weak'); if (!strongResult || !weakResult) throw new Error('Expected results not found'); const strongProbs = icmResultToArray(strongResult); const weakProbs = icmResultToArray(weakResult); // Strong team should have higher P(1st) than weak team expect(strongProbs[0]).toBeGreaterThan(weakProbs[0]); // Strong team should have higher P(2nd) than weak team expect(strongProbs[1]).toBeGreaterThan(weakProbs[1]); // Weak team should have higher probability of worse placements expect(weakProbs[7]).toBeGreaterThan(strongProbs[7]); }); it('handles 32 team NHL scenario', () => { // Simulate realistic NHL championship odds distribution const participants: ParticipantChips[] = [ { participantId: 'COL', championshipProbability: 0.154 }, // 15.4% favorite { participantId: 'FLA', championshipProbability: 0.111 }, // 11.1% { participantId: 'VGK', championshipProbability: 0.111 }, // 11.1% { participantId: 'TBL', championshipProbability: 0.091 }, // 9.1% { participantId: 'NJD', championshipProbability: 0.067 }, // 6.7% { participantId: 'TOR', championshipProbability: 0.038 }, // 3.8% { participantId: 'NYR', championshipProbability: 0.024 }, // 2.4% { participantId: 'DET', championshipProbability: 0.013 }, // 1.3% // 24 more teams with decreasing odds ...Array.from({ length: 24 }, (_, i) => ({ participantId: `TEAM${i + 9}`, championshipProbability: 0.013 / (i + 2), // Decreasing odds })), ]; const results = calculateICM(participants); expect(results.size).toBe(32); // Colorado (favorite) should have highest P(1st) const colResult32 = results.get('COL'); if (!colResult32) throw new Error('COL result not found'); const colProbs = icmResultToArray(colResult32); expect(colProbs[0]).toBeGreaterThan(0.05); // Should have >5% chance of 1st // Even the worst team should have some probability for all placements const worstResult = results.get('TEAM32'); if (!worstResult) throw new Error('TEAM32 result not found'); const worstProbs = icmResultToArray(worstResult); worstProbs.forEach(p => { expect(p).toBeGreaterThan(0); // Not zero expect(p).toBeLessThan(1); // Valid probability }); // Column sums should equal 1.0 (each position distributed across all teams) for (let place = 0; place < 8; place++) { let colSum = 0; results.forEach((result) => { const probs = icmResultToArray(result); colSum += probs[place]; }); expect(colSum).toBeCloseTo(1.0, 2); } }); it('handles edge case with single participant', () => { const participants: ParticipantChips[] = [ { participantId: '1', championshipProbability: 1.0 }, ]; const results = calculateICM(participants); expect(results.size).toBe(1); const singleResult = results.get('1'); if (!singleResult) throw new Error('Result for participant 1 not found'); const probs = icmResultToArray(singleResult); // With 1 participant, only 1st place is filled (removed from pool after) expect(probs[0]).toBeCloseTo(1.0, 1); // 100% chance of 1st const sum = probs.reduce((acc, p) => acc + p, 0); expect(sum).toBeCloseTo(1.0, 1); }); it('handles zero championship probabilities gracefully', () => { const participants: ParticipantChips[] = [ { participantId: '1', championshipProbability: 0 }, { participantId: '2', championshipProbability: 0 }, { participantId: '3', championshipProbability: 0 }, ]; const results = calculateICM(participants); expect(results.size).toBe(3); // Should give equal probabilities when all have zero odds // With 3 teams, only positions 0-2 are filled (each team removed after placement) results.forEach((result) => { const probs = icmResultToArray(result); // First 3 positions should each have ~1/3 for (let i = 0; i < 3; i++) { expect(probs[i]).toBeCloseTo(1/3, 1); } // Positions 3-7 are 0 (no participants left to fill them) for (let i = 3; i < 8; i++) { expect(probs[i]).toBe(0); } }); }); it('normalizes championship probabilities that do not sum to 1.0', () => { const participants: ParticipantChips[] = [ { participantId: '1', championshipProbability: 0.6 }, // 60% (with vig) { participantId: '2', championshipProbability: 0.55 }, // 55% (with vig) // Total > 1.0, should be normalized ]; const results = calculateICM(participants); expect(results.size).toBe(2); // With 2 participants, only positions 0-1 are filled // Verify those columns sum to 1.0 for (let place = 0; place < 2; place++) { let colSum = 0; results.forEach((result) => { const probs = icmResultToArray(result); colSum += probs[place]; }); expect(colSum).toBeCloseTo(1.0, 2); } // Participant 1 (higher prob) should have higher P(1st) const r1 = results.get('1'); const r2 = results.get('2'); if (!r1 || !r2) throw new Error('Results for participants 1 and 2 not found'); const probs1 = icmResultToArray(r1); const probs2 = icmResultToArray(r2); expect(probs1[0]).toBeGreaterThan(probs2[0]); }); it('returns empty map for empty input', () => { const results = calculateICM([]); expect(results.size).toBe(0); }); it('maintains probability ordering for sorted championship odds', () => { const participants: ParticipantChips[] = [ { participantId: '1st', championshipProbability: 0.40 }, { participantId: '2nd', championshipProbability: 0.30 }, { participantId: '3rd', championshipProbability: 0.20 }, { participantId: '4th', championshipProbability: 0.10 }, ]; const results = calculateICM(participants); const rFirst = results.get('1st'); const rSecond = results.get('2nd'); const rThird = results.get('3rd'); const rFourth = results.get('4th'); if (!rFirst || !rSecond || !rThird || !rFourth) throw new Error('Expected results not found'); const first = icmResultToArray(rFirst); const second = icmResultToArray(rSecond); const third = icmResultToArray(rThird); const fourth = icmResultToArray(rFourth); // P(1st place) should be ordered expect(first[0]).toBeGreaterThan(second[0]); expect(second[0]).toBeGreaterThan(third[0]); expect(third[0]).toBeGreaterThan(fourth[0]); }); }); describe('calculateICMFromOdds', () => { it('converts American odds to ICM probabilities', () => { const odds = [ { participantId: 'COL', odds: 550 }, // +550 { participantId: 'FLA', odds: 800 }, // +800 { participantId: 'ARI', odds: 100000 }, // +100000 (longshot) ]; const results = calculateICMFromOdds(odds); expect(results.size).toBe(3); // Colorado should have better odds than Arizona const colResultOdds = results.get('COL'); const ariResult = results.get('ARI'); if (!colResultOdds || !ariResult) throw new Error('COL or ARI result not found'); const colProbs = icmResultToArray(colResultOdds); const ariProbs = icmResultToArray(ariResult); expect(colProbs[0]).toBeGreaterThan(ariProbs[0]); // Even Arizona should have some probability expect(ariProbs[0]).toBeGreaterThan(0); }); it('handles negative odds (favorites)', () => { const odds = [ { participantId: 'FAV', odds: -200 }, // Favorite { participantId: 'DOG', odds: 500 }, // Underdog ]; const results = calculateICMFromOdds(odds); const favResult = results.get('FAV'); const dogResult = results.get('DOG'); if (!favResult || !dogResult) throw new Error('FAV or DOG result not found'); const favProbs = icmResultToArray(favResult); const dogProbs = icmResultToArray(dogResult); // Favorite should have higher P(1st) expect(favProbs[0]).toBeGreaterThan(dogProbs[0]); }); it('uses custom scoring places', () => { const odds = [ { participantId: '1', odds: 200 }, { participantId: '2', odds: 300 }, { participantId: '3', odds: 400 }, ]; const results = calculateICMFromOdds(odds, 5); // 5 scoring places results.forEach((result) => { const probs = icmResultToArray(result); // Should still have 8 values but calculated for 5 places expect(probs).toHaveLength(8); }); }); }); describe('icmResultToArray', () => { it('converts ICM result to array format', () => { const icmResult = { participantId: 'TEST', probabilities: { first: 0.25, second: 0.20, third: 0.15, fourth: 0.12, fifth: 0.10, sixth: 0.08, seventh: 0.06, eighth: 0.04, }, }; const array = icmResultToArray(icmResult); expect(array).toEqual([0.25, 0.20, 0.15, 0.12, 0.10, 0.08, 0.06, 0.04]); expect(array).toHaveLength(8); }); }); describe('integration: realistic NHL 32-team scenario', () => { it('calculates reasonable probabilities for full NHL league', () => { // Full 32-team NHL with realistic odds distribution const odds = [ { participantId: 'COL', odds: 550 }, { participantId: 'FLA', odds: 800 }, { participantId: 'VGK', odds: 800 }, { participantId: 'TBL', odds: 1000 }, { participantId: 'NJD', odds: 1400 }, { participantId: 'TOR', odds: 2500 }, { participantId: 'NYR', odds: 4000 }, { participantId: 'DET', odds: 7500 }, { participantId: 'VAN', odds: 7500 }, { participantId: 'NYI', odds: 15000 }, { participantId: 'NSH', odds: 40000 }, // 21 more teams with increasing odds ...Array.from({ length: 21 }, (_, i) => ({ participantId: `TEAM${i + 12}`, odds: 40000 + (i + 1) * 5000, // Increasing odds })), ]; const results = calculateICMFromOdds(odds); expect(results.size).toBe(32); // Favorite (Colorado) should have reasonable championship probability const colResultInteg = results.get('COL'); const detResult = results.get('DET'); const longResult = results.get('TEAM32'); if (!colResultInteg || !detResult || !longResult) throw new Error('Expected results not found'); const colProbs = icmResultToArray(colResultInteg); expect(colProbs[0]).toBeGreaterThan(0.05); // >5% for 1st expect(colProbs[0]).toBeLessThan(0.35); // <35% for 1st (not guaranteed) // Middle team (Detroit) should have middling probabilities const detProbs = icmResultToArray(detResult); expect(detProbs[0]).toBeGreaterThan(0); // Some chance expect(detProbs[0]).toBeLessThan(0.10); // But not high // Longshot should have very small but non-zero probabilities const longProbs = icmResultToArray(longResult); expect(longProbs[0]).toBeGreaterThan(0); // Not impossible expect(longProbs[0]).toBeLessThan(0.05); // But unlikely (with 32 teams, even worst has ~3% uniform) // Column sums should equal 1.0 (each position distributed across all teams) for (let place = 0; place < 8; place++) { let colSum = 0; results.forEach((result) => { const probs = icmResultToArray(result); colSum += probs[place]; }); expect(colSum).toBeCloseTo(1.0, 2); } }); }); });