import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { frameWinProb, matchWinProb, SnookerSimulator } from "../snooker-simulator"; // ─── Pure math: frameWinProb ────────────────────────────────────────────────── describe("frameWinProb", () => { it("returns 0.5 for equal Elo", () => { expect(frameWinProb(2400, 2400)).toBeCloseTo(0.5, 5); expect(frameWinProb(1500, 1500)).toBeCloseTo(0.5, 5); }); it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => { expect(frameWinProb(2600, 2300)).toBeGreaterThan(0.5); expect(frameWinProb(2300, 2600)).toBeLessThan(0.5); }); it("larger Elo gap → higher win probability (monotonic)", () => { const p300 = frameWinProb(2600, 2300); const p400 = frameWinProb(2700, 2300); const p200 = frameWinProb(2500, 2300); expect(p400).toBeGreaterThan(p300); expect(p300).toBeGreaterThan(p200); }); it("is symmetric: frameWinProb(a, b) + frameWinProb(b, a) = 1", () => { expect(frameWinProb(2600, 2300) + frameWinProb(2300, 2600)).toBeCloseTo(1.0, 5); expect(frameWinProb(2837, 2756) + frameWinProb(2756, 2837)).toBeCloseTo(1.0, 5); }); it("returns value in [0, 1]", () => { expect(frameWinProb(3000, 1000)).toBeLessThan(1); expect(frameWinProb(3000, 1000)).toBeGreaterThan(0); expect(frameWinProb(1000, 3000)).toBeLessThan(1); expect(frameWinProb(1000, 3000)).toBeGreaterThan(0); }); it("is symmetric: p1 vs p2 win probs sum to 1", () => { expect(frameWinProb(2500, 2200) + frameWinProb(2200, 2500)).toBeCloseTo(1.0, 5); }); }); // ─── Pure math: matchWinProb ────────────────────────────────────────────────── describe("matchWinProb", () => { it("returns 0.5 for equal players (p=0.5) at any match length", () => { // Best-of-19 (F=10), best-of-25 (F=13), best-of-33 (F=17), best-of-35 (F=18) expect(matchWinProb(0.5, 10)).toBeCloseTo(0.5, 3); expect(matchWinProb(0.5, 13)).toBeCloseTo(0.5, 3); expect(matchWinProb(0.5, 17)).toBeCloseTo(0.5, 3); expect(matchWinProb(0.5, 18)).toBeCloseTo(0.5, 3); }); it("returns ~0.758 for p=0.68, best-of-3 (F=2) — matches reference example", () => { // Site example: 68% per-frame win rate → 75.8% match win for best-of-3 expect(matchWinProb(0.68, 2)).toBeCloseTo(0.758, 2); }); it("approaches 1.0 as p → 1.0", () => { expect(matchWinProb(0.9999, 10)).toBeCloseTo(1.0, 3); expect(matchWinProb(0.9999, 18)).toBeCloseTo(1.0, 3); }); it("approaches 0.0 as p → 0.0", () => { expect(matchWinProb(0.0001, 10)).toBeCloseTo(0.0, 3); expect(matchWinProb(0.0001, 18)).toBeCloseTo(0.0, 3); }); it("longer matches amplify the stronger player's advantage", () => { // p=0.6: win prob should be higher in best-of-35 than best-of-3 const bo3 = matchWinProb(0.6, 2); const bo35 = matchWinProb(0.6, 18); expect(bo35).toBeGreaterThan(bo3); }); it("match win prob + opponent win prob sums to 1.0", () => { // p = 0.65, F = 13 const p = 0.65; const winProb = matchWinProb(p, 13); const lossProb = matchWinProb(1 - p, 13); expect(winProb + lossProb).toBeCloseTo(1.0, 5); }); }); // ─── Integration tests with mocked DB ──────────────────────────────────────── const PARTICIPANT_IDS = Array.from({ length: 32 }, (_, i) => `player-${i + 1}`); const ELO_BASE = 2400; 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 5-round bracket (32 players, 31 matches total). */ function makeFullBracket(): ReturnType[] { const matches: ReturnType[] = []; // R32: 16 matches, pairs: (0,1), (2,3), ..., (30,31) for (let i = 0; i < 16; i++) { matches.push(makeMatch("First Round", i + 1, i * 2, i * 2 + 1)); } // R16: 8 matches for (let i = 0; i < 8; i++) { matches.push(makeMatch("Second Round", i + 1, 0, 1)); // participants filled in by bracket } // QF: 4 matches for (let i = 0; i < 4; i++) { matches.push(makeMatch("Quarter-Finals", i + 1, 0, 1)); } // SF: 2 matches for (let i = 0; i < 2; i++) { matches.push(makeMatch("Semi-Finals", i + 1, 0, 1)); } // Final: 1 match matches.push(makeMatch("Final", 1, 0, 1)); return matches; } /** Build EV rows with uniform Elo for all 32 participants. */ 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("SnookerSimulator.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 has wrong number of rounds", async () => { // Only 4 rounds instead of 5 (missing Final) const matches = [ ...Array.from({ length: 16 }, (_, i) => makeMatch("First Round", i + 1, i * 2, i * 2 + 1)), ...Array.from({ length: 8 }, (_, i) => makeMatch("Second Round", 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 SnookerSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 5 rounds/); }); it("throws if First Round match count is wrong", async () => { // Only 8 R32 matches instead of 16 const matches = [ ...Array.from({ length: 8 }, (_, i) => makeMatch("First Round", i + 1, i * 2, i * 2 + 1)), ...Array.from({ length: 8 }, (_, i) => makeMatch("Second Round", 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 SnookerSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 16 First Round/); }); it("throws if a First Round match is missing participants", async () => { const matches = makeFullBracket(); // Remove participant2Id from First Round match 5 const r32Match5 = matches.find(m => m.round === "First Round" && m.matchNumber === 5); if (!r32Match5) throw new Error("match not found"); Object.assign(r32Match5, { participant2Id: null }); mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); const sim = new SnookerSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/); }); it("returns 32 results with valid probability distributions", async () => { mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket()); const sim = new SnookerSimulator(); const results = await sim.simulate("season-1"); expect(results).toHaveLength(32); for (const r of results) { const p = r.probabilities; expect(p.probFirst).toBeGreaterThanOrEqual(0); expect(p.probSecond).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 32 participants", async () => { mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket()); const sim = new SnookerSimulator(); 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("completed tournament: champion has probFirst≈1, all other players have probFirst=0", async () => { const champion = PARTICIPANT_IDS[0]; // player-1 const finalist = PARTICIPANT_IDS[2]; // player-3 // Build a complete bracket where player-1 wins everything const r32Matches = Array.from({ length: 16 }, (_, i) => { const p1 = PARTICIPANT_IDS[i * 2]; const p2 = PARTICIPANT_IDS[i * 2 + 1]; const winner = i === 0 ? p1 : p2; // match 1: p1 wins; rest: p2 wins return makeMatch("First Round", i + 1, i * 2, i * 2 + 1, { winnerId: winner, loserId: winner === p1 ? p2 : p1, isComplete: true, }); }); // R16: player-1 vs player-4 (p2 winners from R32 were p2s) // For simplicity, mark QF/SF/Final all complete with player-1 winning const r16Matches = Array.from({ length: 8 }, (_, i) => ({ ...makeMatch("Second Round", i + 1, 0, 1), winnerId: i === 0 ? champion : PARTICIPANT_IDS[3], loserId: i === 0 ? PARTICIPANT_IDS[3] : champion, isComplete: true, })); const qfMatches = Array.from({ length: 4 }, (_, i) => ({ ...makeMatch("Quarter-Finals", i + 1, 0, 1), winnerId: i === 0 ? champion : PARTICIPANT_IDS[5], loserId: i === 0 ? PARTICIPANT_IDS[5] : champion, isComplete: true, })); const sfMatches = Array.from({ length: 2 }, (_, i) => ({ ...makeMatch("Semi-Finals", i + 1, 0, 1), winnerId: i === 0 ? champion : finalist, loserId: i === 0 ? finalist : champion, isComplete: true, })); const finalMatch = { ...makeMatch("Final", 1, 0, 1), winnerId: champion, loserId: finalist, isComplete: true, }; mockDb.query.playoffMatches.findMany.mockResolvedValue([ ...r32Matches, ...r16Matches, ...qfMatches, ...sfMatches, finalMatch, ]); const sim = new SnookerSimulator(); const results = await sim.simulate("season-1"); const championResult = results.find(r => r.participantId === champion); expect(championResult?.probabilities.probFirst).toBeCloseTo(1.0, 2); const finalistResult = results.find(r => r.participantId === finalist); expect(finalistResult?.probabilities.probSecond).toBeCloseTo(1.0, 2); // R32 losers (the p2s from matches 2-16) should have all-zero probs const r32Loser = PARTICIPANT_IDS[3]; // p2 of match 2, loses in R32 const loserResult = results.find(r => r.participantId === r32Loser); if (!loserResult) throw new Error("loserResult not found"); const lp = loserResult.probabilities; expect(lp.probFirst).toBe(0); expect(lp.probSecond).toBe(0); const loserSum = lp.probFirst + lp.probSecond + lp.probThird + lp.probFourth + lp.probFifth + lp.probSixth + lp.probSeventh + lp.probEighth; expect(loserSum).toBe(0); }); it("falls back to equal Elo (50/50) when no sourceElo stored", async () => { // Return empty EV rows — no Elo stored mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }); mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket()); const sim = new SnookerSimulator(); const results = await sim.simulate("season-1"); // With equal Elo, probFirst should be roughly equal across all participants expect(results).toHaveLength(32); // Each player should have a roughly fair shot — not zero for all const hasNonZero = results.some(r => r.probabilities.probFirst > 0); expect(hasNonZero).toBe(true); }); it("labels results with snooker_world_championship_monte_carlo source", async () => { mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket()); const sim = new SnookerSimulator(); const results = await sim.simulate("season-1"); expect(results.every(r => r.source === "snooker_world_championship_monte_carlo")).toBe(true); }); }); // ─── Path B: pre-bracket simulation ────────────────────────────────────────── describe("SnookerSimulator.simulate() — Path B (pre-bracket)", () => { let mockDb: { query: { scoringEvents: { findFirst: MockInstance }; playoffMatches: { findMany: MockInstance }; }; select: MockInstance; }; // 32 participants — names don't drive seeding anymore; worldRanking in EV rows does. const SEASON_PARTICIPANTS = Array.from({ length: 32 }, (_, i) => ({ id: `player-${i + 1}`, name: `Player ${i + 1}`, })); // Build EV rows including worldRanking (rank 1 = player-1, rank 2 = player-2, etc.) function makeEvRows(rankOverride?: Map) { return SEASON_PARTICIPANTS.map((p, i) => ({ participantId: p.id, sourceElo: ELO_BASE, worldRanking: rankOverride?.has(p.id) ? rankOverride.get(p.id) : i + 1, })); } function setupMockDb(evRows: ReturnType, participantRows = SEASON_PARTICIPANTS) { mockDb = { query: { scoringEvents: { findFirst: vi.fn() }, playoffMatches: { findMany: vi.fn() }, }, select: vi.fn() // First select(): participantExpectedValues (Elo + worldRanking) .mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(evRows) }), }) // Second select(): participants (id + name) .mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows) }), }), }; mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1", sportsSeasonId: "season-1", eventType: "playoff_game", }); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); } beforeEach(async () => { const { database } = await import("~/database/context"); setupMockDb(makeEvRows()); (database as unknown as MockInstance).mockReturnValue(mockDb); }); it("returns 32 results when bracket is not yet populated", async () => { const sim = new SnookerSimulator(); const results = await sim.simulate("season-1"); expect(results).toHaveLength(32); }); it("each probability column sums to 1.0 across all 32 participants", async () => { const sim = new SnookerSimulator(); 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("top-seeded player (rank 1, highest Elo) has highest win probability", async () => { const { database } = await import("~/database/context"); // Give the seed-1 player a big Elo advantage; everyone else gets low Elo const eloRows = SEASON_PARTICIPANTS.map((p, i) => ({ participantId: p.id, sourceElo: i === 0 ? 3000 : 1500, worldRanking: i + 1, })); setupMockDb(eloRows); (database as unknown as MockInstance).mockReturnValue(mockDb); const sim = new SnookerSimulator(); const results = await sim.simulate("season-1"); const topSeedResult = results.find(r => r.participantId === "player-1"); const otherProbFirsts = results .filter(r => r.participantId !== "player-1") .map(r => r.probabilities.probFirst); expect(topSeedResult).toBeDefined(); expect(topSeedResult!.probabilities.probFirst).toBeGreaterThan(Math.max(...otherProbFirsts)); }); it("falls back to Elo order when no world rankings are stored", async () => { const { database } = await import("~/database/context"); // No worldRanking on any row — simulator should use Elo order const eloRows = SEASON_PARTICIPANTS.map((p, i) => ({ participantId: p.id, sourceElo: i === 0 ? 3000 : 1500, // player-1 has highest Elo worldRanking: null, })); setupMockDb(eloRows); (database as unknown as MockInstance).mockReturnValue(mockDb); const sim = new SnookerSimulator(); const results = await sim.simulate("season-1"); // player-1 with the highest Elo should be treated as seed 1 const topEloResult = results.find(r => r.participantId === "player-1"); const otherProbFirsts = results .filter(r => r.participantId !== "player-1") .map(r => r.probabilities.probFirst); expect(topEloResult).toBeDefined(); expect(topEloResult!.probabilities.probFirst).toBeGreaterThan(Math.max(...otherProbFirsts)); }); it("throws if fewer than 17 participants exist", async () => { const { database } = await import("~/database/context"); const tooFew = SEASON_PARTICIPANTS.slice(0, 10); const eloRows = tooFew.map((p, i) => ({ participantId: p.id, sourceElo: ELO_BASE, worldRanking: i + 1 })); (database as unknown as MockInstance).mockReturnValue({ ...mockDb, select: vi.fn() .mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRows) }), }) .mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(tooFew) }), }), }); const sim = new SnookerSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/at least 17 participants/); }); });