408 lines
11 KiB
TypeScript
408 lines
11 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import {
|
||
calculateEV,
|
||
validateProbabilities,
|
||
normalizeProbabilities,
|
||
calculateProjectedTotal,
|
||
calculateReplacementLevel,
|
||
calculateVORP,
|
||
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
|
||
});
|
||
});
|
||
|
||
describe("calculateReplacementLevel", () => {
|
||
it("returns average EV of 12th-14th players (indices 11-13) when 14+ players exist", () => {
|
||
const evs = Array.from({ length: 16 }, (_, i) => 100 - i * 10);
|
||
// evs = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0, -10, -20, -30, -40, -50]
|
||
// indices 11-13 = -10, -20, -30 → avg = -20
|
||
expect(calculateReplacementLevel(evs)).toBeCloseTo(-20);
|
||
});
|
||
|
||
it("returns average EV of 12th-14th when exactly 14 players", () => {
|
||
const evs = Array.from({ length: 14 }, (_, i) => 100 - i * 5);
|
||
// evs[11] = 45, evs[12] = 40, evs[13] = 35 → avg = 40
|
||
expect(calculateReplacementLevel(evs)).toBeCloseTo(40);
|
||
});
|
||
|
||
it("uses available players starting at index 11 when fewer than 14", () => {
|
||
const evs = [50, 40, 30, 20, 15, 12, 10, 8, 6, 4, 2, 1]; // 12 players
|
||
// Only index 11: evs[11] = 1 → avg = 1
|
||
expect(calculateReplacementLevel(evs)).toBeCloseTo(1);
|
||
});
|
||
|
||
it("uses last player when fewer than 12 players", () => {
|
||
const evs = [50, 40, 30, 20, 10]; // 5 players
|
||
// Use last player: evs[4] = 10
|
||
expect(calculateReplacementLevel(evs)).toBeCloseTo(10);
|
||
});
|
||
|
||
it("returns 0 for empty array", () => {
|
||
expect(calculateReplacementLevel([])).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe("calculateVORP", () => {
|
||
it("returns ev minus replacement level", () => {
|
||
expect(calculateVORP(75, 40)).toBeCloseTo(35);
|
||
});
|
||
|
||
it("returns negative value when ev is below replacement level", () => {
|
||
expect(calculateVORP(20, 40)).toBeCloseTo(-20);
|
||
});
|
||
|
||
it("returns 0 when ev equals replacement level", () => {
|
||
expect(calculateVORP(40, 40)).toBe(0);
|
||
});
|
||
});
|