brackt/app/services/__tests__/ev-calculator.test.ts

408 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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