brackt/app/services/standings-sync/__tests__/wnba.test.ts
Chris Parsons bde1e6e5f0
Add WNBA playoff simulator with SRS-based Elo ratings, fixes #125 (#231)
* 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>
2026-03-26 00:34:51 -07:00

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");
});
});