282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
|
|
import { describe, it, expect } from "vitest";
|
|||
|
|
import { eloWinProb, buildDraw, simulateMajor } from "../tennis-simulator";
|
|||
|
|
|
|||
|
|
// ─── eloWinProb ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
describe("eloWinProb", () => {
|
|||
|
|
it("returns 0.5 for equal Elo", () => {
|
|||
|
|
expect(eloWinProb(1800, 1800)).toBeCloseTo(0.5, 5);
|
|||
|
|
expect(eloWinProb(1500, 1500)).toBeCloseTo(0.5, 5);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => {
|
|||
|
|
expect(eloWinProb(2000, 1600)).toBeGreaterThan(0.5);
|
|||
|
|
expect(eloWinProb(1600, 2000)).toBeLessThan(0.5);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("is symmetric: eloWinProb(a, b) + eloWinProb(b, a) = 1", () => {
|
|||
|
|
expect(eloWinProb(2000, 1600) + eloWinProb(1600, 2000)).toBeCloseTo(1.0, 5);
|
|||
|
|
expect(eloWinProb(1837, 1756) + eloWinProb(1756, 1837)).toBeCloseTo(1.0, 5);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("returns value in (0, 1)", () => {
|
|||
|
|
expect(eloWinProb(3000, 1000)).toBeLessThan(1);
|
|||
|
|
expect(eloWinProb(3000, 1000)).toBeGreaterThan(0);
|
|||
|
|
expect(eloWinProb(1000, 3000)).toBeLessThan(1);
|
|||
|
|
expect(eloWinProb(1000, 3000)).toBeGreaterThan(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("larger Elo gap → higher win probability (monotonic)", () => {
|
|||
|
|
const p200 = eloWinProb(1900, 1700);
|
|||
|
|
const p400 = eloWinProb(2100, 1700);
|
|||
|
|
expect(p400).toBeGreaterThan(p200);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("400-point gap gives ~91% win probability (standard Elo)", () => {
|
|||
|
|
expect(eloWinProb(2200, 1800)).toBeCloseTo(0.909, 2);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── buildDraw ────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
function makeIds(n: number): string[] {
|
|||
|
|
return Array.from({ length: n }, (_, i) => `p${String(i + 1).padStart(3, "0")}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function makeEloMap(
|
|||
|
|
ids: string[],
|
|||
|
|
elos: number[],
|
|||
|
|
rankings?: (number | null)[]
|
|||
|
|
): Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }> {
|
|||
|
|
return new Map(ids.map((id, i) => [id, {
|
|||
|
|
worldRanking: rankings ? (rankings[i] ?? null) : i + 1, // default: rank by index (1-indexed)
|
|||
|
|
eloHard: elos[i],
|
|||
|
|
eloClay: elos[i],
|
|||
|
|
eloGrass: elos[i],
|
|||
|
|
}]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
describe("buildDraw", () => {
|
|||
|
|
const IDS = makeIds(128);
|
|||
|
|
|
|||
|
|
it("returns exactly 128 slots", () => {
|
|||
|
|
const eloMap = makeEloMap(IDS, IDS.map((_, i) => 2000 - i));
|
|||
|
|
expect(buildDraw(IDS, eloMap)).toHaveLength(128);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("contains every participant exactly once", () => {
|
|||
|
|
const eloMap = makeEloMap(IDS, IDS.map((_, i) => 2000 - i));
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
expect(new Set(draw).size).toBe(128);
|
|||
|
|
for (const id of IDS) {
|
|||
|
|
expect(draw).toContain(id);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("places seed 1 (highest Elo) in slot 0", () => {
|
|||
|
|
const elos = IDS.map((_, i) => 2000 - i); // descending, p001 is best
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
expect(draw[0]).toBe("p001"); // seed 1 always goes to slot 0
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("places seed 2 (second-highest Elo) in slot 64", () => {
|
|||
|
|
const elos = IDS.map((_, i) => 2000 - i);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
expect(draw[64]).toBe("p002"); // seed 2 always goes to slot 64
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("seeds 1 and 2 are in opposite halves (slots 0–63 and 64–127)", () => {
|
|||
|
|
const elos = IDS.map((_, i) => 2000 - i);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
// Run multiple times to confirm it's not random
|
|||
|
|
for (let i = 0; i < 10; i++) {
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const seed1Slot = draw.indexOf("p001");
|
|||
|
|
const seed2Slot = draw.indexOf("p002");
|
|||
|
|
const seed1InTopHalf = seed1Slot < 64;
|
|||
|
|
const seed2InTopHalf = seed2Slot < 64;
|
|||
|
|
expect(seed1InTopHalf).not.toBe(seed2InTopHalf);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("seeds by worldRanking regardless of surface (ATP/WTA ranking used for all majors)", () => {
|
|||
|
|
// p001 = world #2, p002 = world #1 — p002 should always be seed 1 regardless of surface Elo
|
|||
|
|
const eloMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
|
|||
|
|
eloMap.set("p001", { worldRanking: 2, eloHard: 2500, eloClay: 2500, eloGrass: 2500 }); // world #2 (high Elo)
|
|||
|
|
eloMap.set("p002", { worldRanking: 1, eloHard: 1400, eloClay: 1400, eloGrass: 1400 }); // world #1 (low Elo)
|
|||
|
|
for (let i = 2; i < 128; i++) {
|
|||
|
|
eloMap.set(IDS[i], { worldRanking: i + 1, eloHard: 1500, eloClay: 1500, eloGrass: 1500 });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// p002 (world #1) should always be seed 1 → slot 0, regardless of Elo
|
|||
|
|
expect(buildDraw(IDS, eloMap)[0]).toBe("p002");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("falls back to Elo 1500 for players not in the Elo map", () => {
|
|||
|
|
// Create a map with only 1 entry; remaining 127 get fallback 1500
|
|||
|
|
const partialMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
|
|||
|
|
partialMap.set("p001", { worldRanking: 1, eloHard: 2000, eloClay: null, eloGrass: null });
|
|||
|
|
|
|||
|
|
// Should not throw
|
|||
|
|
expect(() => buildDraw(IDS, partialMap)).not.toThrow();
|
|||
|
|
const draw = buildDraw(IDS, partialMap);
|
|||
|
|
expect(draw).toHaveLength(128);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── simulateMajor ────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
describe("simulateMajor", () => {
|
|||
|
|
const IDS = makeIds(128);
|
|||
|
|
|
|||
|
|
it("returns one QP result per participant in the draw", () => {
|
|||
|
|
const elos = IDS.map(() => 1500);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const results = simulateMajor(draw, eloMap, "hard");
|
|||
|
|
expect(results).toHaveLength(128);
|
|||
|
|
const ids = results.map((r) => r.participantId);
|
|||
|
|
expect(new Set(ids).size).toBe(128);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("QP values are non-negative", () => {
|
|||
|
|
const elos = IDS.map(() => 1500);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const results = simulateMajor(draw, eloMap, "hard");
|
|||
|
|
for (const r of results) {
|
|||
|
|
expect(r.qp).toBeGreaterThanOrEqual(0);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("exactly one winner with 20 QP, one finalist with 14 QP", () => {
|
|||
|
|
const elos = IDS.map(() => 1500);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const results = simulateMajor(draw, eloMap, "hard");
|
|||
|
|
const winners = results.filter((r) => r.qp === 20);
|
|||
|
|
const finalists = results.filter((r) => r.qp === 14);
|
|||
|
|
expect(winners).toHaveLength(1);
|
|||
|
|
expect(finalists).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("exactly two SF losers with 9 QP", () => {
|
|||
|
|
const elos = IDS.map(() => 1500);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const results = simulateMajor(draw, eloMap, "hard");
|
|||
|
|
expect(results.filter((r) => r.qp === 9)).toHaveLength(2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("exactly four QF losers with 4 QP", () => {
|
|||
|
|
const elos = IDS.map(() => 1500);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const results = simulateMajor(draw, eloMap, "hard");
|
|||
|
|
expect(results.filter((r) => r.qp === 4)).toHaveLength(4);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("exactly eight R16 losers with 1.5 QP", () => {
|
|||
|
|
const elos = IDS.map(() => 1500);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const results = simulateMajor(draw, eloMap, "hard");
|
|||
|
|
expect(results.filter((r) => r.qp === 1.5)).toHaveLength(8);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("remaining 112 players (R1–R3 losers) earn 0 QP", () => {
|
|||
|
|
const elos = IDS.map(() => 1500);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const results = simulateMajor(draw, eloMap, "hard");
|
|||
|
|
expect(results.filter((r) => r.qp === 0)).toHaveLength(112);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("with a dominant player (Elo gap 1000+), they win the title almost always", () => {
|
|||
|
|
// Give p001 an overwhelming Elo advantage
|
|||
|
|
const dominantEloMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
|
|||
|
|
dominantEloMap.set("p001", { worldRanking: 1, eloHard: 5000, eloClay: 5000, eloGrass: 5000 });
|
|||
|
|
for (let i = 1; i < IDS.length; i++) {
|
|||
|
|
dominantEloMap.set(IDS[i], { worldRanking: i + 1, eloHard: 1000, eloClay: 1000, eloGrass: 1000 });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let wins = 0;
|
|||
|
|
const TRIALS = 200;
|
|||
|
|
for (let i = 0; i < TRIALS; i++) {
|
|||
|
|
const draw = buildDraw(IDS, dominantEloMap);
|
|||
|
|
const results = simulateMajor(draw, dominantEloMap, "hard");
|
|||
|
|
const p001 = results.find((r) => r.participantId === "p001");
|
|||
|
|
if (p001?.qp === 20) wins++;
|
|||
|
|
}
|
|||
|
|
// With Elo 5000 vs 1000, win probability per match ≈ 99.99% → should win almost all trials
|
|||
|
|
expect(wins).toBeGreaterThan(190);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("uses the correct surface Elo for match win probability", () => {
|
|||
|
|
// p001 dominates on clay but is average on hard; p002 is the opposite.
|
|||
|
|
// With a 2000-point Elo gap, the dominant player wins every match.
|
|||
|
|
const surfaceMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
|
|||
|
|
surfaceMap.set("p001", { worldRanking: 1, eloHard: 1500, eloClay: 3500, eloGrass: 1500 });
|
|||
|
|
surfaceMap.set("p002", { worldRanking: 2, eloHard: 3500, eloClay: 1500, eloGrass: 1500 });
|
|||
|
|
for (let i = 2; i < 128; i++) {
|
|||
|
|
surfaceMap.set(IDS[i], { worldRanking: i + 1, eloHard: 1000, eloClay: 1000, eloGrass: 1000 });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let p001ClayWins = 0;
|
|||
|
|
let p002HardWins = 0;
|
|||
|
|
const TRIALS = 50;
|
|||
|
|
for (let i = 0; i < TRIALS; i++) {
|
|||
|
|
const draw = buildDraw(IDS, surfaceMap);
|
|||
|
|
const clayResults = simulateMajor(draw, surfaceMap, "clay");
|
|||
|
|
const hardResults = simulateMajor(draw, surfaceMap, "hard");
|
|||
|
|
if (clayResults.find((r) => r.participantId === "p001")?.qp === 20) p001ClayWins++;
|
|||
|
|
if (hardResults.find((r) => r.participantId === "p002")?.qp === 20) p002HardWins++;
|
|||
|
|
}
|
|||
|
|
// With Elo 3500 vs ≤1500, each player should win essentially every trial on their surface
|
|||
|
|
expect(p001ClayWins).toBeGreaterThan(45); // p001 dominates clay
|
|||
|
|
expect(p002HardWins).toBeGreaterThan(45); // p002 dominates hard
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("total QP distributed per major: 20+14+2*9+4*4+8*1.5 = 80 QP", () => {
|
|||
|
|
const elos = IDS.map(() => 1500);
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const results = simulateMajor(draw, eloMap, "hard");
|
|||
|
|
const total = results.reduce((sum, r) => sum + r.qp, 0);
|
|||
|
|
// 20 + 14 + 9*2 + 4*4 + 1.5*8 = 20 + 14 + 18 + 16 + 12 = 80
|
|||
|
|
expect(total).toBeCloseTo(80, 5);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── TennisSimulator column-sum property ──────────────────────────────────────
|
|||
|
|
// Full integration test via the simulate() method would require DB mocking.
|
|||
|
|
// The column-sum guarantee is verified by the math:
|
|||
|
|
// - In each simulation, exactly one player holds each rank 1–8.
|
|||
|
|
// - Therefore count[rank] sums to NUM_SIMULATIONS across all players.
|
|||
|
|
// - Dividing by NUM_SIMULATIONS gives column sums of exactly 1.0.
|
|||
|
|
// This is verified indirectly by the simulateMajor + buildDraw tests above.
|
|||
|
|
|
|||
|
|
describe("QP accumulation ranking (unit)", () => {
|
|||
|
|
it("seed 1 wins more often than a random player across many trials", () => {
|
|||
|
|
const IDS = makeIds(128);
|
|||
|
|
const elos = IDS.map((_, i) => 2000 - i * 3); // descending by seeding
|
|||
|
|
const eloMap = makeEloMap(IDS, elos);
|
|||
|
|
|
|||
|
|
let seed1Wins = 0;
|
|||
|
|
const TRIALS = 500;
|
|||
|
|
for (let i = 0; i < TRIALS; i++) {
|
|||
|
|
const draw = buildDraw(IDS, eloMap);
|
|||
|
|
const results = simulateMajor(draw, eloMap, "hard");
|
|||
|
|
const seed1 = results.find((r) => r.participantId === "p001");
|
|||
|
|
if (seed1?.qp === 20) seed1Wins++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Seed 1 has ~3 points advantage per player; against 127 opponents should win
|
|||
|
|
// substantially more than 1/128 ≈ 0.78% of the time
|
|||
|
|
const winRate = seed1Wins / TRIALS;
|
|||
|
|
expect(winRate).toBeGreaterThan(0.03); // > 3% (well above random 0.78%)
|
|||
|
|
});
|
|||
|
|
});
|