import { describe, it, expect, vi, beforeEach } from "vitest"; import { simGroupMatch, WorldCupSimulator } from "../world-cup-simulator"; // ─── Pure math: simGroupMatch ───────────────────────────────────────────────── describe("simGroupMatch", () => { it("returns win, draw, or loss", () => { const results = new Set(); for (let i = 0; i < 300; i++) { results.add(simGroupMatch(1800, 1600)); } // All three outcomes should appear in 300 trials expect(results.has("win")).toBe(true); expect(results.has("draw")).toBe(true); expect(results.has("loss")).toBe(true); }); it("equal teams draw ≈28% of the time", () => { let draws = 0; const N = 5_000; for (let i = 0; i < N; i++) { if (simGroupMatch(1500, 1500) === "draw") draws++; } const drawRate = draws / N; // BASE_DRAW_RATE = 0.28 at eloDiff=0; accept ±5% from sampling noise at N=5k expect(drawRate).toBeGreaterThan(0.23); expect(drawRate).toBeLessThan(0.33); }); it("draw rate decays with large Elo gap", () => { let draws = 0; const N = 5_000; for (let i = 0; i < N; i++) { if (simGroupMatch(2000, 1400) === "draw") draws++; } const drawRate = draws / N; // eloDiff = 600 → pDraw ≈ 0.28 * exp(-1.2) ≈ 0.084; accept ±5% expect(drawRate).toBeLessThan(0.15); }); it("strong favorite wins more often than underdog", () => { let wins = 0; let losses = 0; const N = 5_000; for (let i = 0; i < N; i++) { const r = simGroupMatch(1900, 1600); if (r === "win") wins++; if (r === "loss") losses++; } expect(wins).toBeGreaterThan(losses); }); it("results sum to 1.0 (no probability mass lost)", () => { let wins = 0, draws = 0, losses = 0; const N = 10_000; for (let i = 0; i < N; i++) { const r = simGroupMatch(1700, 1700); if (r === "win") wins++; else if (r === "draw") draws++; else losses++; } expect(wins + draws + losses).toBe(N); }); }); // ─── WorldCupSimulator (with mocked DB) ─────────────────────────────────────── // Build 48 mock participants (ids p0..p47) function makeParticipants(count = 48) { return Array.from({ length: count }, (_, i) => ({ id: `p${i}`, name: `Team${i}`, sportsSeasonId: "season-1", })); } vi.mock("~/database/context", () => ({ database: vi.fn(), })); import { database } from "~/database/context"; const mockDb = { query: { participants: { findMany: vi.fn() }, scoringEvents: { findFirst: vi.fn() }, tournamentGroups: { findMany: vi.fn() }, playoffMatches: { findMany: vi.fn() }, }, select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([]), }; beforeEach(() => { vi.mocked(database).mockReturnValue(mockDb as never); mockDb.query.participants.findMany.mockReset(); mockDb.query.scoringEvents.findFirst.mockReset(); mockDb.query.tournamentGroups.findMany.mockReset(); mockDb.query.playoffMatches.findMany.mockReset(); mockDb.select.mockReturnValue(mockDb); mockDb.from.mockReturnValue(mockDb); mockDb.where.mockResolvedValue([]); }); describe("WorldCupSimulator", () => { it("throws when no participants are found", async () => { mockDb.query.participants.findMany.mockResolvedValue([]); mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(500); await expect(sim.simulate("season-1")).rejects.toThrow("No participants found"); }); it("returns one result per participant", async () => { const participants = makeParticipants(48); mockDb.query.participants.findMany.mockResolvedValue(participants); mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(500); const results = await sim.simulate("season-1"); expect(results).toHaveLength(48); const ids = new Set(results.map((r) => r.participantId)); for (const p of participants) { expect(ids.has(p.id)).toBe(true); } }); it("column sums for champion, runner-up, 3rd, 4th are each ≈1.0", async () => { const participants = makeParticipants(48); mockDb.query.participants.findMany.mockResolvedValue(participants); mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(500); const results = await sim.simulate("season-1"); const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0); const sumSecond = results.reduce((s, r) => s + r.probabilities.probSecond, 0); const sumThird = results.reduce((s, r) => s + r.probabilities.probThird, 0); const sumFourth = results.reduce((s, r) => s + r.probabilities.probFourth, 0); expect(sumFirst).toBeCloseTo(1.0, 2); expect(sumSecond).toBeCloseTo(1.0, 2); expect(sumThird).toBeCloseTo(1.0, 2); expect(sumFourth).toBeCloseTo(1.0, 2); }); it("probabilities are all non-negative", async () => { const participants = makeParticipants(48); mockDb.query.participants.findMany.mockResolvedValue(participants); mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(500); const results = await sim.simulate("season-1"); for (const r of results) { const { probFirst, probSecond, probThird, probFourth, probFifth } = r.probabilities; expect(probFirst).toBeGreaterThanOrEqual(0); expect(probSecond).toBeGreaterThanOrEqual(0); expect(probThird).toBeGreaterThanOrEqual(0); expect(probFourth).toBeGreaterThanOrEqual(0); expect(probFifth).toBeGreaterThanOrEqual(0); } }); it("SF losers land in 3rd or 4th, never 1st or 2nd", async () => { // Set up 8 participants (small bracket, 2 groups of 4) const participants = makeParticipants(8); mockDb.query.participants.findMany.mockResolvedValue(participants); mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(500); const results = await sim.simulate("season-1"); // probFirst + probSecond + probThird + probFourth should cover all probability mass // No team should have probThird or probFourth < 0 for (const r of results) { expect(r.probabilities.probFirst).toBeGreaterThanOrEqual(0); expect(r.probabilities.probThird).toBeGreaterThanOrEqual(0); expect(r.probabilities.probFourth).toBeGreaterThanOrEqual(0); // A champion should have 0 chance at 3rd/4th AND vice versa // (not guaranteed in aggregate but probFirst + probThird can't both be 1) expect(r.probabilities.probFirst + r.probabilities.probThird).toBeLessThanOrEqual(1.01); } }); it("a team with pre-completed group stage result is fixed in simulation", async () => { const participants = makeParticipants(48); mockDb.query.participants.findMany.mockResolvedValue(participants); mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" }); // One group fully complete: p0 wins everything, p3 loses everything const group = { id: "group-a", groupName: "A", scoringEventId: "event-1", members: [ { participantId: "p0" }, { participantId: "p1" }, { participantId: "p2" }, { participantId: "p3" }, ], matches: [ { participant1Id: "p0", participant2Id: "p1", participant1Score: 3, participant2Score: 0, isComplete: true, matchday: 1 }, { participant1Id: "p2", participant2Id: "p3", participant1Score: 2, participant2Score: 0, isComplete: true, matchday: 1 }, { participant1Id: "p0", participant2Id: "p2", participant1Score: 2, participant2Score: 0, isComplete: true, matchday: 2 }, { participant1Id: "p1", participant2Id: "p3", participant1Score: 1, participant2Score: 0, isComplete: true, matchday: 2 }, { participant1Id: "p0", participant2Id: "p3", participant1Score: 1, participant2Score: 0, isComplete: true, matchday: 3 }, { participant1Id: "p1", participant2Id: "p2", participant1Score: 1, participant2Score: 1, isComplete: true, matchday: 3 }, ], }; // Remaining 44 participants in 11 synthetic groups const remainingGroups = Array.from({ length: 11 }, (_, gi) => ({ id: `group-${gi + 2}`, groupName: String.fromCharCode(66 + gi), scoringEventId: "event-1", members: [ { participantId: `p${(gi + 1) * 4 + 0}` }, { participantId: `p${(gi + 1) * 4 + 1}` }, { participantId: `p${(gi + 1) * 4 + 2}` }, { participantId: `p${(gi + 1) * 4 + 3}` }, ], matches: [], })); mockDb.query.tournamentGroups.findMany.mockResolvedValue([group, ...remainingGroups]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(500); const results = await sim.simulate("season-1"); const p3Result = results.find((r) => r.participantId === "p3"); expect(p3Result).toBeDefined(); // p3 lost all 3 group games (0 pts) — always last in group, never advances // → probFirst = probSecond = probThird = probFourth = 0 expect(p3Result?.probabilities.probFirst).toBe(0); expect(p3Result?.probabilities.probSecond).toBe(0); expect(p3Result?.probabilities.probThird).toBe(0); expect(p3Result?.probabilities.probFourth).toBe(0); }); it("probFifth through probEighth are equal for each participant (QF losers split evenly)", async () => { const participants = makeParticipants(48); mockDb.query.participants.findMany.mockResolvedValue(participants); mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(500); const results = await sim.simulate("season-1"); for (const r of results) { const { probFifth, probSixth, probSeventh, probEighth } = r.probabilities; expect(probFifth).toBeCloseTo(probSixth, 10); expect(probSixth).toBeCloseTo(probSeventh, 10); expect(probSeventh).toBeCloseTo(probEighth, 10); } }); });