* 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>
173 lines
7 KiB
TypeScript
173 lines
7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
normalizeTeamName,
|
|
getTeamData,
|
|
eloWinProbability,
|
|
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();
|
|
}
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
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));
|
|
});
|
|
});
|