import { describe, it, expect } from "vitest"; import { normalizeTeamName, getTeamData, winRateFromRDif, rawWinRateFromRDif, rawWinRateFromElo, rdifWinProbability, eloToRDif, projectionForSeeding, seedingWinRateFor, sampleBinomial, 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); }); }); // ─── 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); }); }); // ─── rawWinRateFromRDif ─────────────────────────────────────────────────────── describe("rawWinRateFromRDif", () => { it("returns 0.5 for RDif = 0", () => { expect(rawWinRateFromRDif(0)).toBeCloseTo(0.5, 6); }); it("is higher than winRateFromRDif for positive RDif (less compression)", () => { const rdif = 137; expect(rawWinRateFromRDif(rdif)).toBeGreaterThan(winRateFromRDif(rdif)); }); it("Dodgers (RDif +137) project to ~.585 season win rate", () => { // 137 runs / 1620 ≈ 0.585 expect(rawWinRateFromRDif(137)).toBeCloseTo(0.585, 2); }); it("clamps to [0.01, 0.99]", () => { expect(rawWinRateFromRDif(10000)).toBe(0.99); expect(rawWinRateFromRDif(-10000)).toBe(0.01); }); }); // ─── rawWinRateFromElo ──────────────────────────────────────────────────────── describe("rawWinRateFromElo", () => { it("returns 0.5 for Elo 1500 (average)", () => { expect(rawWinRateFromElo(1500)).toBeCloseTo(0.5, 6); }); it("returns above 0.5 for Elo above 1500", () => { expect(rawWinRateFromElo(1600)).toBeGreaterThan(0.5); }); it("returns below 0.5 for Elo below 1500", () => { expect(rawWinRateFromElo(1400)).toBeLessThan(0.5); }); it("round-trips from projected wins: projectedWins → Elo → rawWinRate ≈ winRate", () => { // 95 wins out of 162 → winRate ≈ 0.5864 const projectedWins = 95; const totalGames = 162; const winRate = projectedWins / totalGames; // Build Elo from winRate the same way projectedWinsToElo does const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate); expect(rawWinRateFromElo(elo)).toBeCloseTo(winRate, 4); }); it("is symmetric around 1500", () => { expect(rawWinRateFromElo(1600)).toBeCloseTo(1 - rawWinRateFromElo(1400), 6); }); }); // ─── 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); }); }); // ─── sampleBinomial ─────────────────────────────────────────────────────────── describe("sampleBinomial", () => { it("returns 0 when n = 0", () => { expect(sampleBinomial(0, 0.5)).toBe(0); }); it("returns 0 when p = 0 (exact path, n < 30)", () => { expect(sampleBinomial(10, 0)).toBe(0); }); it("returns n when p = 1 (exact path, n < 30)", () => { expect(sampleBinomial(10, 1)).toBe(10); }); it("returns 0 when p = 0 (normal-approx path, n >= 30)", () => { // p <= 0 guard fires before normal approx expect(sampleBinomial(162, 0)).toBe(0); }); it("returns n when p = 1 (normal-approx path, n >= 30)", () => { // p >= 1 guard fires before normal approx expect(sampleBinomial(162, 1)).toBe(162); }); it("result is always in [0, n] — exact path (n < 30)", () => { for (let i = 0; i < 50; i++) { const result = sampleBinomial(10, 0.5); expect(result).toBeGreaterThanOrEqual(0); expect(result).toBeLessThanOrEqual(10); } }); it("result is always in [0, n] — normal-approx path (n >= 30)", () => { for (let i = 0; i < 50; i++) { const result = sampleBinomial(162, 0.5); expect(result).toBeGreaterThanOrEqual(0); expect(result).toBeLessThanOrEqual(162); } }); it("mean is close to n*p over many samples — normal-approx path", () => { const n = 162; const p = 0.586; const samples = Array.from({ length: 2000 }, () => sampleBinomial(n, p)); const mean = samples.reduce((s, x) => s + x, 0) / samples.length; // Expected mean ≈ 94.9; allow ±3 (well within 3σ for 2000 samples) expect(mean).toBeGreaterThan(n * p - 3); expect(mean).toBeLessThan(n * p + 3); }); it("mean is close to n*p over many samples — exact path", () => { const n = 10; const p = 0.6; const samples = Array.from({ length: 2000 }, () => sampleBinomial(n, p)); const mean = samples.reduce((s, x) => s + x, 0) / samples.length; // Expected mean = 6; allow ±0.5 expect(mean).toBeGreaterThan(n * p - 0.5); expect(mean).toBeLessThan(n * p + 0.5); }); }); // ─── Series simulators ──────────────────────────────────────────────────────── const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null }; const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0, projectedWins: null }; 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("lands on the same run-differential scale as the hardcoded TEAMS_DATA rdif", () => { // 95 projected wins out of 162 → Elo ≈ 1561. On the TEAMS_DATA scale that is a // ~+140 run differential, right alongside the Dodgers' hardcoded +137 — not the // ~+686 the old RDIF_DIVISOR scaling produced. const winRate = 95 / 162; const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate); expect(eloToRDif(elo)).toBeGreaterThan(120); expect(eloToRDif(elo)).toBeLessThan(160); }); it("is compressed by winRateFromRDif for playoff matchups, like a hardcoded rdif", () => { // The whole point of RDIF_DIVISOR: playoff series are near coin-flips between // playoff-calibre teams. An Elo-rated team must not skip that compression. const winRate = 95 / 162; const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate); const playoffRate = winRateFromRDif(eloToRDif(elo)); expect(playoffRate).toBeCloseTo(0.517, 2); // Strictly compressed relative to the team's raw season win rate. expect(playoffRate).toBeLessThan(rawWinRateFromElo(elo)); }); it("agrees with the hardcoded rdif path for a team of equivalent strength", () => { // Dodgers: hardcoded +137. An Elo carrying the same seeding win rate should // produce a comparable playoff win rate rather than a wildly more dominant one. const dodgers = getTeamData("Los Angeles Dodgers"); const eloEquivalent = 1500 + 400 * Math.log10( rawWinRateFromRDif(dodgers?.rdif ?? 0) / (1 - rawWinRateFromRDif(dodgers?.rdif ?? 0)) ); expect(winRateFromRDif(eloToRDif(eloEquivalent))).toBeCloseTo( winRateFromRDif(dodgers?.rdif ?? 0), 3 ); }); }); // ─── seedingWinRateFor ──────────────────────────────────────────────────────── describe("seedingWinRateFor", () => { const eloRate = 95 / 162; // ≈ 0.5864 — the rate a 95-win projection implies it("is a no-op pre-season: the target equals the Elo-implied rate", () => { expect(seedingWinRateFor(eloRate, 95, 0, 162)).toBeCloseTo(eloRate, 6); }); it("spreads the shortfall over the remaining games mid-season", () => { // 60-50 and projected for 95: 35 wins needed in 52 games ≈ .673, well above the // .586 the season-long Elo implies. Without this the sim finishes around 90.5. expect(seedingWinRateFor(eloRate, 95, 60, 52)).toBeCloseTo(35 / 52, 6); }); it("reaches the projection in expectation", () => { const currentWins = 60; const remaining = 52; const rate = seedingWinRateFor(eloRate, 95, currentWins, remaining); expect(currentWins + rate * remaining).toBeCloseTo(95, 6); }); it("falls back to the Elo rate once a team has passed its projection", () => { // Clamping to a floor instead would simulate a 96-40 team to go 0-26 for the // rest of the season and drop out of the field. The projection is stale, so it // is dropped rather than obeyed. expect(seedingWinRateFor(eloRate, 95, 96, 26)).toBe(eloRate); }); it("falls back to the Elo rate when a team has exactly met its projection", () => { expect(seedingWinRateFor(eloRate, 95, 95, 26)).toBe(eloRate); }); it("falls back to the Elo rate when the projection is unreachable", () => { // 40-70 projected for 95 needs better than 1.000 — the mirror image of the // case above, and dropped for the same reason. expect(seedingWinRateFor(eloRate, 95, 40, 52)).toBe(eloRate); }); it("falls back to the Elo rate when the target is exactly 1.000", () => { expect(seedingWinRateFor(eloRate, 95, 43, 52)).toBe(eloRate); }); it("keeps a target just inside the reachable range", () => { expect(seedingWinRateFor(eloRate, 95, 94, 26)).toBeCloseTo(1 / 26, 6); }); it("clamps a weight above 1 rather than extrapolating past the target", () => { const target = 35 / 52; expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBeCloseTo(target, 6); expect(seedingWinRateFor(eloRate, 95, 60, 52, 3)).toBe( seedingWinRateFor(eloRate, 95, 60, 52, 1) ); }); it("never returns a rate outside (0, 1) for any weight", () => { for (const weight of [0.25, 0.5, 0.75, 1, 5]) { for (const [current, remaining] of [[0, 162], [60, 52], [94, 26], [10, 152]]) { const rate = seedingWinRateFor(eloRate, 95, current, remaining, weight); expect(rate).toBeGreaterThan(0); expect(rate).toBeLessThan(1); } } }); it("falls back to the Elo rate with no projection", () => { expect(seedingWinRateFor(eloRate, null, 60, 52)).toBe(eloRate); }); it("falls back to the Elo rate when the season is over", () => { expect(seedingWinRateFor(eloRate, 95, 95, 0)).toBe(eloRate); }); it("falls back to the Elo rate at weight 0", () => { expect(seedingWinRateFor(eloRate, 95, 60, 52, 0)).toBe(eloRate); }); it("blends target and Elo rate at an intermediate weight", () => { const target = 35 / 52; expect(seedingWinRateFor(eloRate, 95, 60, 52, 0.5)).toBeCloseTo( 0.5 * target + 0.5 * eloRate, 6 ); }); }); // ─── projectionForSeeding ───────────────────────────────────────────────────── describe("projectionForSeeding", () => { it("uses the projection when it alone produced the resolved Elo", () => { expect(projectionForSeeding(95, { sourceEloMethod: "projectedWins" })).toBe(95); }); it("ignores a projection that lost the baseEloPriority race", () => { // The season resolved its Elo from a hand-entered value. Seeding off the // projection anyway would ignore it as the Elo source while still letting it // dictate the standings. expect(projectionForSeeding(95, { sourceEloMethod: "direct" })).toBeNull(); }); it("ignores a projection that was blended with futures odds", () => { // The blend lives in the Elo; seeding off the raw projection would discard it // and run seeding and playoff matchups on different strength scales. expect(projectionForSeeding(95, { sourceEloMethod: "blend" })).toBeNull(); expect(projectionForSeeding(95, { sourceEloMethod: "sourceOdds" })).toBeNull(); }); it("ignores a projection on a participant resolved by a fallback", () => { expect(projectionForSeeding(95, { sourceEloMethod: "averageKnown" })).toBeNull(); }); it("ignores a projection with no method recorded", () => { expect(projectionForSeeding(95, null)).toBeNull(); expect(projectionForSeeding(95, undefined)).toBeNull(); expect(projectionForSeeding(95, {})).toBeNull(); }); it("passes a null projection through", () => { expect(projectionForSeeding(null, { sourceEloMethod: "projectedWins" })).toBeNull(); }); });