import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { normalizeTeamName, getTeamData, eloWinProbability, simulateProjectedPoints, NHLSimulator, } from "../nhl-simulator"; // ─── normalizeTeamName ──────────────────────────────────────────────────────── describe("normalizeTeamName", () => { it("lowercases and trims", () => { expect(normalizeTeamName(" Colorado Avalanche ")).toBe("colorado avalanche"); }); it("collapses internal whitespace", () => { expect(normalizeTeamName("Tampa Bay Lightning")).toBe("tampa bay lightning"); }); it("is already-normalized identity", () => { expect(normalizeTeamName("dallas stars")).toBe("dallas stars"); }); }); // ─── getTeamData ────────────────────────────────────────────────────────────── describe("getTeamData", () => { it("returns data for an exact match", () => { const d = getTeamData("Colorado Avalanche"); expect(d).toBeDefined(); expect(d?.conference).toBe("Western"); expect(d?.division).toBe("Central"); expect(d?.elo).toBeGreaterThan(1500); }); it("is case-insensitive", () => { expect(getTeamData("colorado avalanche")).toEqual(getTeamData("Colorado Avalanche")); }); it("returns undefined for an unknown team", () => { expect(getTeamData("Springfield Ice Hounds")).toBeUndefined(); }); it("all 32 teams are present", () => { const allTeams = [ // Eastern — Atlantic "Tampa Bay Lightning", "Buffalo Sabres", "Montreal Canadiens", "Ottawa Senators", "Detroit Red Wings", "Boston Bruins", "Florida Panthers", "Toronto Maple Leafs", // Eastern — Metropolitan "Carolina Hurricanes", "Columbus Blue Jackets", "Pittsburgh Penguins", "New York Islanders", "Philadelphia Flyers", "Washington Capitals", "New Jersey Devils", "New York Rangers", // Western — Central "Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth", "Nashville Predators", "Winnipeg Jets", "St. Louis Blues", "Chicago Blackhawks", // Western — Pacific "Anaheim Ducks", "Edmonton Oilers", "Vegas Golden Knights", "Los Angeles Kings", "San Jose Sharks", "Seattle Kraken", "Calgary Flames", "Vancouver Canucks", ]; for (const name of allTeams) { expect(getTeamData(name), `missing team: ${name}`).toBeDefined(); } }); it("all 32 teams have a valid conference and division", () => { const validConferences = new Set(["Eastern", "Western"]); const validDivisions = new Set(["Atlantic", "Metropolitan", "Central", "Pacific"]); const allTeams = [ "Tampa Bay Lightning", "Buffalo Sabres", "Montreal Canadiens", "Ottawa Senators", "Detroit Red Wings", "Boston Bruins", "Florida Panthers", "Toronto Maple Leafs", "Carolina Hurricanes", "Columbus Blue Jackets", "Pittsburgh Penguins", "New York Islanders", "Philadelphia Flyers", "Washington Capitals", "New Jersey Devils", "New York Rangers", "Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth", "Nashville Predators", "Winnipeg Jets", "St. Louis Blues", "Chicago Blackhawks", "Anaheim Ducks", "Edmonton Oilers", "Vegas Golden Knights", "Los Angeles Kings", "San Jose Sharks", "Seattle Kraken", "Calgary Flames", "Vancouver Canucks", ]; for (const name of allTeams) { const data = getTeamData(name); expect(validConferences.has(data?.conference ?? ""), `${name}: invalid conference`).toBe(true); expect(validDivisions.has(data?.division ?? ""), `${name}: invalid division`).toBe(true); } }); it("conference/division assignments are correct for spot-checked teams", () => { expect(getTeamData("Carolina Hurricanes")?.division).toBe("Metropolitan"); expect(getTeamData("Colorado Avalanche")?.division).toBe("Central"); expect(getTeamData("Vegas Golden Knights")?.division).toBe("Pacific"); expect(getTeamData("Buffalo Sabres")?.conference).toBe("Eastern"); expect(getTeamData("Edmonton Oilers")?.conference).toBe("Western"); }); }); // ─── eloWinProbability ──────────────────────────────────────────────────────── describe("eloWinProbability (PARITY_FACTOR = 1000)", () => { it("returns 0.5 for equal Elo ratings", () => { expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6); }); it("favors the higher-rated team", () => { expect(eloWinProbability(1594, 1500)).toBeGreaterThan(0.5); expect(eloWinProbability(1500, 1594)).toBeLessThan(0.5); }); it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => { const p = eloWinProbability(1580, 1520); expect(p + eloWinProbability(1520, 1580)).toBeCloseTo(1.0, 10); }); it("a 100-pt gap gives ~55.7% win prob per game", () => { const p = eloWinProbability(1600, 1500); expect(p).toBeCloseTo(0.557, 2); }); it("a 200-pt gap gives ~61.3% win prob per game", () => { const p = eloWinProbability(1700, 1500); expect(p).toBeCloseTo(0.613, 2); }); }); // ─── simulateProjectedPoints ────────────────────────────────────────────────── const makeEntry = (overrides: Partial<{ currentPoints: number; remainingGames: number; winProb: number; }> = {}) => ({ id: "test", name: "Test Team", data: undefined, conference: "Western" as const, division: "Central", currentPoints: overrides.currentPoints ?? 80, remainingGames: overrides.remainingGames ?? 10, winProb: overrides.winProb ?? 0.55, resolvedElo: 1500, }); describe("simulateProjectedPoints", () => { it("returns exactly currentPoints when no games remain", () => { const entry = makeEntry({ currentPoints: 95, remainingGames: 0 }); expect(simulateProjectedPoints(entry)).toBe(95); }); it("returns at least currentPoints (points never decrease)", () => { const entry = makeEntry({ currentPoints: 70, remainingGames: 20, winProb: 0.5 }); for (let i = 0; i < 100; i++) { expect(simulateProjectedPoints(entry)).toBeGreaterThanOrEqual(70); } }); it("returns at most currentPoints + remainingGames * 2 (can't exceed max)", () => { const entry = makeEntry({ currentPoints: 60, remainingGames: 15, winProb: 0.9 }); for (let i = 0; i < 100; i++) { expect(simulateProjectedPoints(entry)).toBeLessThanOrEqual(60 + 15 * 2); } }); it("higher winProb produces higher projected points on average", () => { const base = makeEntry({ currentPoints: 0, remainingGames: 82 }); const runs = 2000; const avg = (winProb: number) => { let total = 0; for (let i = 0; i < runs; i++) { total += simulateProjectedPoints({ ...base, winProb }); } return total / runs; }; 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); if (!r1m3) throw new Error("R1 M3 not found in test fixture"); 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"); if (!final) throw new Error("Stanley Cup match not found in test fixture"); 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"); } }); });