- Add 70/30 Elo + Vegas futures blending for per-game win probability, mirroring the UCL simulator pattern; falls back to Elo-only when no sourceOdds are stored in participantExpectedValues - Adjust PARITY_FACTOR to better match Vegas championship implied probabilities (COL was ~12% vs ~20% market expectation) - Add 15 unit tests covering name normalization, team data lookup, parity-factor math, seeding probability sanity, and eliminated-team checks Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
136 lines
5.3 KiB
TypeScript
136 lines
5.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
normalizeTeamName,
|
|
getTeamData,
|
|
eloWinProbability,
|
|
} 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();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── 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", () => {
|
|
// At 1000: P = 1 / (1 + 10^(-100/1000)) ≈ 0.557
|
|
const p = eloWinProbability(1600, 1500);
|
|
expect(p).toBeCloseTo(0.557, 2);
|
|
});
|
|
|
|
it("a 200-pt gap gives ~61.3% win prob per game", () => {
|
|
// At 1000: P = 1 / (1 + 10^(-200/1000)) ≈ 0.613
|
|
const p = eloWinProbability(1700, 1500);
|
|
expect(p).toBeCloseTo(0.613, 2);
|
|
});
|
|
});
|
|
|
|
// ─── Seeding probabilities sanity checks ─────────────────────────────────────
|
|
|
|
describe("team seeding probabilities", () => {
|
|
it("playoff probability sums to ≤ 1.0 per team", () => {
|
|
const teamNames = [
|
|
"Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth",
|
|
"Carolina Hurricanes", "Buffalo Sabres", "Tampa Bay Lightning",
|
|
];
|
|
for (const name of teamNames) {
|
|
const d = getTeamData(name);
|
|
expect(d).toBeDefined();
|
|
const total =
|
|
(d?.p_div1 ?? 0) + (d?.p_div2 ?? 0) + (d?.p_div3 ?? 0) +
|
|
(d?.p_wc1 ?? 0) + (d?.p_wc2 ?? 0);
|
|
expect(total, `${name} playoff prob > 1`).toBeLessThanOrEqual(1.001);
|
|
}
|
|
});
|
|
|
|
it("eliminated teams have no seeding probability keys", () => {
|
|
const eliminated = ["Toronto Maple Leafs", "New York Rangers", "Chicago Blackhawks", "Vancouver Canucks"];
|
|
for (const name of eliminated) {
|
|
const d = getTeamData(name);
|
|
expect(d).toBeDefined();
|
|
const total =
|
|
(d?.p_div1 ?? 0) + (d?.p_div2 ?? 0) + (d?.p_div3 ?? 0) +
|
|
(d?.p_wc1 ?? 0) + (d?.p_wc2 ?? 0);
|
|
expect(total, `${name} should have 0 playoff probability`).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("division leaders have the highest p_div1 in their division", () => {
|
|
// COL should dominate Central div1
|
|
const col = getTeamData("Colorado Avalanche");
|
|
const dal = getTeamData("Dallas Stars");
|
|
expect((col?.p_div1 ?? 0)).toBeGreaterThan(dal?.p_div1 ?? 0);
|
|
|
|
// CAR should dominate Metro div1
|
|
const car = getTeamData("Carolina Hurricanes");
|
|
const cbj = getTeamData("Columbus Blue Jackets");
|
|
expect((car?.p_div1 ?? 0)).toBeGreaterThan(cbj?.p_div1 ?? 0);
|
|
});
|
|
});
|