import { describe, it, expect } from "vitest"; import { eloWinProb, buildDraw, simulateMajor, drawFromBracket, buildHonoredMap, DEFAULT_SLAM_QP, type RealBracketMatch, } from "../tennis-simulator"; import { deriveBracketQualifyingStates, getRoundConfig } from "~/models/scoring-calculator"; import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; // ─── 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 { 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(); 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(); 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(); 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(); 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. // ─── Not-participating exclusion ────────────────────────────────────────────── describe("not-participating exclusion (tennis)", () => { it("falls back to full participant list when too few remain after exclusions (< 128)", () => { // With exactly 128 season participants and 1 excluded, activeIds.length = 127 < 128. // The simulator uses the full list rather than building a broken bracket. const IDS = makeIds(128); const elos = IDS.map(() => 1500); const eloMap = makeEloMap(IDS, elos); const activeIds = IDS.filter((id) => id !== "p001"); // 127 — triggers fallback expect(activeIds).toHaveLength(127); // Fallback: build with full IDS, no crash, draw is valid 128-slot bracket const drawIds = activeIds.length >= 128 ? activeIds : IDS; const draw = buildDraw(drawIds, eloMap); expect(draw).toHaveLength(128); expect(draw).toContain("p001"); // included via fallback }); it("when activeIds.length >= 128, excluded player's draw slot is freed", () => { // 130 participants; exclude 2 → 128 remain → draw contains only active players const LARGE_IDS = makeIds(130); const elos = LARGE_IDS.map(() => 1500); const eloMap = makeEloMap(LARGE_IDS, elos); const excluded = new Set(["p001", "p002"]); const activeIds = LARGE_IDS.filter((id) => !excluded.has(id)); // 128 players expect(activeIds).toHaveLength(128); const draw = buildDraw(activeIds, eloMap); expect(draw).toHaveLength(128); expect(draw).not.toContain("p001"); expect(draw).not.toContain("p002"); }); it("excluding the dominant player redistributes wins to the remaining field", () => { const LARGE_IDS = makeIds(130); const dominantEloMap = new Map(); dominantEloMap.set("p001", { worldRanking: 1, eloHard: 5000, eloClay: 5000, eloGrass: 5000 }); for (let i = 1; i < LARGE_IDS.length; i++) { dominantEloMap.set(LARGE_IDS[i], { worldRanking: i + 1, eloHard: 1500, eloClay: 1500, eloGrass: 1500 }); } const activeIds = LARGE_IDS.filter((id) => id !== "p001"); // exclude dominant player expect(activeIds.length).toBeGreaterThanOrEqual(128); const TRIALS = 100; let p001Wins = 0; for (let i = 0; i < TRIALS; i++) { const draw = buildDraw(activeIds, dominantEloMap); const results = simulateMajor(draw, dominantEloMap, "grass"); if (results.find((r) => r.participantId === "p001")?.qp === 20) p001Wins++; } // Excluded player should not appear in any draw → 0 wins expect(p001Wins).toBe(0); }); }); 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 = 2000; 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 Elo points advantage per seed; should win substantially // more than 1/128 ≈ 0.78% of the time. Threshold at 0.02 is still well // above random; 2000 trials keeps std dev small enough to be non-flaky. const winRate = seed1Wins / TRIALS; expect(winRate).toBeGreaterThan(0.02); // > 2% (well above random 0.78%) }); }); // ─── drawFromBracket ──────────────────────────────────────────────────────────── describe("drawFromBracket", () => { const ids = makeIds(128); // 64 Round-of-128 matches: match N holds ids[2N-2], ids[2N-1]. const fullR128 = Array.from({ length: 64 }, (_, i) => ({ round: "Round of 128", matchNumber: i + 1, participant1Id: ids[i * 2], participant2Id: ids[i * 2 + 1], })); it("reconstructs a 128-slot draw in match order", () => { const draw = drawFromBracket(fullR128, (id) => id); expect(draw).not.toBeNull(); expect(draw).toHaveLength(128); expect(draw?.[0]).toBe("p001"); expect(draw?.[1]).toBe("p002"); expect(draw?.[127]).toBe("p128"); }); it("returns null when the draw is not fully populated", () => { const partial = fullR128.slice(0, 60); // only 60 of 64 matches expect(drawFromBracket(partial, (id) => id)).toBeNull(); const withNull = fullR128.map((m, i) => i === 0 ? { ...m, participant1Id: null } : m ); expect(drawFromBracket(withNull, (id) => id)).toBeNull(); }); it("applies the id translator", () => { const draw = drawFromBracket(fullR128, (id) => (id ? `x-${id}` : id)); expect(draw?.[0]).toBe("x-p001"); }); }); // ─── simulateMajor honoring a real bracket ────────────────────────────────────── describe("simulateMajor with realBracket", () => { const ids = makeIds(128); const eloMap = makeEloMap(ids, ids.map(() => 1500)); // A fully-decided bracket where the lower draw index always wins. Champion is // therefore ids[0]; the result is deterministic regardless of RNG. function decideBracket(draw: string[]): RealBracketMatch[] { const rounds = [ "Round of 128", "Round of 64", "Round of 32", "Round of 16", "Quarterfinals", "Semifinals", "Final", ]; const order = new Map(draw.map((id, i) => [id, i])); const matches: RealBracketMatch[] = []; let current = [...draw]; for (const round of rounds) { const next: string[] = []; for (let i = 0; i < current.length; i += 2) { const p1 = current[i]; const p2 = current[i + 1]; const winner = (order.get(p1) ?? 0) < (order.get(p2) ?? 0) ? p1 : p2; matches.push({ round, matchNumber: i / 2 + 1, winnerId: winner, isComplete: true }); next.push(winner); } current = next; } return matches; } it("honors a fully-decided bracket deterministically (champion = draw[0])", () => { const draw = [...ids]; const realBracket = decideBracket(draw); const results = simulateMajor(draw, eloMap, "hard", { realBracket }); const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner); expect(champ?.participantId).toBe(draw[0]); // Structural counts are unchanged by honoring. expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1); expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.finalist)).toHaveLength(1); expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.sf)).toHaveLength(2); expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.qf)).toHaveLength(4); expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.r16)).toHaveLength(8); }); it("honors a completed Final winner even against a heavy Elo underdog", () => { // Underdog at draw[0] (low Elo) but the Final is recorded as their win. const draw = [...ids]; const skewed = makeEloMap(draw, draw.map((_, i) => (i === 0 ? 100 : 3000))); const realBracket = decideBracket(draw); // draw[0] wins everything const results = simulateMajor(draw, skewed, "hard", { realBracket }); const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner); expect(champ?.participantId).toBe(draw[0]); }); it("uses config QP values from opts.qp", () => { const draw = [...ids]; const realBracket = decideBracket(draw); const qp = { winner: 100, finalist: 70, sf: 40, qf: 20, r16: 5 }; const results = simulateMajor(draw, eloMap, "hard", { realBracket, qp }); expect(results.find((r) => r.participantId === draw[0])?.qp).toBe(100); expect(results.filter((r) => r.qp === 70)).toHaveLength(1); expect(results.filter((r) => r.qp === 5)).toHaveLength(8); }); }); // ─── tennis_128 bracket scoring (QP placement derivation) ─────────────────────── describe("tennis_128 qualifying bracket scoring", () => { it("derives placements 1/2/3/5/9 from the tennis_128 round config", () => { const template = BRACKET_TEMPLATES.tennis_128; // A wins R16 → QF → SF → Final (champion). Losers exit at each scoring round. const matches = [ { round: "Round of 16", winnerId: "A", loserId: "I", participant1Id: "A", participant2Id: "I" }, { round: "Quarterfinals", winnerId: "A", loserId: "E", participant1Id: "A", participant2Id: "E" }, { round: "Semifinals", winnerId: "A", loserId: "C", participant1Id: "A", participant2Id: "C" }, { round: "Final", winnerId: "A", loserId: "B", participant1Id: "A", participant2Id: "B" }, ]; const states = deriveBracketQualifyingStates( matches, template.rounds, (round) => getRoundConfig(round, "tennis_128") ); expect(states.get("A")?.placement).toBe(1); // champion expect(states.get("B")?.placement).toBe(2); // Final loser expect(states.get("C")).toMatchObject({ placement: 3, tieCount: 2 }); // SF loser expect(states.get("E")).toMatchObject({ placement: 5, tieCount: 4 }); // QF loser expect(states.get("I")).toMatchObject({ placement: 9, tieCount: 8 }); // R16 loser }); }); // ─── exclusions (not-participating) + prebuilt honored map ────────────────────── describe("simulateMajor exclusions & prebuilt honored", () => { const ids = makeIds(128); it("a withdrawn player walks over (earns 0 QP) even with a dominant Elo", () => { // draw[0] would otherwise win almost everything; excluding them must zero them. const dominant = makeEloMap(ids, ids.map((_, i) => (i === 0 ? 5000 : 1000))); const excluded = new Set([ids[0]]); for (let t = 0; t < 20; t++) { const results = simulateMajor(ids, dominant, "hard", { excluded }); expect(results.find((r) => r.participantId === ids[0])?.qp).toBe(0); } }); it("a completed match is still honored even if the winner is later excluded", () => { // Fully-decided bracket says draw[0] wins; excluding them must NOT override a // recorded real result (the match already happened). function decide(draw: string[]): RealBracketMatch[] { const rounds = [ "Round of 128", "Round of 64", "Round of 32", "Round of 16", "Quarterfinals", "Semifinals", "Final", ]; const order = new Map(draw.map((id, i) => [id, i])); const matches: RealBracketMatch[] = []; let cur = [...draw]; for (const round of rounds) { const next: string[] = []; for (let i = 0; i < cur.length; i += 2) { const w = (order.get(cur[i]) ?? 0) < (order.get(cur[i + 1]) ?? 0) ? cur[i] : cur[i + 1]; matches.push({ round, matchNumber: i / 2 + 1, winnerId: w, isComplete: true }); next.push(w); } cur = next; } return matches; } const honored = buildHonoredMap(decide(ids)); const eloMap = makeEloMap(ids, ids.map(() => 1500)); const results = simulateMajor(ids, eloMap, "hard", { honored, excluded: new Set([ids[0]]), }); expect(results.find((r) => r.qp === DEFAULT_SLAM_QP.winner)?.participantId).toBe(ids[0]); }); it("prebuilt honored map produces the same champion as realBracket input", () => { const realBracket: RealBracketMatch[] = [ { round: "Final", matchNumber: 1, winnerId: null, isComplete: false }, ]; const honored = buildHonoredMap(realBracket); // Empty honored (no completed matches) → no crash, full simulation runs. const eloMap = makeEloMap(ids, ids.map(() => 1500)); const results = simulateMajor(ids, eloMap, "hard", { honored }); expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1); }); });