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, })); } 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: use names that match the hardcoded rankings const RANKED_NAMES = [ "Judd Trump", "Kyren Wilson", "Mark Selby", "Ronnie O'Sullivan", "Mark Allen", "Luca Brecel", "Neil Robertson", "John Higgins", "Ryan Day", "Zhao Xintong", "Jak Jones", "Si Jiahui", "Barry Hawkins", "Ding Junhui", "Pang Junxu", "Zhang Anda", "Xiao Guodong", "Jack Lisowski", "Shaun Murphy", "David Gilbert", "Anthony McGill", "Tom Ford", "Stuart Bingham", "Andrew Higginson", "Chris Wakelin", "Gary Wilson", "Matthew Selt", "Gerard Greene", "Ben Woollaston", "Jamie Jones", "Robbie Williams", "Mark Williams", ]; const SEASON_PARTICIPANTS = RANKED_NAMES.map((name, i) => ({ id: `player-${i + 1}`, name, })); beforeEach(async () => { const { database } = await import("~/database/context"); // select() is called twice in Path B: // 1. for participantExpectedValues (EV/Elo rows) // 2. for participants (all players in the season) const eloRows = SEASON_PARTICIPANTS.map(p => ({ participantId: p.id, sourceElo: ELO_BASE })); 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({ id: "event-1", sportsSeasonId: "season-1", eventType: "playoff_game", }); // No matches → triggers Path B mockDb.query.playoffMatches.findMany.mockResolvedValue([]); }); 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("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 => ({ participantId: p.id, sourceElo: ELO_BASE })); (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/); }); });