brackt/app/services/simulations/__tests__/mlb-simulator.test.ts
Chris Parsons ef0ddeff39
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support

NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.

MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators

All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.

NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.

NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.

WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests

- NHLSimulator.simulateBracket: remove unused nameById variable
- NHLSimulator.simulateBracket: validate all R1 participants before entering
  Monte Carlo loop; throws with a clear message if any slot is missing
- wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter
  on resolveElo() (overrides SRS, overrides futures, overrides fallback,
  null/undefined fall through to normal priority chain)
- nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests)
  covering fallback to season-projection, structure validation errors, probability
  distributions, fully-decided champion, and source tag

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts

TeamEntry requires resolvedElo after the simulator refactor.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

* Fix lint: replace non-null assertions with null-safe alternatives in NHL test

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00

266 lines
11 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
normalizeTeamName,
getTeamData,
winRateFromRDif,
rdifWinProbability,
eloToRDif,
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?.rdif).toBeGreaterThan(0);
});
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");
}
});
it("Dodgers have the highest RDif in NL West", () => {
const dodgers = getTeamData("Los Angeles Dodgers")?.rdif ?? -999;
const padres = getTeamData("San Diego Padres")?.rdif ?? -999;
const dbacks = getTeamData("Arizona Diamondbacks")?.rdif ?? -999;
expect(dodgers).toBeGreaterThan(padres);
expect(dodgers).toBeGreaterThan(dbacks);
});
it("rebuilding teams have negative RDif", () => {
expect(getTeamData("Chicago White Sox")?.rdif ?? 0).toBeLessThan(0);
expect(getTeamData("Colorado Rockies")?.rdif ?? 0).toBeLessThan(0);
});
});
// ─── 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("Dodgers have the highest p_div in NL West", () => {
const dodgers = getTeamData("Los Angeles Dodgers")?.p_div ?? 0;
expect(dodgers).toBeGreaterThan(0.8);
});
it("rebuilding teams have very low p_div", () => {
expect(getTeamData("Chicago White Sox")?.p_div ?? 1).toBeLessThan(0.02);
expect(getTeamData("Colorado Rockies")?.p_div ?? 1).toBeLessThan(0.01);
});
});
// ─── winRateFromRDif ──────────────────────────────────────────────────────────
describe("winRateFromRDif", () => {
it("returns 0.5 for RDif = 0", () => {
expect(winRateFromRDif(0)).toBeCloseTo(0.5, 6);
});
it("returns a value above 0.5 for positive RDif", () => {
expect(winRateFromRDif(100)).toBeGreaterThan(0.5);
});
it("returns a value below 0.5 for negative RDif", () => {
expect(winRateFromRDif(-100)).toBeLessThan(0.5);
});
it("clamps to [0.01, 0.99]", () => {
expect(winRateFromRDif(10000)).toBe(0.99);
expect(winRateFromRDif(-10000)).toBe(0.01);
});
it("Dodgers (RDif +137) project to a win rate above .500", () => {
expect(winRateFromRDif(137)).toBeGreaterThan(0.5);
expect(winRateFromRDif(137)).toBeLessThan(0.65);
});
});
// ─── rdifWinProbability ───────────────────────────────────────────────────────
describe("rdifWinProbability (log5)", () => {
it("returns 0.5 for equal RDif", () => {
expect(rdifWinProbability(50, 50)).toBeCloseTo(0.5, 6);
});
it("favors the team with better RDif", () => {
expect(rdifWinProbability(100, 0)).toBeGreaterThan(0.5);
expect(rdifWinProbability(0, 100)).toBeLessThan(0.5);
});
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
const p = rdifWinProbability(80, -20);
expect(p + rdifWinProbability(-20, 80)).toBeCloseTo(1.0, 10);
});
it("returns a value strictly between 0 and 1", () => {
expect(rdifWinProbability(200, -200)).toBeGreaterThan(0);
expect(rdifWinProbability(200, -200)).toBeLessThan(1);
});
it("Dodgers vs Rockies favors the Dodgers", () => {
// Even the largest RDif gap in the league should still favour the better team,
// but compression toward .500 via RDIF_DIVISOR keeps it well below 1.0.
const p = rdifWinProbability(137, -173);
expect(p).toBeGreaterThan(0.5);
expect(p).toBeLessThan(1.0);
});
});
// ─── 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);
});
});
// ─── eloToRDif ────────────────────────────────────────────────────────────────
describe("eloToRDif", () => {
it("maps Elo 1500 (average) to RDif 0", () => {
expect(eloToRDif(1500)).toBeCloseTo(0, 5);
});
it("maps Elo above 1500 to a positive RDif", () => {
expect(eloToRDif(1600)).toBeGreaterThan(0);
});
it("maps Elo below 1500 to a negative RDif", () => {
expect(eloToRDif(1400)).toBeLessThan(0);
});
it("is symmetric: eloToRDif(1500 + d) = -eloToRDif(1500 - d)", () => {
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
});
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
const elo = 1620;
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
});
});