diff --git a/app/services/simulations/__tests__/nhl-simulator.test.ts b/app/services/simulations/__tests__/nhl-simulator.test.ts index 80e2029..a04ad61 100644 --- a/app/services/simulations/__tests__/nhl-simulator.test.ts +++ b/app/services/simulations/__tests__/nhl-simulator.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { normalizeTeamName, getTeamData, eloWinProbability, simulateProjectedPoints, + NHLSimulator, } from "../nhl-simulator"; // ─── normalizeTeamName ──────────────────────────────────────────────────────── @@ -171,3 +172,277 @@ describe("simulateProjectedPoints", () => { expect(avg(0.7)).toBeGreaterThan(avg(0.4)); }); }); + +// ─── NHLSimulator — bracket-aware mode ─────────────────────────────────────── + +const TEAM_IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`); + +function makeMatch( + round: string, + matchNumber: number, + opts: { + p1?: string | null; + p2?: string | null; + winnerId?: string | null; + loserId?: string | null; + isComplete?: boolean; + } = {} +) { + return { + id: `${round.replace(/\s/g, "-").toLowerCase()}-m${matchNumber}`, + scoringEventId: "event-1", + round, + matchNumber, + participant1Id: opts.p1 ?? null, + participant2Id: opts.p2 ?? null, + winnerId: opts.winnerId ?? null, + loserId: opts.loserId ?? null, + isComplete: opts.isComplete ?? false, + isScoring: false, + templateRound: round, + seedInfo: null, + participant1Score: null, + participant2Score: null, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +/** + * Builds a full 16-team NHL bracket fixture: + * R1: 8 matches (Wild Card, matchNumbers 1–8) + * R2: 4 matches (Divisional, matchNumbers 1–4) — participants null (filled from R1 winners) + * R3: 2 matches (Conference Finals, matchNumbers 1–2) + * Final: 1 match (Stanley Cup, matchNumber 1) + */ +function buildFullNHLBracket() { + const t = TEAM_IDS; + return [ + makeMatch("Wild Card", 1, { p1: t[0], p2: t[15] }), + makeMatch("Wild Card", 2, { p1: t[1], p2: t[14] }), + makeMatch("Wild Card", 3, { p1: t[2], p2: t[13] }), + makeMatch("Wild Card", 4, { p1: t[3], p2: t[12] }), + makeMatch("Wild Card", 5, { p1: t[4], p2: t[11] }), + makeMatch("Wild Card", 6, { p1: t[5], p2: t[10] }), + makeMatch("Wild Card", 7, { p1: t[6], p2: t[9] }), + makeMatch("Wild Card", 8, { p1: t[7], p2: t[8] }), + makeMatch("Divisional", 1), + makeMatch("Divisional", 2), + makeMatch("Divisional", 3), + makeMatch("Divisional", 4), + makeMatch("Conference Finals", 1), + makeMatch("Conference Finals", 2), + makeMatch("Stanley Cup", 1), + ]; +} + +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +vi.mock("~/models/regular-season-standings", () => ({ + getRegularSeasonStandings: vi.fn().mockResolvedValue([]), +})); + +describe("NHLSimulator — bracket-aware mode", () => { + 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( + TEAM_IDS.map((id) => ({ id, name: id })) + ), + }), + }), + }; + + (database as unknown as MockInstance).mockReturnValue(mockDb); + + mockDb.query.scoringEvents.findFirst.mockResolvedValue({ + id: "event-1", + sportsSeasonId: "season-1", + eventType: "playoff_game", + }); + }); + + it("falls back to season-projection when no bracket event exists", async () => { + mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); + + const sim = new NHLSimulator(); + // Season-projection fails: unknown team IDs → no division data → division check throws + await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/); + }); + + it("falls back to season-projection when bracket matches have no participants", async () => { + const emptyMatches = buildFullNHLBracket().map((m) => ({ + ...m, + participant1Id: null, + participant2Id: null, + })); + mockDb.query.playoffMatches.findMany.mockResolvedValue(emptyMatches); + + const sim = new NHLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/); + }); + + it("throws if bracket has fewer than 4 rounds", async () => { + const oneRound = Array.from({ length: 8 }, (_, i) => + makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 8] ?? TEAM_IDS[0] }) + ); + mockDb.query.playoffMatches.findMany.mockResolvedValue(oneRound); + + const sim = new NHLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/unexpected structure/); + }); + + it("throws if R1 has wrong match count", async () => { + const shortR1 = [ + ...Array.from({ length: 4 }, (_, i) => + makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 4] }) + ), + makeMatch("Divisional", 1), + makeMatch("Divisional", 2), + makeMatch("Conference Finals", 1), + makeMatch("Stanley Cup", 1), + ]; + mockDb.query.playoffMatches.findMany.mockResolvedValue(shortR1); + + const sim = new NHLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 8 first-round matches/); + }); + + it("throws if an R1 participant slot is missing", async () => { + const matches = buildFullNHLBracket(); + const r1m3 = matches.find((m) => m.round === "Wild Card" && m.matchNumber === 3)!; + r1m3.participant2Id = null; + + mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); + + const sim = new NHLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/); + }); + + it("returns results for all 16 participants", async () => { + mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket()); + + const sim = new NHLSimulator(); + const results = await sim.simulate("season-1"); + + expect(results).toHaveLength(16); + }); + + it("each probability column sums to 1.0 across all 16 participants", async () => { + mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket()); + + const sim = new NHLSimulator(); + 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, `column ${key} should sum to 1`).toBeCloseTo(1.0, 1); + } + }); + + it("all probabilities are non-negative", async () => { + mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket()); + + const sim = new NHLSimulator(); + const results = await sim.simulate("season-1"); + + for (const r of results) { + for (const v of Object.values(r.probabilities)) { + expect(v).toBeGreaterThanOrEqual(0); + } + } + }); + + it("champion has probFirst=1 when bracket is fully decided", async () => { + const t = TEAM_IDS; + const matches = buildFullNHLBracket(); + + // R1: p1 always wins + const r1 = matches.filter((m) => m.round === "Wild Card"); + for (const m of r1) { + m.winnerId = m.participant1Id; + m.loserId = m.participant2Id; + m.isComplete = true; + } + const r1Winners = r1.map((m) => m.winnerId!); + + // R2: fill from R1 winners, p1 always wins + const r2 = matches.filter((m) => m.round === "Divisional"); + for (let i = 0; i < 4; i++) { + r2[i].participant1Id = r1Winners[i * 2]; + r2[i].participant2Id = r1Winners[i * 2 + 1]; + r2[i].winnerId = r2[i].participant1Id; + r2[i].loserId = r2[i].participant2Id; + r2[i].isComplete = true; + } + const r2Winners = r2.map((m) => m.winnerId!); + + // R3: fill from R2 winners, p1 always wins + const r3 = matches.filter((m) => m.round === "Conference Finals"); + for (let i = 0; i < 2; i++) { + r3[i].participant1Id = r2Winners[i * 2]; + r3[i].participant2Id = r2Winners[i * 2 + 1]; + r3[i].winnerId = r3[i].participant1Id; + r3[i].loserId = r3[i].participant2Id; + r3[i].isComplete = true; + } + const r3Winners = r3.map((m) => m.winnerId!); + + // Final: t[0] vs t[1], t[0] wins + const champion = t[0]; + const final = matches.find((m) => m.round === "Stanley Cup")!; + final.participant1Id = r3Winners[0]; + final.participant2Id = r3Winners[1]; + final.winnerId = champion; + final.loserId = r3Winners[1]; + final.isComplete = true; + + mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); + + const sim = new NHLSimulator(); + const results = await sim.simulate("season-1"); + + const champResult = results.find((r) => r.participantId === champion); + expect(champResult, "champion result should exist").toBeDefined(); + expect(champResult?.probabilities.probFirst).toBeCloseTo(1.0, 3); + + for (const r of results) { + if (r.participantId !== champion) { + expect(r.probabilities.probFirst).toBeCloseTo(0, 3); + } + } + }); + + it("uses source 'nhl_bracket_monte_carlo' on all results", async () => { + mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket()); + + const sim = new NHLSimulator(); + const results = await sim.simulate("season-1"); + + for (const r of results) { + expect(r.source).toBe("nhl_bracket_monte_carlo"); + } + }); +}); diff --git a/app/services/simulations/__tests__/wnba-simulator.test.ts b/app/services/simulations/__tests__/wnba-simulator.test.ts index 1d6a20a..36d6fac 100644 --- a/app/services/simulations/__tests__/wnba-simulator.test.ts +++ b/app/services/simulations/__tests__/wnba-simulator.test.ts @@ -80,6 +80,26 @@ describe("resolveElo", () => { it("futures mode with no futures: falls back to 1500", () => { expect(resolveElo(8, null, false)).toBe(1500); }); + + it("sourceElo overrides SRS when in SRS mode", () => { + expect(resolveElo(5, 1600, true, 1750)).toBe(1750); + }); + + it("sourceElo overrides futures when not in SRS mode", () => { + expect(resolveElo(null, 1600, false, 1750)).toBe(1750); + }); + + it("sourceElo overrides 1500 fallback when all other signals are absent", () => { + expect(resolveElo(null, null, false, 1700)).toBe(1700); + }); + + it("null sourceElo falls through to normal priority chain", () => { + expect(resolveElo(5, 1600, true, null)).toBe(srsToElo(5)); + }); + + it("undefined sourceElo falls through to normal priority chain", () => { + expect(resolveElo(null, 1620, false, undefined)).toBe(1620); + }); }); // ─── simSeriesN ─────────────────────────────────────────────────────────────── diff --git a/app/services/simulations/nhl-simulator.ts b/app/services/simulations/nhl-simulator.ts index af175dd..5891634 100644 --- a/app/services/simulations/nhl-simulator.ts +++ b/app/services/simulations/nhl-simulator.ts @@ -607,6 +607,16 @@ export class NHLSimulator implements Simulator { ); } + // Validate all R1 matches have participants before entering the hot loop. + for (const m of r1Matches) { + if (!m.participant1Id || !m.participant2Id) { + throw new Error( + `Round 1 match ${m.matchNumber} is missing participants. ` + + `Seed all 16 teams into the bracket before running simulation.` + ); + } + } + // Load all participants and EV data in parallel. const [participantRows, evRows] = await Promise.all([ db @@ -624,7 +634,6 @@ export class NHLSimulator implements Simulator { ]); const allParticipantIds = participantRows.map((r) => r.id); - const nameById = new Map(participantRows.map((r) => [r.id, r.name])); const dbEloMap = new Map(); for (const row of evRows) {