import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { buildCollegeHockeyTeams, CollegeHockeySimulator, npiRankSelectionWeight, npiRankToElo, } from "../college-hockey-simulator"; vi.mock("~/database/context", () => ({ database: vi.fn(), })); const IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`); const POOL_IDS = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`); function makeParticipants(ids: string[]) { return ids.map((id) => ({ id })); } function makeEvRows(ids: string[]) { return ids.map((participantId, i) => ({ participantId, sourceElo: 1700 - i * 20, sourceOdds: null, worldRanking: i + 1, })); } function makeMatch(round: string, matchNumber: number, p1?: string, p2?: string, winner?: string, loser?: string) { return { id: `${round}-${matchNumber}`, scoringEventId: "event-1", round, templateRound: null, matchNumber, participant1Id: p1 ?? null, participant2Id: p2 ?? null, winnerId: winner ?? null, loserId: loser ?? null, isComplete: Boolean(winner), participant1Score: null, participant2Score: null, isScoring: round !== "Regional Semifinals", seedInfo: null, createdAt: new Date(), updatedAt: new Date(), }; } function makeCompletedBracket() { const r16Winners = IDS.slice(0, 8); const qfWinners = IDS.slice(0, 4); const sfWinners = IDS.slice(0, 2); return [ ...Array.from({ length: 8 }, (_, i) => makeMatch("Regional Semifinals", i + 1, IDS[i], IDS[15 - i], r16Winners[i], IDS[15 - i]) ), ...Array.from({ length: 4 }, (_, i) => makeMatch("Regional Finals", i + 1, r16Winners[i * 2], r16Winners[i * 2 + 1], qfWinners[i], r16Winners[i * 2 + 1]) ), ...Array.from({ length: 2 }, (_, i) => makeMatch("Frozen Four Semifinals", i + 1, qfWinners[i * 2], qfWinners[i * 2 + 1], sfWinners[i], qfWinners[i * 2 + 1]) ), makeMatch("National Championship", 1, sfWinners[0], sfWinners[1], sfWinners[0], sfWinners[1]), ]; } function makeBracketWithStaleFutureSlots() { return [ ...Array.from({ length: 8 }, (_, i) => makeMatch("Regional Semifinals", i + 1, IDS[i], IDS[15 - i]) ), // These incomplete downstream slots intentionally contain stale participants. // The simulator should ignore them and use the simulated/completed upstream winners. makeMatch("Regional Finals", 1, "team-9", "team-10"), makeMatch("Regional Finals", 2, "team-11", "team-12"), makeMatch("Regional Finals", 3, "team-13", "team-14"), makeMatch("Regional Finals", 4, "team-15", "team-16"), makeMatch("Frozen Four Semifinals", 1), makeMatch("Frozen Four Semifinals", 2), makeMatch("National Championship", 1), ]; } describe("college hockey input helpers", () => { it("maps better NPI ranks to higher Elo ratings", () => { expect(npiRankToElo(1, 64)).toBeGreaterThan(npiRankToElo(16, 64)); }); it("maps better NPI ranks to higher Zipf selection weights", () => { expect(npiRankSelectionWeight(1)).toBeGreaterThan(npiRankSelectionWeight(10)); }); it("builds team strength from admin-editable NPI rank when Elo is absent", () => { const teams = buildCollegeHockeyTeams(["one", "two"], [ { participantId: "one", sourceElo: null, sourceOdds: null, worldRanking: 1 }, { participantId: "two", sourceElo: null, sourceOdds: null, worldRanking: 20 }, ]); expect(teams[0].elo).toBeGreaterThan(teams[1].elo); expect(teams[0].selectionWeight).toBeGreaterThan(teams[1].selectionWeight); }); it("uses NPI rank before Elo for seeding when both are present", () => { const teams = buildCollegeHockeyTeams(["strong-elo", "better-npi"], [ { participantId: "strong-elo", sourceElo: 1800, sourceOdds: null, worldRanking: 12 }, { participantId: "better-npi", sourceElo: 1500, sourceOdds: null, worldRanking: 2 }, ]); const byId = new Map(teams.map((team) => [team.participantId, team])); expect(byId.get("better-npi")?.seedScore).toBeGreaterThan(byId.get("strong-elo")?.seedScore ?? 0); }); it("gives no-odds teams a fallback field-selection weight when odds are partial", () => { const teams = buildCollegeHockeyTeams(["with-odds", "no-odds"], [ { participantId: "with-odds", sourceElo: null, sourceOdds: 200, worldRanking: 1 }, { participantId: "no-odds", sourceElo: null, sourceOdds: null, worldRanking: 2 }, ]); const noOdds = teams.find((team) => team.participantId === "no-odds"); expect(noOdds?.selectionWeight).toBeGreaterThan(0); }); }); describe("CollegeHockeySimulator", () => { let mockDb: { query: { scoringEvents: { findFirst: MockInstance }; playoffMatches: { findMany: MockInstance }; }; select: MockInstance; }; let selectCallCount: number; beforeEach(async () => { selectCallCount = 0; const { database } = await import("~/database/context"); mockDb = { query: { scoringEvents: { findFirst: vi.fn() }, playoffMatches: { findMany: vi.fn() }, }, select: vi.fn(), }; (database as unknown as MockInstance).mockReturnValue(mockDb); }); function setupPreBracket(ids: string[]) { mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); mockDb.select.mockImplementation(() => { const data = selectCallCount++ === 0 ? makeParticipants(ids) : makeEvRows(ids); return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) }; }); } function setupExistingBracket(matches = makeCompletedBracket()) { mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" }); mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(makeEvRows(IDS)) }), })); } it("simulates a pre-bracket 20-team pool and returns all participants", async () => { setupPreBracket(POOL_IDS); const results = await new CollegeHockeySimulator().simulate("season-1"); expect(results).toHaveLength(20); expect(results.every((r) => r.source === "college_hockey_monte_carlo")).toBe(true); }); it("normalizes champion and finalist probability buckets", async () => { setupPreBracket(IDS); const results = await new CollegeHockeySimulator().simulate("season-1"); expect(results.reduce((sum, r) => sum + r.probabilities.probFirst, 0)).toBeCloseTo(1, 1); expect(results.reduce((sum, r) => sum + r.probabilities.probSecond, 0)).toBeCloseTo(1, 1); }); it("honors completed results in an existing Frozen Four bracket", async () => { setupExistingBracket(); const results = await new CollegeHockeySimulator().simulate("season-1"); const byId = new Map(results.map((result) => [result.participantId, result])); expect(byId.get("team-1")?.probabilities.probFirst).toBe(1); expect(byId.get("team-2")?.probabilities.probSecond).toBe(1); }); it("ignores stale future-round slots when upstream rounds are unresolved", async () => { setupExistingBracket(makeBracketWithStaleFutureSlots()); const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); try { const results = await new CollegeHockeySimulator().simulate("season-1"); const byId = new Map(results.map((result) => [result.participantId, result])); expect(byId.get("team-1")?.probabilities.probFirst).toBe(1); expect(byId.get("team-9")?.probabilities.probFirst).toBe(0); } finally { randomSpy.mockRestore(); } }); });