import { describe, it, expect } from "vitest"; import { calculateEV, validateProbabilities, normalizeProbabilities, calculateProjectedTotal, type ScoringRules, type ProbabilityDistribution, } from "../ev-calculator"; describe("calculateEV", () => { const defaultScoring: ScoringRules = { pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, }; it("should calculate EV for equal probabilities", () => { const probabilities: ProbabilityDistribution = { probFirst: 0.125, probSecond: 0.125, probThird: 0.125, probFourth: 0.125, probFifth: 0.125, probSixth: 0.125, probSeventh: 0.125, probEighth: 0.125, }; const ev = calculateEV(probabilities, defaultScoring); // EV = 0.125 × (100+70+50+40+25+25+15+15) = 0.125 × 340 = 42.5 expect(ev).toBe(42.5); }); it("should calculate EV for favorite (high probability of 1st)", () => { const probabilities: ProbabilityDistribution = { probFirst: 0.50, probSecond: 0.30, probThird: 0.10, probFourth: 0.05, probFifth: 0.03, probSixth: 0.01, probSeventh: 0.005, probEighth: 0.005, }; const ev = calculateEV(probabilities, defaultScoring); // EV = 0.50×100 + 0.30×70 + 0.10×50 + 0.05×40 + 0.03×25 + 0.01×25 + 0.005×15 + 0.005×15 // = 50 + 21 + 5 + 2 + 0.75 + 0.25 + 0.075 + 0.075 = 79.15 expect(ev).toBe(79.15); }); it("should calculate EV for underdog (low probability of 1st)", () => { const probabilities: ProbabilityDistribution = { probFirst: 0.02, probSecond: 0.05, probThird: 0.08, probFourth: 0.10, probFifth: 0.15, probSixth: 0.20, probSeventh: 0.20, probEighth: 0.20, }; const ev = calculateEV(probabilities, defaultScoring); // EV = 0.02×100 + 0.05×70 + 0.08×50 + 0.10×40 + 0.15×25 + 0.20×25 + 0.20×15 + 0.20×15 // = 2 + 3.5 + 4 + 4 + 3.75 + 5 + 3 + 3 = 28.25 expect(ev).toBe(28.25); }); it("should calculate EV with custom scoring rules", () => { const customScoring: ScoringRules = { pointsFor1st: 200, pointsFor2nd: 150, pointsFor3rd: 100, pointsFor4th: 80, pointsFor5th: 50, pointsFor6th: 50, pointsFor7th: 30, pointsFor8th: 30, }; const probabilities: ProbabilityDistribution = { probFirst: 0.20, probSecond: 0.20, probThird: 0.15, probFourth: 0.15, probFifth: 0.10, probSixth: 0.10, probSeventh: 0.05, probEighth: 0.05, }; const ev = calculateEV(probabilities, customScoring); // EV = 0.20×200 + 0.20×150 + 0.15×100 + 0.15×80 + 0.10×50 + 0.10×50 + 0.05×30 + 0.05×30 // = 40 + 30 + 15 + 12 + 5 + 5 + 1.5 + 1.5 = 110 expect(ev).toBe(110); }); it("should handle 100% probability of one placement (finished participant)", () => { const probabilities: ProbabilityDistribution = { probFirst: 1.0, probSecond: 0, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, }; const ev = calculateEV(probabilities, defaultScoring); expect(ev).toBe(100); // 1.0 × 100 = 100 }); it("should handle probabilities with decimal places", () => { const probabilities: ProbabilityDistribution = { probFirst: 0.1575, probSecond: 0.1425, probThird: 0.1350, probFourth: 0.1275, probFifth: 0.1100, probSixth: 0.1050, probSeventh: 0.1125, probEighth: 0.1100, }; const ev = calculateEV(probabilities, defaultScoring); // EV = 0.1575×100 + 0.1425×70 + ... = 46.29 expect(ev).toBeCloseTo(46.29, 2); }); it("should return 0 when all probabilities are 0", () => { const probabilities: ProbabilityDistribution = { probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, }; const ev = calculateEV(probabilities, defaultScoring); expect(ev).toBe(0); }); }); describe("validateProbabilities", () => { it("should validate probabilities that sum to 1.0", () => { const valid: ProbabilityDistribution = { probFirst: 0.20, probSecond: 0.20, probThird: 0.15, probFourth: 0.15, probFifth: 0.10, probSixth: 0.10, probSeventh: 0.05, probEighth: 0.05, }; expect(validateProbabilities(valid)).toBe(true); }); it("should accept probabilities within tolerance (default 0.01)", () => { const nearlyValid: ProbabilityDistribution = { probFirst: 0.2005, probSecond: 0.20, probThird: 0.15, probFourth: 0.15, probFifth: 0.10, probSixth: 0.10, probSeventh: 0.05, probEighth: 0.0495, }; expect(validateProbabilities(nearlyValid)).toBe(true); }); it("should reject probabilities that sum too high", () => { const tooHigh: ProbabilityDistribution = { probFirst: 0.20, probSecond: 0.20, probThird: 0.20, probFourth: 0.20, probFifth: 0.10, probSixth: 0.10, probSeventh: 0.05, probEighth: 0.05, }; expect(validateProbabilities(tooHigh)).toBe(false); }); it("should reject probabilities that sum too low", () => { const tooLow: ProbabilityDistribution = { probFirst: 0.10, probSecond: 0.10, probThird: 0.10, probFourth: 0.10, probFifth: 0.10, probSixth: 0.10, probSeventh: 0.05, probEighth: 0.05, }; expect(validateProbabilities(tooLow)).toBe(false); }); it("should allow custom tolerance", () => { const probabilities: ProbabilityDistribution = { probFirst: 0.21, probSecond: 0.20, probThird: 0.15, probFourth: 0.15, probFifth: 0.10, probSixth: 0.10, probSeventh: 0.05, probEighth: 0.04, }; // Sum is 1.0, within any tolerance expect(validateProbabilities(probabilities, 0.01)).toBe(true); expect(validateProbabilities(probabilities, 0.001)).toBe(true); }); }); describe("normalizeProbabilities", () => { it("should normalize probabilities that sum to more than 1.0", () => { const input: ProbabilityDistribution = { probFirst: 0.22, probSecond: 0.22, probThird: 0.17, probFourth: 0.17, probFifth: 0.11, probSixth: 0.11, probSeventh: 0.055, probEighth: 0.055, }; // Sum = 1.11 const normalized = normalizeProbabilities(input); // Each should be scaled down by 1/1.11 expect(normalized.probFirst).toBeCloseTo(0.1982, 3); expect(normalized.probSecond).toBeCloseTo(0.1982, 3); // Sum should be exactly 1.0 (within rounding) const sum = Object.values(normalized).reduce((a, b) => a + b, 0); expect(sum).toBeCloseTo(1.0, 3); }); it("should normalize probabilities that sum to less than 1.0", () => { const input: ProbabilityDistribution = { probFirst: 0.18, probSecond: 0.18, probThird: 0.135, probFourth: 0.135, probFifth: 0.09, probSixth: 0.09, probSeventh: 0.045, probEighth: 0.045, }; // Sum = 0.90 const normalized = normalizeProbabilities(input); // Each should be scaled up by 1/0.90 expect(normalized.probFirst).toBeCloseTo(0.20, 2); expect(normalized.probSecond).toBeCloseTo(0.20, 2); const sum = Object.values(normalized).reduce((a, b) => a + b, 0); expect(sum).toBeCloseTo(1.0, 3); }); it("should handle probabilities that already sum to 1.0", () => { const input: ProbabilityDistribution = { probFirst: 0.20, probSecond: 0.20, probThird: 0.15, probFourth: 0.15, probFifth: 0.10, probSixth: 0.10, probSeventh: 0.05, probEighth: 0.05, }; const normalized = normalizeProbabilities(input); // Should remain essentially unchanged expect(normalized.probFirst).toBeCloseTo(0.20, 3); expect(normalized.probSecond).toBeCloseTo(0.20, 3); }); it("should handle all zeros by returning equal probabilities", () => { const input: ProbabilityDistribution = { probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, }; const normalized = normalizeProbabilities(input); // Should return 0.125 for each (equal distribution) expect(normalized.probFirst).toBe(0.125); expect(normalized.probSecond).toBe(0.125); expect(normalized.probEighth).toBe(0.125); }); }); describe("calculateProjectedTotal", () => { it("should calculate projected total with multiple unfinished participants", () => { const result = calculateProjectedTotal( 150, // actual points from finished [45.5, 30.2, 25.8, 20.1] // EVs of unfinished participants ); expect(result.actualPoints).toBe(150); expect(result.projectedPoints).toBe(271.6); // 150 + 121.6 expect(result.participantsRemaining).toBe(4); }); it("should handle no remaining participants", () => { const result = calculateProjectedTotal(250, []); expect(result.actualPoints).toBe(250); expect(result.projectedPoints).toBe(250); // Same as actual expect(result.participantsRemaining).toBe(0); }); it("should handle zero actual points", () => { const result = calculateProjectedTotal(0, [50, 40, 30]); expect(result.actualPoints).toBe(0); expect(result.projectedPoints).toBe(120); expect(result.participantsRemaining).toBe(3); }); it("should round to 2 decimal places", () => { const result = calculateProjectedTotal(100.123, [25.456, 30.789]); expect(result.actualPoints).toBe(100.12); expect(result.projectedPoints).toBe(156.37); // Rounded }); });