brackt/app/services/__tests__/ev-calculator.test.ts
Chris Parsons 96b4c5a350 fix: align ev-calculator tests with decimal (0-1) probability format
The implementation and rest of the system (ICM calculator, probability
updater) use decimals (0-1) for probabilities, but the tests were using
percentages (0-100). Updated tests and docs to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:26:32 -08:00

361 lines
9.7 KiB
TypeScript
Raw 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,
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
});
});