* Add MLB playoff simulator with AL/NL division standings, fixes #121 - New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners (weighted by p_div) and 3 WC teams per league, then simulating WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series. Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available. - New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API, mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits. - New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division winner per division section + Wild Card section with 3-spot playoff line, headings read "AL/NL League" instead of "Conference". - Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections (divisionSpots + wcSpots params); conferenceLabel now flows through group objects rather than being derived from displayMode in the render. - DB migration 0062 adds `mlb_bracket` to simulator_type enum. - 37 new tests across simulator, adapter, and component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix flaky tennis simulator test: increase trials and lower threshold 500 trials with a ~3–5% expected win rate had enough variance to occasionally land below the 0.03 threshold (~2% failure rate). Bumping to 2000 trials reduces the std dev by 2x; lowering the threshold to 0.02 keeps the assertion meaningful (still well above random 0.78%) while eliminating the flakiness. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
211 lines
8.6 KiB
TypeScript
211 lines
8.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
normalizeTeamName,
|
|
getTeamData,
|
|
eloWinProbability,
|
|
simBo3,
|
|
simBo5,
|
|
simBo7,
|
|
} from "../mlb-simulator";
|
|
|
|
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
|
|
|
describe("normalizeTeamName", () => {
|
|
it("lowercases and trims", () => {
|
|
expect(normalizeTeamName(" Los Angeles Dodgers ")).toBe("los angeles dodgers");
|
|
});
|
|
|
|
it("collapses internal whitespace", () => {
|
|
expect(normalizeTeamName("New York Yankees")).toBe("new york yankees");
|
|
});
|
|
|
|
it("is identity for already-normalized strings", () => {
|
|
expect(normalizeTeamName("houston astros")).toBe("houston astros");
|
|
});
|
|
});
|
|
|
|
// ─── getTeamData ──────────────────────────────────────────────────────────────
|
|
|
|
describe("getTeamData", () => {
|
|
it("returns data for an exact match", () => {
|
|
const d = getTeamData("Los Angeles Dodgers");
|
|
expect(d).toBeDefined();
|
|
expect(d?.league).toBe("NL");
|
|
expect(d?.division).toBe("NL West");
|
|
expect(d?.elo).toBeGreaterThan(1500);
|
|
});
|
|
|
|
it("is case-insensitive", () => {
|
|
expect(getTeamData("los angeles dodgers")).toEqual(getTeamData("Los Angeles Dodgers"));
|
|
});
|
|
|
|
it("returns undefined for an unknown team", () => {
|
|
expect(getTeamData("Springfield Isotopes")).toBeUndefined();
|
|
});
|
|
|
|
it("all 30 MLB teams are present", () => {
|
|
const allTeams = [
|
|
// AL East
|
|
"New York Yankees", "Baltimore Orioles", "Boston Red Sox",
|
|
"Tampa Bay Rays", "Toronto Blue Jays",
|
|
// AL Central
|
|
"Kansas City Royals", "Cleveland Guardians", "Minnesota Twins",
|
|
"Detroit Tigers", "Chicago White Sox",
|
|
// AL West
|
|
"Houston Astros", "Seattle Mariners", "Texas Rangers",
|
|
"Los Angeles Angels", "Athletics",
|
|
// NL East
|
|
"Philadelphia Phillies", "Atlanta Braves", "New York Mets",
|
|
"Washington Nationals", "Miami Marlins",
|
|
// NL Central
|
|
"Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals",
|
|
"Cincinnati Reds", "Pittsburgh Pirates",
|
|
// NL West
|
|
"Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks",
|
|
"San Francisco Giants", "Colorado Rockies",
|
|
];
|
|
for (const name of allTeams) {
|
|
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("all teams have the correct league", () => {
|
|
const alTeams = [
|
|
"New York Yankees", "Baltimore Orioles", "Boston Red Sox", "Tampa Bay Rays", "Toronto Blue Jays",
|
|
"Kansas City Royals", "Cleveland Guardians", "Minnesota Twins", "Detroit Tigers", "Chicago White Sox",
|
|
"Houston Astros", "Seattle Mariners", "Texas Rangers", "Los Angeles Angels", "Athletics",
|
|
];
|
|
const nlTeams = [
|
|
"Philadelphia Phillies", "Atlanta Braves", "New York Mets", "Washington Nationals", "Miami Marlins",
|
|
"Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals", "Cincinnati Reds", "Pittsburgh Pirates",
|
|
"Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks", "San Francisco Giants", "Colorado Rockies",
|
|
];
|
|
for (const name of alTeams) {
|
|
expect(getTeamData(name)?.league, `${name} should be AL`).toBe("AL");
|
|
}
|
|
for (const name of nlTeams) {
|
|
expect(getTeamData(name)?.league, `${name} should be NL`).toBe("NL");
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── eloWinProbability ────────────────────────────────────────────────────────
|
|
|
|
describe("eloWinProbability (PARITY_FACTOR = 350)", () => {
|
|
it("returns 0.5 for equal Elo ratings", () => {
|
|
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
|
|
});
|
|
|
|
it("favors the higher-rated team", () => {
|
|
expect(eloWinProbability(1570, 1500)).toBeGreaterThan(0.5);
|
|
expect(eloWinProbability(1500, 1570)).toBeLessThan(0.5);
|
|
});
|
|
|
|
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
|
|
const p = eloWinProbability(1560, 1480);
|
|
expect(p + eloWinProbability(1480, 1560)).toBeCloseTo(1.0, 10);
|
|
});
|
|
|
|
it("a 100-pt gap gives ~65.9% win prob per game", () => {
|
|
// At 350: P = 1 / (1 + 10^(-100/350)) = 1 / (1 + 0.518) ≈ 0.659
|
|
const p = eloWinProbability(1600, 1500);
|
|
expect(p).toBeCloseTo(0.659, 2);
|
|
});
|
|
|
|
it("returns a value strictly between 0 and 1", () => {
|
|
expect(eloWinProbability(1200, 1800)).toBeGreaterThan(0);
|
|
expect(eloWinProbability(1200, 1800)).toBeLessThan(1);
|
|
});
|
|
});
|
|
|
|
// ─── Seeding probabilities sanity checks ─────────────────────────────────────
|
|
|
|
describe("team seeding probabilities", () => {
|
|
const DIVISIONS = {
|
|
"AL East": ["New York Yankees", "Baltimore Orioles", "Boston Red Sox", "Tampa Bay Rays", "Toronto Blue Jays"],
|
|
"AL Central": ["Kansas City Royals", "Cleveland Guardians", "Minnesota Twins", "Detroit Tigers", "Chicago White Sox"],
|
|
"AL West": ["Houston Astros", "Seattle Mariners", "Texas Rangers", "Los Angeles Angels", "Athletics"],
|
|
"NL East": ["Philadelphia Phillies", "Atlanta Braves", "New York Mets", "Washington Nationals", "Miami Marlins"],
|
|
"NL Central": ["Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals", "Cincinnati Reds", "Pittsburgh Pirates"],
|
|
"NL West": ["Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks", "San Francisco Giants", "Colorado Rockies"],
|
|
};
|
|
|
|
it("p_div values sum to ~1.0 within each division", () => {
|
|
for (const [divName, teams] of Object.entries(DIVISIONS)) {
|
|
const sum = teams.reduce((s, name) => s + (getTeamData(name)?.p_div ?? 0), 0);
|
|
expect(sum, `${divName} p_div sum should be ~1`).toBeCloseTo(1.0, 1);
|
|
}
|
|
});
|
|
|
|
it("p_div + p_wc does not exceed 1.0 per team", () => {
|
|
for (const teams of Object.values(DIVISIONS)) {
|
|
for (const name of teams) {
|
|
const d = getTeamData(name);
|
|
expect((d?.p_div ?? 0) + (d?.p_wc ?? 0), `${name} p_div + p_wc > 1`).toBeLessThanOrEqual(1.001);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("Dodgers have the highest p_div in NL West", () => {
|
|
const dodgers = getTeamData("Los Angeles Dodgers");
|
|
const padres = getTeamData("San Diego Padres");
|
|
const dbacks = getTeamData("Arizona Diamondbacks");
|
|
expect(dodgers?.p_div ?? 0).toBeGreaterThan(padres?.p_div ?? 0);
|
|
expect(dodgers?.p_div ?? 0).toBeGreaterThan(dbacks?.p_div ?? 0);
|
|
});
|
|
|
|
it("rebuilding teams have low p_div", () => {
|
|
const whiteSox = getTeamData("Chicago White Sox");
|
|
const rockies = getTeamData("Colorado Rockies");
|
|
expect(whiteSox?.p_div ?? 0).toBeLessThan(0.15);
|
|
expect(rockies?.p_div ?? 0).toBeLessThan(0.10);
|
|
});
|
|
});
|
|
|
|
// ─── Series simulators ────────────────────────────────────────────────────────
|
|
|
|
const teamA = { id: "a", name: "Team A", data: undefined };
|
|
const teamB = { id: "b", name: "Team B", data: undefined };
|
|
const alwaysA = () => 1.0; // team A always wins each game
|
|
const alwaysB = () => 0.0; // team B always wins each game
|
|
const coinFlip = () => 0.5;
|
|
|
|
describe("simBo3 (Wildcard Round — first to 2 wins)", () => {
|
|
it("correct team wins when one side is dominant", () => {
|
|
expect(simBo3(teamA, teamB, alwaysA).winner).toBe(teamA);
|
|
expect(simBo3(teamA, teamB, alwaysB).winner).toBe(teamB);
|
|
});
|
|
|
|
it("returns a winner and loser", () => {
|
|
const result = simBo3(teamA, teamB, coinFlip);
|
|
expect([teamA, teamB]).toContain(result.winner);
|
|
expect([teamA, teamB]).toContain(result.loser);
|
|
expect(result.winner).not.toBe(result.loser);
|
|
});
|
|
});
|
|
|
|
describe("simBo5 (Division Series — first to 3 wins)", () => {
|
|
it("correct team wins when one side is dominant", () => {
|
|
expect(simBo5(teamA, teamB, alwaysA).winner).toBe(teamA);
|
|
expect(simBo5(teamA, teamB, alwaysB).winner).toBe(teamB);
|
|
});
|
|
|
|
it("returns a winner and loser", () => {
|
|
const result = simBo5(teamA, teamB, coinFlip);
|
|
expect([teamA, teamB]).toContain(result.winner);
|
|
expect(result.winner).not.toBe(result.loser);
|
|
});
|
|
});
|
|
|
|
describe("simBo7 (LCS / World Series — first to 4 wins)", () => {
|
|
it("correct team wins when one side is dominant", () => {
|
|
expect(simBo7(teamA, teamB, alwaysA).winner).toBe(teamA);
|
|
expect(simBo7(teamA, teamB, alwaysB).winner).toBe(teamB);
|
|
});
|
|
|
|
it("returns a winner and loser", () => {
|
|
const result = simBo7(teamA, teamB, coinFlip);
|
|
expect([teamA, teamB]).toContain(result.winner);
|
|
expect(result.winner).not.toBe(result.loser);
|
|
});
|
|
});
|