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: 12.5, probSecond: 12.5, probThird: 12.5, probFourth: 12.5, probFifth: 12.5, probSixth: 12.5, probSeventh: 12.5, probEighth: 12.5, }; const ev = calculateEV(probabilities, defaultScoring); // EV = 12.5% × (100+70+50+40+25+25+15+15) = 12.5% × 340 = 42.5 expect(ev).toBe(42.5); }); it("should calculate EV for favorite (high probability of 1st)", () => { const probabilities: ProbabilityDistribution = { probFirst: 50, probSecond: 30, probThird: 10, probFourth: 5, probFifth: 3, probSixth: 1, probSeventh: 0.5, probEighth: 0.5, }; const ev = calculateEV(probabilities, defaultScoring); // EV = 50% × 100 + 30% × 70 + 10% × 50 + 5% × 40 + 3% × 25 + 1% × 25 + 0.5% × 15 + 0.5% × 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: 2, probSecond: 5, probThird: 8, probFourth: 10, probFifth: 15, probSixth: 20, probSeventh: 20, probEighth: 20, }; const ev = calculateEV(probabilities, defaultScoring); // EV = 2% × 100 + 5% × 70 + 8% × 50 + 10% × 40 + 15% × 25 + 20% × 25 + 20% × 15 + 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: 20, probSecond: 20, probThird: 15, probFourth: 15, probFifth: 10, probSixth: 10, probSeventh: 5, probEighth: 5, }; const ev = calculateEV(probabilities, customScoring); // EV = 20% × 200 + 20% × 150 + 15% × 100 + 15% × 80 + 10% × 50 + 10% × 50 + 5% × 30 + 5% × 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: 100, probSecond: 0, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0, }; const ev = calculateEV(probabilities, defaultScoring); expect(ev).toBe(100); // 100% × 100 = 100 }); it("should handle probabilities with decimal places", () => { const probabilities: ProbabilityDistribution = { probFirst: 15.75, probSecond: 14.25, probThird: 13.50, probFourth: 12.75, probFifth: 11.00, probSixth: 10.50, probSeventh: 11.25, probEighth: 11.00, }; const ev = calculateEV(probabilities, defaultScoring); // EV = 15.75% × 100 + 14.25% × 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 100", () => { const valid: ProbabilityDistribution = { probFirst: 20, probSecond: 20, probThird: 15, probFourth: 15, probFifth: 10, probSixth: 10, probSeventh: 5, probEighth: 5, }; expect(validateProbabilities(valid)).toBe(true); }); it("should accept probabilities within tolerance (default 0.1%)", () => { const nearlyValid: ProbabilityDistribution = { probFirst: 20.05, probSecond: 20, probThird: 15, probFourth: 15, probFifth: 10, probSixth: 10, probSeventh: 5, probEighth: 4.95, }; expect(validateProbabilities(nearlyValid)).toBe(true); }); it("should reject probabilities that sum too high", () => { const tooHigh: ProbabilityDistribution = { probFirst: 20, probSecond: 20, probThird: 20, probFourth: 20, probFifth: 10, probSixth: 10, probSeventh: 5, probEighth: 5, }; expect(validateProbabilities(tooHigh)).toBe(false); }); it("should reject probabilities that sum too low", () => { const tooLow: ProbabilityDistribution = { probFirst: 10, probSecond: 10, probThird: 10, probFourth: 10, probFifth: 10, probSixth: 10, probSeventh: 5, probEighth: 5, }; expect(validateProbabilities(tooLow)).toBe(false); }); it("should allow custom tolerance", () => { const probabilities: ProbabilityDistribution = { probFirst: 21, probSecond: 20, probThird: 15, probFourth: 15, probFifth: 10, probSixth: 10, probSeventh: 5, probEighth: 4, }; // Sum is 100, but with 1% tolerance expect(validateProbabilities(probabilities, 1)).toBe(true); // With stricter 0.1% tolerance expect(validateProbabilities(probabilities, 0.1)).toBe(true); }); }); describe("normalizeProbabilities", () => { it("should normalize probabilities that sum to more than 100", () => { const input: ProbabilityDistribution = { probFirst: 22, probSecond: 22, probThird: 17, probFourth: 17, probFifth: 11, probSixth: 11, probSeventh: 5.5, probEighth: 5.5, }; // Sum = 111 const normalized = normalizeProbabilities(input); // Each should be scaled down by 100/111 expect(normalized.probFirst).toBeCloseTo(19.82, 2); expect(normalized.probSecond).toBeCloseTo(19.82, 2); // Sum should be exactly 100 (within rounding) const sum = Object.values(normalized).reduce((a, b) => a + b, 0); expect(sum).toBeCloseTo(100, 1); }); it("should normalize probabilities that sum to less than 100", () => { const input: ProbabilityDistribution = { probFirst: 18, probSecond: 18, probThird: 13.5, probFourth: 13.5, probFifth: 9, probSixth: 9, probSeventh: 4.5, probEighth: 4.5, }; // Sum = 90 const normalized = normalizeProbabilities(input); // Each should be scaled up by 100/90 expect(normalized.probFirst).toBeCloseTo(20, 1); expect(normalized.probSecond).toBeCloseTo(20, 1); const sum = Object.values(normalized).reduce((a, b) => a + b, 0); expect(sum).toBeCloseTo(100, 1); }); it("should handle probabilities that already sum to 100", () => { const input: ProbabilityDistribution = { probFirst: 20, probSecond: 20, probThird: 15, probFourth: 15, probFifth: 10, probSixth: 10, probSeventh: 5, probEighth: 5, }; const normalized = normalizeProbabilities(input); // Should remain essentially unchanged expect(normalized.probFirst).toBeCloseTo(20, 1); expect(normalized.probSecond).toBeCloseTo(20, 1); }); 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 12.5% for each (equal distribution) expect(normalized.probFirst).toBe(12.5); expect(normalized.probSecond).toBe(12.5); expect(normalized.probEighth).toBe(12.5); }); }); 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 }); });