363 lines
9.6 KiB
TypeScript
363 lines
9.6 KiB
TypeScript
|
|
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
|
|||
|
|
});
|
|||
|
|
});
|