brackt/app/services/standings-sync/__tests__/mlb.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

281 lines
8.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { MlbStandingsAdapter } from "../mlb";
const SAMPLE_MLB_RESPONSE = {
records: [
{
// AL East division record
teamRecords: [
{
team: {
id: 110,
name: "Baltimore Orioles",
league: { name: "American League" },
division: { name: "AL East" },
},
wins: 47,
losses: 34,
gamesPlayed: 81,
winningPercentage: ".580",
divisionRank: "1",
leagueRank: "1",
gamesBack: "-",
streak: { streakCode: "W", streakNumber: 3 },
records: {
splitRecords: [
{ type: "home", wins: 24, losses: 15 },
{ type: "away", wins: 23, losses: 19 },
{ type: "lastTen", wins: 7, losses: 3 },
],
},
},
{
team: {
id: 111,
name: "Boston Red Sox",
league: { name: "American League" },
division: { name: "AL East" },
},
wins: 42,
losses: 39,
gamesPlayed: 81,
winningPercentage: ".519",
divisionRank: "2",
leagueRank: "4",
gamesBack: "5.0",
streak: { streakCode: "L", streakNumber: 2 },
records: {
splitRecords: [
{ type: "home", wins: 22, losses: 18 },
{ type: "away", wins: 20, losses: 21 },
{ type: "lastTen", wins: 4, losses: 6 },
],
},
},
],
},
{
// NL East division record
teamRecords: [
{
team: {
id: 121,
name: "New York Mets",
league: { name: "National League" },
division: { name: "NL East" },
},
wins: 43,
losses: 38,
gamesPlayed: 81,
winningPercentage: ".531",
divisionRank: "1",
leagueRank: "1",
gamesBack: "-",
streak: { streakCode: "W", streakNumber: 1 },
records: {
splitRecords: [
{ type: "home", wins: 22, losses: 17 },
{ type: "away", wins: 21, losses: 21 },
{ type: "lastTen", wins: 6, losses: 4 },
],
},
},
],
},
],
};
describe("MlbStandingsAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("maps AL teams to conference 'AL'", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Baltimore Orioles");
expect(bos?.conference).toBe("AL");
});
it("maps NL teams to conference 'NL'", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const mets = records.find((r) => r.teamName === "New York Mets");
expect(mets?.conference).toBe("NL");
});
it("maps division names correctly", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Baltimore Orioles");
const mets = records.find((r) => r.teamName === "New York Mets");
expect(bos?.division).toBe("AL East");
expect(mets?.division).toBe("NL East");
});
it("maps wins, losses, gamesPlayed, and winPct", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.wins).toBe(47);
expect(ori?.losses).toBe(34);
expect(ori?.gamesPlayed).toBe(81);
expect(ori?.winPct).toBeCloseTo(0.58, 2);
});
it("formats streak as 'W3' or 'L2'", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
const bos = records.find((r) => r.teamName === "Boston Red Sox");
expect(ori?.streak).toBe("W3");
expect(bos?.streak).toBe("L2");
});
it("extracts lastTen from splitRecords", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.lastTen).toBe("7-3");
});
it("extracts homeRecord and awayRecord from splitRecords", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.homeRecord).toBe("24-15");
expect(ori?.awayRecord).toBe("23-19");
});
it("parses gamesBack as null for the division leader ('-')", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.gamesBack).toBeUndefined();
});
it("parses gamesBack as a number for non-leaders", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Boston Red Sox");
expect(bos?.gamesBack).toBeCloseTo(5.0, 1);
});
it("computes leagueRank sorted by wins desc across all 30 teams", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
// Baltimore (47W) should rank #1 overall, Boston (42W) should rank #2
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
const bos = records.find((r) => r.teamName === "Boston Red Sox");
expect(ori?.leagueRank).toBe(1);
expect(bos?.leagueRank).toBeGreaterThan(1);
});
it("never sets otLosses (MLB has no OT losses)", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
for (const record of records) {
expect(record.otLosses).toBeUndefined();
}
});
it("uses team numeric id as externalTeamId", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.externalTeamId).toBe("110");
});
it("throws on non-ok HTTP response", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 503,
statusText: "Service Unavailable",
} as Response);
const adapter = new MlbStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow("503");
});
it("throws on unexpected response shape", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ unexpected: true }),
} as Response);
const adapter = new MlbStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow();
});
});