brackt/app/services/simulations/__tests__/tennis-simulator.test.ts
Chris Parsons 43dbdf0040
Add not-participating exclusion support for event results (#446)
* Add notParticipating flag to allow excluding withdrawn participants from qualifying-points simulators

Adds a `not_participating` boolean column to `event_results` so admins can
mark a participant as not competing in a specific upcoming major (e.g. Alcaraz
withdrawing from Wimbledon due to injury). The golf, tennis, and CS2 major
simulators now query this flag for incomplete events and exclude those
participants from the event's draw/field, redistributing probability weight
to the remaining field. Admin UI for qualifying major_tournament events gains
a "Not Participating" card to mark/unmark withdrawals before the event runs.

https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9

* Address code review feedback on not-participating flag

Security: unmark-not-participating now validates the result exists, belongs
to this event, and is actually a DNP row before deleting. mark-not-participating
now returns a user-friendly error on duplicate-key constraint violations.

Code quality: extract shared getExcludedByEventMap() utility to event-result
model, eliminating the duplicated 20-line exclusion-loading block that was
copy-pasted into all three simulators. Fix hasParticipantResult() to exclude
notParticipating rows so it correctly reflects actual competition participation.
Remove optional chaining on the non-optional notParticipatingIds field in the
admin UI. Fix misleading empty-state message.

Tests: replace the misleading first tennis DNP test (which never used the
activeIds variable it created) with a test that explicitly validates the
fallback behaviour when too few players remain after exclusion. Add three CS2
DNP tests covering the excluded-team-gets-zero-QP path, the redistribution
of wins, and per-event pool independence.

https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9

* Fix lint errors: replace non-null assertions in CS2 DNP test

https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 11:13:32 -07:00

339 lines
15 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 { 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 063 and 64127)", () => {
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 (R1R3 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 18.
// - 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<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 < 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%)
});
});