brackt/app/services/simulations/__tests__/mlb-simulator.test.ts
Chris Parsons 2e2d8db777
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* 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>
2026-03-25 08:47:02 -07:00

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);
});
});