* Add WNBA playoff simulator with SRS-based Elo ratings, fixes #125 - New WNBASimulator: Monte Carlo (50k sims) projecting remaining regular season games → seeding → R1 Bo3 / Semis Bo5 / Finals Bo7 bracket - Hybrid Elo sourcing: pre-season uses futures odds (ICM); once avg gamesPlayed ≥ 5, switches automatically to SRS-derived Elo (elo = 1500 + srs × 20) - New WnbaStandingsAdapter: fetches ESPN standings + teams endpoints in parallel; includes zero-records for 2026 expansion teams (Portland Fire, Toronto Tempo) not yet in standings - Added srs column to regular_season_standings (migration 0063); stored as net rating proxy (avgPointsFor − avgPointsAgainst) - Added wnba_bracket to simulatorTypeEnum Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix missing afterEach import in wnba standings test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { WnbaStandingsAdapter } from "../wnba";
|
|
|
|
function makeStat(name: string, value: number, displayValue?: string) {
|
|
return { name, value, displayValue: displayValue ?? String(value) };
|
|
}
|
|
|
|
// WNBA standings response (13 active teams — flat conference structure, no divisions).
|
|
const SAMPLE_STANDINGS_RESPONSE = {
|
|
children: [
|
|
{
|
|
name: "Eastern Conference",
|
|
standings: {
|
|
entries: [
|
|
{
|
|
team: { id: "5", displayName: "New York Liberty", abbreviation: "NY" },
|
|
stats: [
|
|
makeStat("wins", 28),
|
|
makeStat("losses", 12),
|
|
makeStat("winPercent", 0.7),
|
|
makeStat("gamesBehind", 0),
|
|
makeStat("playoffSeed", 1),
|
|
{ name: "streak", value: 3, displayValue: "W3" },
|
|
{ name: "Last Ten Games", value: 0, displayValue: "8-2" },
|
|
{ name: "Home", value: 0, displayValue: "15-5" },
|
|
{ name: "Road", value: 0, displayValue: "13-7" },
|
|
makeStat("avgPointsFor", 89.5),
|
|
makeStat("avgPointsAgainst", 82.1),
|
|
],
|
|
},
|
|
{
|
|
team: { id: "3", displayName: "Connecticut Sun", abbreviation: "CONN" },
|
|
stats: [
|
|
makeStat("wins", 20),
|
|
makeStat("losses", 20),
|
|
makeStat("winPercent", 0.5),
|
|
makeStat("gamesBehind", 8),
|
|
makeStat("playoffSeed", 4),
|
|
{ name: "streak", value: 1, displayValue: "L1" },
|
|
{ name: "Last Ten Games", value: 0, displayValue: "5-5" },
|
|
{ name: "Home", value: 0, displayValue: "11-9" },
|
|
{ name: "Road", value: 0, displayValue: "9-11" },
|
|
makeStat("avgPointsFor", 78.0),
|
|
makeStat("avgPointsAgainst", 78.0),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "Western Conference",
|
|
standings: {
|
|
entries: [
|
|
{
|
|
team: { id: "20", displayName: "Las Vegas Aces", abbreviation: "LV" },
|
|
stats: [
|
|
makeStat("wins", 26),
|
|
makeStat("losses", 14),
|
|
makeStat("winPercent", 0.65),
|
|
makeStat("gamesBehind", 2),
|
|
makeStat("playoffSeed", 1),
|
|
{ name: "streak", value: 2, displayValue: "W2" },
|
|
{ name: "Last Ten Games", value: 0, displayValue: "7-3" },
|
|
{ name: "Home", value: 0, displayValue: "14-6" },
|
|
{ name: "Road", value: 0, displayValue: "12-8" },
|
|
makeStat("avgPointsFor", 91.2),
|
|
makeStat("avgPointsAgainst", 85.0),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
// Teams endpoint response — includes 2 expansion teams not yet in standings.
|
|
const SAMPLE_TEAMS_RESPONSE = {
|
|
sports: [
|
|
{
|
|
leagues: [
|
|
{
|
|
teams: [
|
|
{ team: { id: "5", displayName: "New York Liberty", isActive: true } },
|
|
{ team: { id: "3", displayName: "Connecticut Sun", isActive: true } },
|
|
{ team: { id: "20", displayName: "Las Vegas Aces", isActive: true } },
|
|
{ team: { id: "99001", displayName: "Portland Fire", isActive: true } },
|
|
{ team: { id: "99002", displayName: "Toronto Tempo", isActive: true } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
// Teams response with no expansion teams beyond what's in standings.
|
|
const TEAMS_RESPONSE_NO_EXTRAS = {
|
|
sports: [{ leagues: [{ teams: [
|
|
{ team: { id: "5", displayName: "New York Liberty", isActive: true } },
|
|
{ team: { id: "3", displayName: "Connecticut Sun", isActive: true } },
|
|
{ team: { id: "20", displayName: "Las Vegas Aces", isActive: true } },
|
|
] }] }],
|
|
};
|
|
|
|
function mockFetch(standingsBody: unknown, teamsBody: unknown) {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => standingsBody } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => teamsBody } as Response);
|
|
}
|
|
|
|
describe("WnbaStandingsAdapter", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("returns standings teams + zero-record expansion teams", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
// 3 from standings + 2 expansion
|
|
expect(records).toHaveLength(5);
|
|
});
|
|
|
|
it("expansion teams have wins=0, losses=0, gamesPlayed=0, srs=null", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const portland = records.find((r) => r.teamName === "Portland Fire");
|
|
if (!portland) throw new Error("Portland Fire not found");
|
|
expect(portland.wins).toBe(0);
|
|
expect(portland.losses).toBe(0);
|
|
expect(portland.gamesPlayed).toBe(0);
|
|
expect(portland.srs).toBeNull();
|
|
|
|
const toronto = records.find((r) => r.teamName === "Toronto Tempo");
|
|
if (!toronto) throw new Error("Toronto Tempo not found");
|
|
expect(toronto.wins).toBe(0);
|
|
});
|
|
|
|
it("expansion teams rank below all active teams", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const maxActiveRank = Math.max(
|
|
...records
|
|
.filter((r) => r.wins > 0 || r.gamesPlayed > 0)
|
|
.map((r) => r.leagueRank)
|
|
);
|
|
const portland = records.find((r) => r.teamName === "Portland Fire");
|
|
const toronto = records.find((r) => r.teamName === "Toronto Tempo");
|
|
if (!portland) throw new Error("Portland Fire not found");
|
|
if (!toronto) throw new Error("Toronto Tempo not found");
|
|
expect(portland.leagueRank).toBeGreaterThan(maxActiveRank);
|
|
expect(toronto.leagueRank).toBeGreaterThan(maxActiveRank);
|
|
});
|
|
|
|
it("parses wins, losses, and gamesPlayed correctly", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const ny = records.find((r) => r.teamName === "New York Liberty");
|
|
if (!ny) throw new Error("New York Liberty not found");
|
|
expect(ny.wins).toBe(28);
|
|
expect(ny.losses).toBe(12);
|
|
expect(ny.gamesPlayed).toBe(40);
|
|
});
|
|
|
|
it("assigns leagueRank 1 to the team with most wins", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const rank1 = records.find((r) => r.leagueRank === 1);
|
|
expect(rank1?.teamName).toBe("New York Liberty");
|
|
});
|
|
|
|
it("assigns conference from the standings response", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const ny = records.find((r) => r.teamName === "New York Liberty");
|
|
expect(ny?.conference).toBe("Eastern Conference");
|
|
|
|
const lv = records.find((r) => r.teamName === "Las Vegas Aces");
|
|
expect(lv?.conference).toBe("Western Conference");
|
|
});
|
|
|
|
it("expansion teams have no division (WNBA has no divisions)", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
for (const record of records) {
|
|
expect(record.division).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("computes srs as net rating (avgPointsFor - avgPointsAgainst)", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const ny = records.find((r) => r.teamName === "New York Liberty");
|
|
expect(ny?.srs).toBeCloseTo(7.4, 1); // 89.5 - 82.1
|
|
|
|
const conn = records.find((r) => r.teamName === "Connecticut Sun");
|
|
expect(conn?.srs).toBeCloseTo(0, 1); // 78.0 - 78.0
|
|
});
|
|
|
|
it("does not duplicate teams that appear in both standings and teams list", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, TEAMS_RESPONSE_NO_EXTRAS);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
expect(records).toHaveLength(3);
|
|
const names = records.map((r) => r.teamName);
|
|
expect(new Set(names).size).toBe(3);
|
|
});
|
|
|
|
it("extracts streak and lastTen from standings", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const ny = records.find((r) => r.teamName === "New York Liberty");
|
|
expect(ny?.streak).toBe("W3");
|
|
expect(ny?.lastTen).toBe("8-2");
|
|
});
|
|
|
|
it("extracts home and away records", async () => {
|
|
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const ny = records.find((r) => r.teamName === "New York Liberty");
|
|
expect(ny?.homeRecord).toBe("15-5");
|
|
expect(ny?.awayRecord).toBe("13-7");
|
|
});
|
|
|
|
it("throws on non-ok standings response", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: false, status: 503, statusText: "Service Unavailable" } as Response)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_TEAMS_RESPONSE } as Response);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
await expect(adapter.fetchStandings()).rejects.toThrow("503");
|
|
});
|
|
|
|
it("throws on non-ok teams response", async () => {
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_STANDINGS_RESPONSE } as Response)
|
|
.mockResolvedValueOnce({ ok: false, status: 429, statusText: "Too Many Requests" } as Response);
|
|
|
|
const adapter = new WnbaStandingsAdapter();
|
|
await expect(adapter.fetchStandings()).rejects.toThrow("429");
|
|
});
|
|
});
|