brackt/app/services/simulations/__tests__/nhl-simulator.test.ts
chrisp 3e50619629
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
claude/probability-config-review-1tdolw (#119)
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #119
2026-06-30 23:24:48 +00:00

458 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
it("a higher parity factor flattens the per-game win probability toward 0.5", () => {
const standard = eloWinProbability(1700, 1500); // default parity 1000
const flatter = eloWinProbability(1700, 1500, 2500); // season config override
expect(flatter).toBeLessThan(standard);
expect(flatter).toBeGreaterThan(0.5);
});
});
// ─── 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 18)
* R2: 4 matches (Divisional, matchNumbers 14) — participants null (filled from R1 winners)
* R3: 2 matches (Conference Finals, matchNumbers 12)
* 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");
}
});
});