* 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>
281 lines
8.4 KiB
TypeScript
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();
|
|
});
|
|
});
|