import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { LLWSSimulator } from "../llws-simulator"; vi.mock("~/database/context", () => ({ database: vi.fn(), })); vi.mock("~/services/probability-engine", async (importOriginal) => { const actual = await importOriginal() as Record; return { ...actual }; }); // ─── Fixtures ───────────────────────────────────────────────────────────────── const US_IDS = Array.from({ length: 10 }, (_, i) => `us-${i + 1}`); const INTL_IDS = Array.from({ length: 10 }, (_, i) => `intl-${i + 1}`); const ALL_IDS = [...US_IDS, ...INTL_IDS]; /** * Build EV rows with descending odds favouring the first team per side. * ids[0] is the strongest (best odds → lowest American number for favorites). */ function makeEvRows(ids: string[], opts: { includeOdds?: boolean } = {}) { return ids.map((participantId, i) => ({ participantId, sourceOdds: opts.includeOdds ? (i === 0 ? -300 : 200 + i * 100) : null, })); } // ─── Tests ──────────────────────────────────────────────────────────────────── describe("LLWSSimulator", () => { let mockDb: { select: MockInstance }; let selectCallCount: number; beforeEach(async () => { selectCallCount = 0; const { database } = await import("~/database/context"); mockDb = { select: vi.fn() }; (database as unknown as MockInstance).mockReturnValue(mockDb); }); function setupMockDb( participants: { id: string; externalId: string }[], evRows: { participantId: string; sourceOdds: number | null }[] ) { mockDb.select.mockImplementation(() => { const callIndex = selectCallCount++; const data = callIndex === 0 ? participants : evRows; return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) }; }); } function defaultParticipants(mode: "randomized" | "fixed" = "randomized") { if (mode === "fixed") { const usA = US_IDS.slice(0, 5).map((id) => ({ id, externalId: "US:A" })); const usB = US_IDS.slice(5).map((id) => ({ id, externalId: "US:B" })); const intlA = INTL_IDS.slice(0, 5).map((id) => ({ id, externalId: "Intl:A" })); const intlB = INTL_IDS.slice(5).map((id) => ({ id, externalId: "Intl:B" })); return [...usA, ...usB, ...intlA, ...intlB]; } return [ ...US_IDS.map((id) => ({ id, externalId: "US" })), ...INTL_IDS.map((id) => ({ id, externalId: "Intl" })), ]; } // ── Core output structure ───────────────────────────────────────────────── describe("output structure", () => { it("returns one result per participant (20 total)", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); expect(results).toHaveLength(20); }); it("every result has source 'llws_monte_carlo'", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); for (const r of results) { expect(r.source).toBe("llws_monte_carlo"); } }); it("all probability values are between 0 and 1", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); for (const r of results) { const p = r.probabilities; for (const v of Object.values(p)) { expect(v).toBeGreaterThanOrEqual(0); expect(v).toBeLessThanOrEqual(1); } } }); }); // ── Probability conservation ────────────────────────────────────────────── describe("probability conservation (one winner per sim)", () => { it("probFirst sums to ~1.0 across all participants", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); }); it("probSecond sums to ~1.0 across all participants", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probSecond, 0); expect(total).toBeCloseTo(1.0, 1); }); it("probThird sums to ~1.0 across all participants", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probThird, 0); expect(total).toBeCloseTo(1.0, 1); }); it("probFourth sums to ~1.0 across all participants", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probFourth, 0); expect(total).toBeCloseTo(1.0, 1); }); it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); // 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0 const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0); expect(total).toBeCloseTo(1.0, 1); }); it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); for (const r of results) { const p = r.probabilities; expect(p.probFifth).toBeCloseTo(p.probSixth, 10); expect(p.probSixth).toBeCloseTo(p.probSeventh, 10); expect(p.probSeventh).toBeCloseTo(p.probEighth, 10); } }); }); // ── Odds-driven probability ─────────────────────────────────────────────── describe("odds-driven win probability", () => { it("strong favourite (us-1) has higher probFirst than a weak team", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true })); const results = await new LLWSSimulator().simulate("season-1"); const byId = new Map(results.map((r) => [r.participantId, r])); const us1prob = byId.get("us-1")?.probabilities.probFirst ?? 0; const us10prob = byId.get("us-10")?.probabilities.probFirst ?? 0; expect(us1prob).toBeGreaterThan(us10prob); }); it("works when no odds are entered (all 50/50 fallback)", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); // no odds const results = await new LLWSSimulator().simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); }); it("with equal odds, each team wins the championship roughly equally", async () => { // Equal positive odds (+5000 for every team) → vig-removed prob ≈ 1/20 each. const eqOddsRows = ALL_IDS.map((id) => ({ participantId: id, sourceOdds: 5000 })); setupMockDb(defaultParticipants(), eqOddsRows); const results = await new LLWSSimulator().simulate("season-1"); for (const r of results) { // With equal odds and random pools, each team should win ~5% of the time. // Allow a generous band given Monte Carlo variance. expect(r.probabilities.probFirst).toBeGreaterThan(0.01); expect(r.probabilities.probFirst).toBeLessThan(0.15); } }); }); // ── Pool assignment modes ───────────────────────────────────────────────── describe("pool assignment modes", () => { it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => { setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); expect(results).toHaveLength(20); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); }); it("mixed mode: US fixed pools, Intl randomized", async () => { const participants = [ ...US_IDS.slice(0, 5).map((id) => ({ id, externalId: "US:A" })), ...US_IDS.slice(5).map((id) => ({ id, externalId: "US:B" })), ...INTL_IDS.map((id) => ({ id, externalId: "Intl" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); const results = await new LLWSSimulator().simulate("season-1"); expect(results).toHaveLength(20); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); }); }); // ── Error cases ─────────────────────────────────────────────────────────── describe("error cases", () => { it("throws when participant count is not 20", async () => { const nineteen = [...US_IDS, ...INTL_IDS.slice(0, 9)]; const participants = nineteen.map((id, i) => ({ id, externalId: i < 10 ? "US" : "Intl", })); setupMockDb(participants, makeEvRows(nineteen)); await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/exactly 20/); }); it("throws when a participant has a missing externalId", async () => { const participants = [ ...US_IDS.map((id) => ({ id, externalId: "US" })), ...INTL_IDS.slice(0, 9).map((id) => ({ id, externalId: "Intl" })), { id: "intl-10", externalId: null as unknown as string }, // missing ]; setupMockDb(participants, makeEvRows(ALL_IDS)); await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/invalid or missing externalId/); }); it("throws when a participant has an unrecognized externalId", async () => { const participants = [ ...US_IDS.map((id) => ({ id, externalId: "US" })), ...INTL_IDS.slice(0, 9).map((id) => ({ id, externalId: "Intl" })), { id: "intl-10", externalId: "CANADA" }, // unrecognized ]; setupMockDb(participants, makeEvRows(ALL_IDS)); await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/invalid or missing externalId/); }); it("throws when US team count is not 10", async () => { // 11 US teams, 9 International const participants = [ ...Array.from({ length: 11 }, (_, i) => ({ id: `us-${i + 1}`, externalId: "US" })), ...Array.from({ length: 9 }, (_, i) => ({ id: `intl-${i + 1}`, externalId: "Intl" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/10 US teams/); }); it("throws when fixed pools have unequal A/B split", async () => { const participants = [ // 6 in Pool A, 4 in Pool B ...US_IDS.slice(0, 6).map((id) => ({ id, externalId: "US:A" })), ...US_IDS.slice(6).map((id) => ({ id, externalId: "US:B" })), ...INTL_IDS.map((id) => ({ id, externalId: "Intl" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/exactly 5 teams each/); }); it("throws when US externalIds mix pool suffixes and bare side", async () => { const participants = [ // Some US:A, some "US" (no pool suffix) → mixed ...US_IDS.slice(0, 5).map((id) => ({ id, externalId: "US:A" })), ...US_IDS.slice(5).map((id) => ({ id, externalId: "US" })), // no pool ...INTL_IDS.map((id) => ({ id, externalId: "Intl" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/mixed externalId formats/); }); }); });