brackt/app/services/simulations/__tests__/nhl-simulator.test.ts

174 lines
7 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from "vitest";
import {
normalizeTeamName,
getTeamData,
eloWinProbability,
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
simulateProjectedPoints,
} 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();
}
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
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);
});
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
// ─── 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,
});
describe("simulateProjectedPoints", () => {
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
it("returns exactly currentPoints when no games remain", () => {
const entry = makeEntry({ currentPoints: 95, remainingGames: 0 });
expect(simulateProjectedPoints(entry)).toBe(95);
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
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);
}
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
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);
}
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
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));
});
});