import { describe, it, expect, vi, beforeEach } from "vitest"; import { MlbStandingsAdapter } from "../mlb"; // Minimal ESPN-shaped response: AL East + NL East with a few teams const SAMPLE_ESPN_RESPONSE = { children: [ { name: "American League", children: [ { name: "American League East", standings: { entries: [ { team: { id: "2", displayName: "Baltimore Orioles" }, stats: [ { name: "wins", value: 47 }, { name: "losses", value: 34 }, { name: "winPercent", value: 0.58 }, { name: "gamesBehind", value: 0 }, { name: "streak", displayValue: "W3" }, { name: "Home", displayValue: "24-15" }, { name: "Road", displayValue: "23-19" }, { name: "Last Ten Games", displayValue: "7-3" }, { name: "playoffSeed", value: 1 }, ], }, { team: { id: "3", displayName: "Boston Red Sox" }, stats: [ { name: "wins", value: 42 }, { name: "losses", value: 39 }, { name: "winPercent", value: 0.519 }, { name: "gamesBehind", value: 5.0 }, { name: "streak", displayValue: "L2" }, { name: "Home", displayValue: "22-18" }, { name: "Road", displayValue: "20-21" }, { name: "Last Ten Games", displayValue: "4-6" }, { name: "playoffSeed", value: 2 }, ], }, ], }, }, ], }, { name: "National League", children: [ { name: "National League East", standings: { entries: [ { team: { id: "21", displayName: "New York Mets" }, stats: [ { name: "wins", value: 43 }, { name: "losses", value: 38 }, { name: "winPercent", value: 0.531 }, { name: "gamesBehind", value: 0 }, { name: "streak", displayValue: "W1" }, { name: "Home", displayValue: "22-17" }, { name: "Road", displayValue: "21-21" }, { name: "Last Ten Games", displayValue: "6-4" }, { name: "playoffSeed", value: 1 }, ], }, ], }, }, ], }, ], }; 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_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); const records = await adapter.fetchStandings(); const ori = records.find((r) => r.teamName === "Baltimore Orioles"); expect(ori?.conference).toBe("AL"); }); it("maps NL teams to conference 'NL'", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_ESPN_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_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); const records = await adapter.fetchStandings(); const ori = records.find((r) => r.teamName === "Baltimore Orioles"); const mets = records.find((r) => r.teamName === "New York Mets"); expect(ori?.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_ESPN_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_ESPN_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 Last Ten Games stat", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_ESPN_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("gamesBack is 0 for the division leader (ESPN returns numeric 0, not '-')", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); const records = await adapter.fetchStandings(); const ori = records.find((r) => r.teamName === "Baltimore Orioles"); expect(ori?.gamesBack).toBe(0); }); it("gamesBack is a positive number for non-leaders", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_ESPN_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("extracts homeRecord and awayRecord", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_ESPN_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("computes leagueRank sorted by wins desc across all teams", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_ESPN_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("uses ESPN string team id as externalTeamId", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_ESPN_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("2"); }); it("never sets otLosses (MLB has no OT losses)", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); const records = await adapter.fetchStandings(); for (const record of records) { expect(record.otLosses).toBeUndefined(); } }); it("throws on non-ok HTTP response", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 406, statusText: "Not Acceptable", } as Response); const adapter = new MlbStandingsAdapter(); await expect(adapter.fetchStandings()).rejects.toThrow("406"); }); it("produces a stable leagueRank when two teams are tied on wins and losses", async () => { const tiedResponse = { children: [ { name: "American League", children: [ { name: "American League East", standings: { entries: [ { team: { id: "99", displayName: "Z Team" }, stats: [ { name: "wins", value: 40 }, { name: "losses", value: 40 }, { name: "winPercent", value: 0.5 }, ], }, { team: { id: "98", displayName: "A Team" }, stats: [ { name: "wins", value: 40 }, { name: "losses", value: 40 }, { name: "winPercent", value: 0.5 }, ], }, ], }, }, ], }, ], }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => tiedResponse, } as Response); const adapter = new MlbStandingsAdapter(); const records = await adapter.fetchStandings(); // Tiebreaker is alphabetical — "A Team" should always rank above "Z Team" const aTeam = records.find((r) => r.teamName === "A Team"); const zTeam = records.find((r) => r.teamName === "Z Team"); expect(aTeam?.leagueRank).toBe(1); expect(zTeam?.leagueRank).toBe(2); }); it("throws when response has no entries", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => ({ children: [] }), } as Response); const adapter = new MlbStandingsAdapter(); await expect(adapter.fetchStandings()).rejects.toThrow(); }); });