import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { americanToImpliedProb, UCLSimulator } from "../ucl-simulator"; // ─── Pure math tests (no DB) ───────────────────────────────────────────────── describe("americanToImpliedProb", () => { it("converts positive (underdog) American odds correctly", () => { // +300 → 100 / (300 + 100) = 0.25 expect(americanToImpliedProb(300)).toBeCloseTo(0.25, 5); // +100 → 100 / 200 = 0.5 expect(americanToImpliedProb(100)).toBeCloseTo(0.5, 5); }); it("converts negative (favorite) American odds correctly", () => { // -200 → 200 / 300 ≈ 0.6667 expect(americanToImpliedProb(-200)).toBeCloseTo(0.6667, 3); // -450 (Arsenal-like) → 450 / 550 ≈ 0.8182 expect(americanToImpliedProb(-450)).toBeCloseTo(0.8182, 3); }); it("sums to more than 1.0 for a two-sided market (reflects vig)", () => { // -110 / -110 line: each side ≈ 0.5238, total ≈ 1.0476 const total = americanToImpliedProb(-110) + americanToImpliedProb(-110); expect(total).toBeGreaterThan(1.0); }); }); // ─── Bracket path logic (pure math, verified against advanceWinnerTemplate) ── describe("UCL bracket path convention", () => { it("R16 match 1 and match 2 winners feed QF match 1", () => { // advanceWinnerTemplate: nextMatchNumber = Math.ceil(matchNumber / 2) // QF match for R16 match N: expect(Math.ceil(1 / 2)).toBe(1); // R16 match 1 → QF match 1 expect(Math.ceil(2 / 2)).toBe(1); // R16 match 2 → QF match 1 expect(Math.ceil(3 / 2)).toBe(2); // R16 match 3 → QF match 2 expect(Math.ceil(4 / 2)).toBe(2); // R16 match 4 → QF match 2 expect(Math.ceil(7 / 2)).toBe(4); // R16 match 7 → QF match 4 expect(Math.ceil(8 / 2)).toBe(4); // R16 match 8 → QF match 4 }); it("QF match N uses r16Winners[(N-1)*2] and [(N-1)*2+1] — same as simulator", () => { // r16Winners is 0-indexed from R16 match 1..8 in order // QF match 1 uses r16Winners[0] (R16 match 1 winner) and r16Winners[1] (R16 match 2 winner) for (let qfMatch = 1; qfMatch <= 4; qfMatch++) { const p1Index = (qfMatch - 1) * 2; const p2Index = (qfMatch - 1) * 2 + 1; expect(p1Index).toBe((qfMatch - 1) * 2); expect(p2Index).toBe((qfMatch - 1) * 2 + 1); expect(p1Index).toBeLessThan(8); expect(p2Index).toBeLessThan(8); } }); it("SF match N uses qfWinners[(N-1)*2] and [(N-1)*2+1]", () => { for (let sfMatch = 1; sfMatch <= 2; sfMatch++) { const p1Index = (sfMatch - 1) * 2; const p2Index = (sfMatch - 1) * 2 + 1; expect(p1Index).toBeLessThan(4); expect(p2Index).toBeLessThan(4); } }); }); // ─── Placement bucket math ──────────────────────────────────────────────────── describe("UCL placement bucket EV alignment", () => { const DEFAULT_SCORING = { pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, }; it("champion slot gives EV = 100 with DEFAULT_SCORING_RULES", () => { const probs = { probFirst: 1, probSecond: 0, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0 }; const ev = probs.probFirst * DEFAULT_SCORING.pointsFor1st; expect(ev).toBe(100); }); it("finalist slot gives EV = 70 with DEFAULT_SCORING_RULES", () => { const probs = { probFirst: 0, probSecond: 1, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0 }; const ev = probs.probSecond * DEFAULT_SCORING.pointsFor2nd; expect(ev).toBe(70); }); it("SF loser split (0.5/0.5 for 3rd/4th) gives EV = 45 with DEFAULT_SCORING_RULES", () => { const ev = 0.5 * DEFAULT_SCORING.pointsFor3rd + 0.5 * DEFAULT_SCORING.pointsFor4th; expect(ev).toBe(45); // (50 + 40) / 2 }); it("QF loser split (0.25 each for 5th-8th) gives EV = 20 with DEFAULT_SCORING_RULES", () => { const ev = 0.25 * DEFAULT_SCORING.pointsFor5th + 0.25 * DEFAULT_SCORING.pointsFor6th + 0.25 * DEFAULT_SCORING.pointsFor7th + 0.25 * DEFAULT_SCORING.pointsFor8th; expect(ev).toBe(20); // (25 + 25 + 15 + 15) / 4 }); it("R16 loser (all zeros) gives EV = 0", () => { const ev = 0 * DEFAULT_SCORING.pointsFor1st; // all slots 0 expect(ev).toBe(0); }); }); // ─── Integration test with mocked DB ───────────────────────────────────────── // Build 16 participant IDs for the test const PARTICIPANT_IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`); /** Build a minimal R16 match object */ function makeR16Match( matchNumber: number, opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {} ) { const p1 = PARTICIPANT_IDS[(matchNumber - 1) * 2]; const p2 = PARTICIPANT_IDS[(matchNumber - 1) * 2 + 1]; return { id: `r16-match-${matchNumber}`, scoringEventId: "event-1", round: "Round of 16", matchNumber, participant1Id: p1, participant2Id: p2, winnerId: opts.winnerId ?? null, loserId: opts.loserId ?? null, isComplete: opts.isComplete ?? false, isScoring: false, templateRound: "Round of 16", seedInfo: null, participant1Score: null, participant2Score: null, createdAt: new Date(), updatedAt: new Date(), }; } function makeQFMatch(matchNumber: number, opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}) { return { id: `qf-match-${matchNumber}`, scoringEventId: "event-1", round: "Quarterfinals", matchNumber, participant1Id: null, participant2Id: null, winnerId: opts.winnerId ?? null, loserId: opts.loserId ?? null, isComplete: opts.isComplete ?? false, isScoring: true, templateRound: "Quarterfinals", seedInfo: null, participant1Score: null, participant2Score: null, createdAt: new Date(), updatedAt: new Date(), }; } function makeSFMatch(matchNumber: number, opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}) { return { ...makeQFMatch(matchNumber, opts), id: `sf-match-${matchNumber}`, round: "Semifinals", templateRound: "Semifinals" }; } function makeFinalMatch(opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}) { return { ...makeQFMatch(1, opts), id: "final-match-1", round: "Finals", templateRound: "Finals" }; } vi.mock("~/database/context", () => ({ database: vi.fn(), })); describe("UCLSimulator.simulate()", () => { 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().from().where() chain returning empty odds rows select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }), }; (database as unknown as MockInstance).mockReturnValue(mockDb); // Default bracket event mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1", sportsSeasonId: "season-1", eventType: "playoff_game", }); }); it("throws if no bracket event found", async () => { mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); const sim = new UCLSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/No bracket event found/); }); it("throws if no playoff matches found", async () => { mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new UCLSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/No playoff matches found/); }); it("throws if R16 match is missing a participant", async () => { const matches = [ ...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)), ...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)), ...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)), makeFinalMatch(), ]; // Remove participant2Id from match 3 (simulates incomplete draw data) // eslint-disable-next-line @typescript-eslint/no-explicit-any matches[2] = { ...matches[2], participant2Id: null } as any; mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); const sim = new UCLSimulator(); await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/); }); it("returns 16 results with valid probability distributions (no odds, coin-flip)", async () => { const allMatches = [ ...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)), ...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)), ...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)), makeFinalMatch(), ]; mockDb.query.playoffMatches.findMany.mockResolvedValue(allMatches); const sim = new UCLSimulator(); const results = await sim.simulate("season-1"); expect(results).toHaveLength(16); for (const result of results) { const p = result.probabilities; const sum = p.probFirst + p.probSecond + p.probThird + p.probFourth + p.probFifth + p.probSixth + p.probSeventh + p.probEighth; // Each team's probabilities should sum to at most ~1.0 (many will sum to < 1 as R16 losers have 0) expect(sum).toBeGreaterThanOrEqual(0); expect(sum).toBeLessThanOrEqual(1.001); // All probabilities are non-negative expect(p.probFirst).toBeGreaterThanOrEqual(0); expect(p.probSecond).toBeGreaterThanOrEqual(0); } }); it("each probability column sums to 1.0 across all 16 participants", async () => { const allMatches = [ ...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)), ...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)), ...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)), makeFinalMatch(), ]; mockDb.query.playoffMatches.findMany.mockResolvedValue(allMatches); const sim = new UCLSimulator(); 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, eliminated teams have all zeros", async () => { // Full tournament completed: 8 R16 losers, 4 QF losers, 2 SF losers, 1 finalist, 1 champion // Use team-1 as champion path: R16m1→QFm1→SFm1→Final const champion = PARTICIPANT_IDS[0]; // team-1, participant1 of R16 match 1 const _finalist = PARTICIPANT_IDS[2]; // team-3, participant1 of R16 match 2 const r16Matches = Array.from({ length: 8 }, (_, i) => { const matchNum = i + 1; const p1 = PARTICIPANT_IDS[(matchNum - 1) * 2]; const p2 = PARTICIPANT_IDS[(matchNum - 1) * 2 + 1]; // Match 1: team-1 beats team-2. Rest: p2 wins. const winner = matchNum === 1 ? p1 : p2; const loser = matchNum === 1 ? p2 : p1; return makeR16Match(matchNum, { winnerId: winner, loserId: loser, isComplete: true }); }); // QF: R16 match 1 winner (team-1) + R16 match 2 winner (team-4) → QF match 1: team-1 wins // R16 match 3 winner (team-6) + R16 match 4 winner (team-8) → QF match 2: team-6 wins // QF match 3: team-10 wins, QF match 4: team-14 wins const _qfWinners = [champion, PARTICIPANT_IDS[3], PARTICIPANT_IDS[9], PARTICIPANT_IDS[13]]; const _qfLosers = [PARTICIPANT_IDS[3], PARTICIPANT_IDS[5], PARTICIPANT_IDS[7], PARTICIPANT_IDS[11]]; // Actually: QF1: team-1 beats team-4; QF2: team-6 beats team-8; etc. const qfMatches = Array.from({ length: 4 }, (_, i) => { const matchNum = i + 1; const r16w1 = r16Matches[(matchNum - 1) * 2].winnerId ?? ""; const r16w2 = r16Matches[(matchNum - 1) * 2 + 1].winnerId ?? ""; const winner = matchNum === 1 ? r16w1 : r16w2; const loser = matchNum === 1 ? r16w2 : r16w1; return makeQFMatch(matchNum, { winnerId: winner, loserId: loser, isComplete: true }); }); // SF: QF1 winner (team-1) + QF2 winner → SF1: team-1 wins const sf1Winner = qfMatches[0].winnerId ?? ""; const sf1Loser = qfMatches[1].winnerId ?? ""; const sf2Winner = qfMatches[2].winnerId ?? ""; const sf2Loser = qfMatches[3].winnerId ?? ""; const sfMatches = [ makeSFMatch(1, { winnerId: sf1Winner, loserId: sf1Loser, isComplete: true }), makeSFMatch(2, { winnerId: sf2Winner, loserId: sf2Loser, isComplete: true }), ]; // Final: sf1Winner (team-1) vs sf2Winner const actualChampion = sf1Winner; // team-1 const actualFinalist = sf2Winner; const finalMatch = makeFinalMatch({ winnerId: actualChampion, loserId: actualFinalist, isComplete: true }); mockDb.query.playoffMatches.findMany.mockResolvedValue([ ...r16Matches, ...qfMatches, ...sfMatches, finalMatch, ]); const sim = new UCLSimulator(); const results = await sim.simulate("season-1"); const championResult = results.find((r) => r.participantId === actualChampion); const finalistResult = results.find((r) => r.participantId === actualFinalist); if (!championResult || !finalistResult) throw new Error('Champion or finalist result not found'); // Champion must have probFirst = 1.0 expect(championResult.probabilities.probFirst).toBeCloseTo(1.0, 3); expect(championResult.probabilities.probSecond).toBeCloseTo(0, 3); // Finalist must have probSecond = 1.0 expect(finalistResult.probabilities.probFirst).toBeCloseTo(0, 3); expect(finalistResult.probabilities.probSecond).toBeCloseTo(1.0, 3); // R16 losers (teams that lost in R16) must have all probs = 0 const r16LoserId = r16Matches[0].loserId ?? ""; // team-2 lost in R16 match 1 const r16LoserResult = results.find((r) => r.participantId === r16LoserId); if (!r16LoserResult) throw new Error('R16 loser result not found'); const r16Sum = Object.values(r16LoserResult.probabilities).reduce((a, b) => a + b, 0); expect(r16Sum).toBeCloseTo(0, 5); // Verify source label expect(championResult.source).toBe("ucl_bracket_monte_carlo"); }); it("uses source: 'ucl_bracket_monte_carlo' on all results", async () => { const allMatches = [ ...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)), ...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)), ...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)), makeFinalMatch(), ]; mockDb.query.playoffMatches.findMany.mockResolvedValue(allMatches); const sim = new UCLSimulator(); const results = await sim.simulate("season-1"); for (const r of results) { expect(r.source).toBe("ucl_bracket_monte_carlo"); } }); });