brackt/app/services/simulations/__tests__/nhl-simulator.test.ts
Chris Parsons ef0ddeff39
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support

NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.

MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators

All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.

NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.

NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.

WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests

- NHLSimulator.simulateBracket: remove unused nameById variable
- NHLSimulator.simulateBracket: validate all R1 participants before entering
  Monte Carlo loop; throws with a clear message if any slot is missing
- wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter
  on resolveElo() (overrides SRS, overrides futures, overrides fallback,
  null/undefined fall through to normal priority chain)
- nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests)
  covering fallback to season-projection, structure validation errors, probability
  distributions, fully-decided champion, and source tag

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts

TeamEntry requires resolvedElo after the simulator refactor.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Fix lint: replace non-null assertions with null-safe alternatives in NHL test

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00

451 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);
});
});
// ─── 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");
}
});
});