import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { setWinProb, matchWinProb, getSeededMatchOrder, buildR1Bracket, DartsSimulator, } from "../darts-simulator"; // ─── Pure math: setWinProb ──────────────────────────────────────────────────── describe("setWinProb", () => { it("returns 0.5 for equal Elo", () => { expect(setWinProb(1800, 1800)).toBeCloseTo(0.5, 5); expect(setWinProb(2000, 2000)).toBeCloseTo(0.5, 5); }); it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => { expect(setWinProb(2000, 1700)).toBeGreaterThan(0.5); expect(setWinProb(1700, 2000)).toBeLessThan(0.5); }); it("is symmetric: setWinProb(a, b) + setWinProb(b, a) = 1", () => { expect(setWinProb(2000, 1700) + setWinProb(1700, 2000)).toBeCloseTo(1.0, 5); expect(setWinProb(2099, 1952) + setWinProb(1952, 2099)).toBeCloseTo(1.0, 5); }); it("larger Elo gap → higher win probability (monotonic)", () => { const p100 = setWinProb(1900, 1800); const p300 = setWinProb(2100, 1800); const p500 = setWinProb(2300, 1800); expect(p300).toBeGreaterThan(p100); expect(p500).toBeGreaterThan(p300); }); it("returns value strictly in (0, 1) for any finite Elo", () => { expect(setWinProb(3000, 1000)).toBeLessThan(1); expect(setWinProb(3000, 1000)).toBeGreaterThan(0); expect(setWinProb(1000, 3000)).toBeLessThan(1); expect(setWinProb(1000, 3000)).toBeGreaterThan(0); }); it("calibration: 100-pt gap → ~62% per set (ELO_DIVISOR=200)", () => { // 1 / (1 + e^(-100/200)) ≈ 0.6225 expect(setWinProb(1900, 1800)).toBeCloseTo(0.6225, 2); }); }); // ─── Pure math: matchWinProb ────────────────────────────────────────────────── describe("matchWinProb", () => { it("returns 0.5 for equal players (p=0.5) at any match length", () => { // PDC formats: bo3 (S=2), bo5 (S=3), bo7 (S=4), bo11 (S=6), bo13 (S=7) expect(matchWinProb(0.5, 2)).toBeCloseTo(0.5, 3); expect(matchWinProb(0.5, 3)).toBeCloseTo(0.5, 3); expect(matchWinProb(0.5, 4)).toBeCloseTo(0.5, 3); expect(matchWinProb(0.5, 6)).toBeCloseTo(0.5, 3); expect(matchWinProb(0.5, 7)).toBeCloseTo(0.5, 3); }); it("approaches 1.0 as p → 1.0", () => { expect(matchWinProb(0.9999, 2)).toBeCloseTo(1.0, 3); expect(matchWinProb(0.9999, 7)).toBeCloseTo(1.0, 3); }); it("approaches 0.0 as p → 0.0", () => { expect(matchWinProb(0.0001, 2)).toBeCloseTo(0.0, 3); expect(matchWinProb(0.0001, 7)).toBeCloseTo(0.0, 3); }); it("longer matches amplify stronger player's advantage", () => { // p=0.6: win prob should be higher in best-of-13 than best-of-3 const bo3 = matchWinProb(0.6, 2); const bo13 = matchWinProb(0.6, 7); expect(bo13).toBeGreaterThan(bo3); }); it("match win prob + opponent win prob sums to 1.0", () => { const p = 0.63; expect(matchWinProb(p, 3) + matchWinProb(1 - p, 3)).toBeCloseTo(1.0, 5); expect(matchWinProb(p, 6) + matchWinProb(1 - p, 6)).toBeCloseTo(1.0, 5); }); it("best-of-3 (S=2): reference check for p=0.6 → ~0.648", () => { // P(2-0) = 0.6^2 = 0.36; P(2-1) = 2 * 0.6^2 * 0.4 = 0.288; total = 0.648 expect(matchWinProb(0.6, 2)).toBeCloseTo(0.648, 3); }); }); // ─── Bracket structure: getSeededMatchOrder ────────────────────────────────── describe("getSeededMatchOrder", () => { it("for n=2, returns [1, 2]", () => { expect(getSeededMatchOrder(2)).toEqual([1, 2]); }); it("for n=4, seed 1 vs 4 in top half, seed 2 vs 3 in bottom half", () => { // Algorithm: start [1,2] → expand to [1,4,2,3] expect(getSeededMatchOrder(4)).toEqual([1, 4, 2, 3]); }); it("for n=8, seed 1 and seed 2 are in opposite halves", () => { const order = getSeededMatchOrder(8); expect(order).toHaveLength(8); const idx1 = order.indexOf(1); const idx2 = order.indexOf(2); // Seeds 1 and 2 should be in different halves (0-3 vs 4-7) expect(Math.floor(idx1 / 4)).not.toBe(Math.floor(idx2 / 4)); }); it("for n=32, contains all seeds 1–32 exactly once", () => { const order = getSeededMatchOrder(32); expect(order).toHaveLength(32); const sorted = [...order].toSorted((a, b) => a - b); expect(sorted).toEqual(Array.from({ length: 32 }, (_, i) => i + 1)); }); it("for n=32, seed 1 and seed 2 are in opposite halves", () => { const order = getSeededMatchOrder(32); const idx1 = order.indexOf(1); const idx2 = order.indexOf(2); // Opposite halves: one in [0,15], other in [16,31] expect(Math.floor(idx1 / 16)).not.toBe(Math.floor(idx2 / 16)); }); }); // ─── Bracket structure: buildR1Bracket ─────────────────────────────────────── describe("buildR1Bracket", () => { const seeded = Array.from({ length: 32 }, (_, i) => `seed-${i + 1}`); const unseeded = Array.from({ length: 96 }, (_, i) => `unseed-${i}`); it("returns exactly 64 pairs", () => { const pairs = buildR1Bracket(seeded, unseeded); expect(pairs).toHaveLength(64); }); it("covers all 128 players (32 seeded + 96 unseeded)", () => { const pairs = buildR1Bracket(seeded, unseeded); const allPlayers = pairs.flat(); expect(allPlayers).toHaveLength(128); for (const [a, b] of pairs) { expect(a).toBeTruthy(); expect(b).toBeTruthy(); } // All seeded players appear exactly once for (const s of seeded) { expect(allPlayers.filter(p => p === s)).toHaveLength(1); } // All unseeded players appear exactly once for (const u of unseeded) { expect(allPlayers.filter(p => p === u)).toHaveLength(1); } }); it("each of the 32 seeded players faces an unseeded opponent in their R1 match", () => { const pairs = buildR1Bracket(seeded, unseeded); const seededSet = new Set(seeded); // Even-indexed pairs (0, 2, 4, ...) are seeded vs unseeded for (let i = 0; i < 64; i += 2) { const [a, b] = pairs[i]; const aSeeded = seededSet.has(a); const bSeeded = seededSet.has(b); expect(aSeeded !== bSeeded).toBe(true); } }); it("odd-indexed pairs are all unseeded vs unseeded (interleaved bracket structure)", () => { const pairs = buildR1Bracket(seeded, unseeded); const seededSet = new Set(seeded); // Odd-indexed pairs (1, 3, 5, ...) are unseeded vs unseeded for (let i = 1; i < 64; i += 2) { const [a, b] = pairs[i]; expect(seededSet.has(a)).toBe(false); expect(seededSet.has(b)).toBe(false); } }); }); // ─── Integration tests with mocked DB ──────────────────────────────────────── const PARTICIPANT_IDS = Array.from({ length: 128 }, (_, i) => `player-${i + 1}`); const ELO_BASE = 1750; function makeMatch( round: string, matchNumber: number, p1Index: number, p2Index: number, opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {} ) { return { id: `${round}-match-${matchNumber}`, scoringEventId: "event-1", round, matchNumber, participant1Id: PARTICIPANT_IDS[p1Index], participant2Id: PARTICIPANT_IDS[p2Index], winnerId: opts.winnerId ?? null, loserId: opts.loserId ?? null, isComplete: opts.isComplete ?? false, isScoring: true, templateRound: round, seedInfo: null, participant1Score: null, participant2Score: null, createdAt: new Date(), updatedAt: new Date(), }; } /** Build a full 7-round bracket (128 players, 127 matches). */ function makeFullBracket(): ReturnType[] { const matches: ReturnType[] = []; for (let i = 0; i < 64; i++) matches.push(makeMatch("Round 1", i + 1, i * 2, i * 2 + 1)); for (let i = 0; i < 32; i++) matches.push(makeMatch("Round 2", i + 1, 0, 1)); for (let i = 0; i < 16; i++) matches.push(makeMatch("Round 3", i + 1, 0, 1)); for (let i = 0; i < 8; i++) matches.push(makeMatch("Round 4", i + 1, 0, 1)); for (let i = 0; i < 4; i++) matches.push(makeMatch("Quarter-Finals", i + 1, 0, 1)); for (let i = 0; i < 2; i++) matches.push(makeMatch("Semi-Finals", i + 1, 0, 1)); matches.push(makeMatch("Final", 1, 0, 1)); return matches; } function makeEVRows(eloOverride?: Map) { return PARTICIPANT_IDS.map((participantId) => ({ participantId, sourceElo: eloOverride?.get(participantId) ?? ELO_BASE, worldRanking: null, })); } vi.mock("~/database/context", () => ({ database: vi.fn(), })); describe("DartsSimulator.simulate() — Path A (bracket populated)", () => { let mockDb: { query: { scoringEvents: { findFirst: MockInstance }; playoffMatches: { findMany: MockInstance }; }; select: MockInstance; }; beforeEach(async () => { const { database } = await import("~/database/context"); mockDb = { query: { scoringEvents: { findFirst: vi.fn() }, playoffMatches: { findMany: vi.fn() }, }, select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(makeEVRows()), }), }), }; (database as unknown as MockInstance).mockReturnValue(mockDb); mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1", sportsSeasonId: "season-1", eventType: "playoff_game", }); }); it("throws if bracket does not have exactly 7 rounds", async () => { // Only 6 rounds (missing Final) const matches = [ ...Array.from({ length: 64 }, (_, i) => makeMatch("Round 1", i + 1, i * 2, i * 2 + 1)), ...Array.from({ length: 32 }, (_, i) => makeMatch("Round 2", i + 1, 0, 1)), ...Array.from({ length: 16 }, (_, i) => makeMatch("Round 3", i + 1, 0, 1)), ...Array.from({ length: 8 }, (_, i) => makeMatch("Round 4", i + 1, 0, 1)), ...Array.from({ length: 4 }, (_, i) => makeMatch("Quarter-Finals", i + 1, 0, 1)), ...Array.from({ length: 2 }, (_, i) => makeMatch("Semi-Finals", i + 1, 0, 1)), ]; mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); const sim = new DartsSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 7 rounds/); }); it("throws if R1 does not have 64 matches", async () => { const matches = [ ...Array.from({ length: 32 }, (_, i) => makeMatch("Round 1", i + 1, i * 2, i * 2 + 1)), ...Array.from({ length: 32 }, (_, i) => makeMatch("Round 2", i + 1, 0, 1)), ...Array.from({ length: 16 }, (_, i) => makeMatch("Round 3", i + 1, 0, 1)), ...Array.from({ length: 8 }, (_, i) => makeMatch("Round 4", i + 1, 0, 1)), ...Array.from({ length: 4 }, (_, i) => makeMatch("Quarter-Finals", i + 1, 0, 1)), ...Array.from({ length: 2 }, (_, i) => makeMatch("Semi-Finals", i + 1, 0, 1)), makeMatch("Final", 1, 0, 1), ]; mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); const sim = new DartsSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 64 R1 matches/); }); it("returns 128 results with valid probability distributions", async () => { mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket()); const sim = new DartsSimulator(); const results = await sim.simulate("season-1"); expect(results).toHaveLength(128); for (const r of results) { const p = r.probabilities; expect(p.probFirst).toBeGreaterThanOrEqual(0); expect(p.probFirst).toBeLessThanOrEqual(1); const sum = p.probFirst + p.probSecond + p.probThird + p.probFourth + p.probFifth + p.probSixth + p.probSeventh + p.probEighth; expect(sum).toBeGreaterThanOrEqual(0); expect(sum).toBeLessThanOrEqual(1.001); } }); it("each probability column sums to 1.0 across all 128 participants", async () => { mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket()); const sim = new DartsSimulator(); const results = await sim.simulate("season-1"); const keys = [ "probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth", ] as const; for (const key of keys) { const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); expect(colSum).toBeCloseTo(1.0, 2); } }); it("labels results with darts_world_championship_monte_carlo source", async () => { mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket()); const sim = new DartsSimulator(); const results = await sim.simulate("season-1"); expect(results.every(r => r.source === "darts_world_championship_monte_carlo")).toBe(true); }); }); // ─── Path B: pre-bracket simulation ────────────────────────────────────────── describe("DartsSimulator.simulate() — Path B (pre-bracket)", () => { let mockDb: { query: { scoringEvents: { findFirst: MockInstance }; playoffMatches: { findMany: MockInstance }; }; select: MockInstance; }; // 128 participants with world rankings const SEASON_PARTICIPANTS = Array.from({ length: 128 }, (_, i) => ({ id: `player-${i + 1}`, name: `Player ${i + 1}`, })); function makeEVRowsWithRankings() { return SEASON_PARTICIPANTS.map((p, i) => ({ participantId: p.id, sourceElo: ELO_BASE - i * 100, // higher seed = higher Elo; large gap needed for reliable MC ordering worldRanking: i + 1, })); } beforeEach(async () => { const { database } = await import("~/database/context"); const eloRows = makeEVRowsWithRankings(); const participantRows = SEASON_PARTICIPANTS; mockDb = { query: { scoringEvents: { findFirst: vi.fn() }, playoffMatches: { findMany: vi.fn() }, }, select: vi.fn() .mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRows) }), }) .mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows) }), }), }; (database as unknown as MockInstance).mockReturnValue(mockDb); mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); }); it("returns 128 results when bracket is not yet populated", async () => { const sim = new DartsSimulator(); const results = await sim.simulate("season-1"); expect(results).toHaveLength(128); }, 15_000); it("each probability column sums to 1.0 across all 128 participants", async () => { const sim = new DartsSimulator(); const results = await sim.simulate("season-1"); const keys = [ "probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth", ] as const; for (const key of keys) { const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); expect(colSum).toBeCloseTo(1.0, 2); } }, 15_000); it("world #1 (highest Elo) has the highest probFirst", async () => { const sim = new DartsSimulator(); const results = await sim.simulate("season-1"); const sorted = [...results].toSorted((a, b) => b.probabilities.probFirst - a.probabilities.probFirst); // player-1 is world #1 with the highest Elo — should have highest win probability expect(sorted[0].participantId).toBe("player-1"); }, 15_000); it("throws if fewer than 2 participants exist", async () => { const { database } = await import("~/database/context"); const tooFew = [{ id: "player-1", name: "Solo" }]; const eloRowsFew = [{ participantId: "player-1", sourceElo: ELO_BASE, worldRanking: 1 }]; (database as unknown as MockInstance).mockReturnValue({ ...mockDb, query: { scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) }, playoffMatches: { findMany: vi.fn().mockResolvedValue([]) }, }, select: vi.fn() .mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRowsFew) }), }) .mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(tooFew) }), }), }); const sim = new DartsSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/at least 2 participants/); }); });