import { describe, it, expect, vi, beforeEach } from "vitest"; import { simGroupMatch, WorldCupSimulator } from "../world-cup-simulator"; vi.mock("~/lib/logger", () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); // ─── 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", })); } /** 12 canonical groups A–L, 4 members each (p0..p47), no completed matches. */ function makeCanonicalGroups() { return Array.from({ length: 12 }, (_, gi) => ({ id: `group-${gi}`, groupName: String.fromCharCode(65 + gi), // A..L scoringEventId: "event-1", members: [0, 1, 2, 3].map((s) => ({ participantId: `p${gi * 4 + s}` })), matches: [], })); } /** 16 Round-of-32 matches with both slots filled (p0..p31), seq pairing. */ function makeFullR32Draw() { return Array.from({ length: 16 }, (_, i) => ({ round: "Round of 32", matchNumber: i + 1, participant1Id: `p${i * 2}`, participant2Id: `p${i * 2 + 1}`, winnerId: null as string | null, loserId: null as string | null, isComplete: false, })); } vi.mock("~/database/context", () => ({ database: vi.fn(), })); import { database } from "~/database/context"; const mockDb = { query: { seasonParticipants: { findMany: vi.fn() }, scoringEvents: { findMany: vi.fn() }, tournamentGroups: { findMany: vi.fn() }, playoffMatches: { findMany: vi.fn() }, }, select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([]), }; /** Set the resolved bracket scoring event (or null for none). */ function mockBracketEvent(event: Record | null) { mockDb.query.scoringEvents.findMany.mockResolvedValue(event ? [event] : []); } beforeEach(() => { vi.mocked(database).mockReturnValue(mockDb as never); mockDb.query.seasonParticipants.findMany.mockReset(); mockDb.query.scoringEvents.findMany.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.seasonParticipants.findMany.mockResolvedValue([]); mockBracketEvent(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(20); await expect(sim.simulate("season-1")).rejects.toThrow("No participants found"); }); it("returns one result per participant", async () => { const participants = makeParticipants(48); mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(20); 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.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(20); 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("SF losers land in 3rd or 4th, never 1st or 2nd", async () => { const participants = makeParticipants(48); mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(50); 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.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent({ 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(20); 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.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(20); 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); } }); // ─── Regime selection & bracket honoring ────────────────────────────────── it("regime=draw: honors the real R32 draw and flags source as real bracket", async () => { const participants = makeParticipants(48); mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" }); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); // no groups, but draw exists const r32 = makeFullR32Draw(); // Lock R32 match 1: p0 beats p1, so p1 is eliminated in the Round of 32. r32[0] = { ...r32[0], winnerId: "p0", loserId: "p1", isComplete: true }; mockDb.query.playoffMatches.findMany.mockResolvedValue(r32); const sim = new WorldCupSimulator(50); const results = await sim.simulate("season-1"); expect(results[0].source).toContain("real bracket"); // p1 lost in the R32 → never earns any placement (scoring starts at QF). const p1 = results.find((r) => r.participantId === "p1")?.probabilities; const p1Mass = (p1?.probFirst ?? 0) + (p1?.probSecond ?? 0) + (p1?.probThird ?? 0) + (p1?.probFourth ?? 0) + (p1?.probFifth ?? 0) + (p1?.probSixth ?? 0) + (p1?.probSeventh ?? 0) + (p1?.probEighth ?? 0); expect(p1Mass).toBe(0); // Teams p32..p47 are not in the 32-team draw → also zero. const p40 = results.find((r) => r.participantId === "p40")?.probabilities; expect((p40?.probFirst ?? 0) + (p40?.probFifth ?? 0)).toBe(0); // Champion mass still normalizes to ~1. const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(sumFirst).toBeCloseTo(1.0, 2); }); it("partial R32 draw never places an already-drawn team into an empty slot twice", async () => { const participants = makeParticipants(48); mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" }); // Real, canonical groups so the empty slots get filled from group seeding. mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups()); // Partial draw: only match 1 is populated (p0 vs p1); matches 2–16 are empty, // so the seeded group results fill them — but must not re-place p0 or p1. const r32 = makeFullR32Draw().map((m, i) => i === 0 ? m : { ...m, participant1Id: null, loserId: null, participant2Id: null } ); mockDb.query.playoffMatches.findMany.mockResolvedValue(r32); const sim = new WorldCupSimulator(40); const results = await sim.simulate("season-1"); // A team can finish at most one placement per sim, so its total probability // mass is ≤ 1. A duplicated team could exceed that (e.g. champion + QF-loser // in the same iteration). Assert no team's mass exceeds 1. for (const r of results) { const p = r.probabilities; const mass = p.probFirst + p.probSecond + p.probThird + p.probFourth + p.probFifth + p.probSixth + p.probSeventh + p.probEighth; expect(mass).toBeLessThanOrEqual(1 + 1e-9); } // Champion mass still normalizes to ~1 (exactly one champion per sim). const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(sumFirst).toBeCloseTo(1.0, 2); }); it("completed Final locks in the champion and runner-up", async () => { const participants = makeParticipants(48); mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent({ id: "event-1" }); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([ { round: "Finals", matchNumber: 1, participant1Id: "p5", participant2Id: "p6", winnerId: "p5", loserId: "p6", isComplete: true }, ]); const sim = new WorldCupSimulator(30); const results = await sim.simulate("season-1"); expect(results.find((r) => r.participantId === "p5")?.probabilities.probFirst).toBe(1); expect(results.find((r) => r.participantId === "p6")?.probabilities.probSecond).toBe(1); }); it("regime=groups: real 12-group stage uses the 2026 bracket seeding source", async () => { const participants = makeParticipants(48); mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" }); mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups()); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); // no draw yet const sim = new WorldCupSimulator(40); const results = await sim.simulate("season-1"); expect(results[0].source).toContain("2026 bracket seeding"); const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(sumFirst).toBeCloseTo(1.0, 2); }); it("regime=futures: no groups and no draw falls back with a flagged source", async () => { const participants = makeParticipants(48); mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants); mockBracketEvent(null); mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); mockDb.query.playoffMatches.findMany.mockResolvedValue([]); const sim = new WorldCupSimulator(30); const results = await sim.simulate("season-1"); expect(results[0].source).toContain("futures fallback"); const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(sumFirst).toBeCloseTo(1.0, 2); }); });