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("assigns divisionRank 1 to the division leader and 2 to the next", 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"); const mets = records.find((r) => r.teamName === "New York Mets"); expect(ori?.divisionRank).toBe(1); expect(bos?.divisionRank).toBe(2); expect(mets?.divisionRank).toBe(1); }); 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(); }); it("resolves division from hardcoded map when ESPN returns flat structure (no division children)", async () => { const flatResponse = { children: [ { name: "American League", // No "children" key — ESPN returns all AL teams directly in standings.entries standings: { entries: [ { team: { id: "30", displayName: "Tampa Bay Rays" }, stats: [ { name: "wins", value: 40 }, { name: "losses", value: 25 }, { name: "winPercent", value: 0.615 }, { name: "gamesBehind", value: 0 }, { name: "playoffSeed", value: 1 }, ], }, { team: { id: "10", displayName: "New York Yankees" }, stats: [ { name: "wins", value: 41 }, { name: "losses", value: 26 }, { name: "winPercent", value: 0.612 }, { name: "gamesBehind", value: 0 }, { name: "playoffSeed", value: 2 }, ], }, { team: { id: "4", displayName: "Chicago White Sox" }, stats: [ { name: "wins", value: 36 }, { name: "losses", value: 31 }, { name: "winPercent", value: 0.537 }, { name: "gamesBehind", value: 5 }, { name: "playoffSeed", value: 3 }, ], }, ], }, }, { name: "National League", standings: { entries: [ { team: { id: "15", displayName: "Atlanta Braves" }, stats: [ { name: "wins", value: 45 }, { name: "losses", value: 23 }, { name: "winPercent", value: 0.662 }, { name: "gamesBehind", value: 0 }, { name: "playoffSeed", value: 1 }, ], }, ], }, }, ], }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => flatResponse, } as Response); const adapter = new MlbStandingsAdapter(); const records = await adapter.fetchStandings(); const rays = records.find((r) => r.teamName === "Tampa Bay Rays"); const yankees = records.find((r) => r.teamName === "New York Yankees"); const whiteSox = records.find((r) => r.teamName === "Chicago White Sox"); const braves = records.find((r) => r.teamName === "Atlanta Braves"); // Division should be resolved from hardcoded map expect(rays?.division).toBe("AL East"); expect(yankees?.division).toBe("AL East"); expect(whiteSox?.division).toBe("AL Central"); expect(braves?.division).toBe("NL East"); // Conference should still be abbreviated correctly expect(rays?.conference).toBe("AL"); expect(braves?.conference).toBe("NL"); // divisionRank is sorted by win-loss within each division, NOT by ESPN response order. // Yankees (41-26) has a better record than Rays (40-25) even though Rays appears first // in ESPN's flat list → Yankees should be divisionRank 1. expect(yankees?.divisionRank).toBe(1); expect(rays?.divisionRank).toBe(2); // White Sox is alone in AL Central sample → divisionRank 1 expect(whiteSox?.divisionRank).toBe(1); // Braves is alone in NL East sample → divisionRank 1 expect(braves?.divisionRank).toBe(1); }); it("warns to console when a team display name is not in the hardcoded division map", async () => { const unknownTeamResponse = { children: [ { name: "American League", standings: { entries: [ { team: { id: "99", displayName: "Portland Lumberjacks" }, stats: [ { name: "wins", value: 30 }, { name: "losses", value: 30 }, { name: "winPercent", value: 0.5 }, ], }, ], }, }, ], }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => unknownTeamResponse, } as Response); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const adapter = new MlbStandingsAdapter(); await adapter.fetchStandings(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Portland Lumberjacks") ); warnSpy.mockRestore(); }); });