* Add MLS standings sync and fix simulator conference resolution - New MlsStandingsAdapter using ESPN's free soccer API (no key required), returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference rank, and home/away records; registered under mls_bracket - Display route now shows soccer table columns and a 9-team playoff cutoff line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs) - MLS simulator gains a third conference fallback: reads externalId="Eastern" or "Western" on the participant, mirroring the LLWS pool-assignment pattern so admins can bootstrap conference data before the first standings sync - 9 unit tests covering stat mapping, conference normalization, rank ordering, and error paths https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Address code review feedback on MLS standings + simulator 1. Update mls-simulator.ts module comment to document the new step-3 externalId fallback in the conference resolution order 2. Tighten normalizeConference to exact-match "Eastern"/"Western Conference" instead of broad substring, preventing false matches on names like "Northeast" 3. Pre-parse statsMap for each entry before the sort so it isn't rebuilt O(n log n) times during comparison 4. Document the externalId name-matching tradeoff on parseConferenceFromExternalId 5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties sum; export parseConferenceFromExternalId for direct testing 6. Add tests: normalizeConference passthrough, winPct=0 at preseason, and parseConferenceFromExternalId (case-insensitivity, null/undefined, numeric ESPN IDs, unrecognized strings) https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Fix lint: replace != null with !== null && !== undefined oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged. https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u --------- Co-authored-by: Claude <noreply@anthropic.com>
286 lines
9.1 KiB
TypeScript
286 lines
9.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { MlsStandingsAdapter } from "../mls";
|
|
|
|
function makeStat(name: string, value: number, displayValue?: string) {
|
|
return { name, value, displayValue: displayValue ?? String(value) };
|
|
}
|
|
|
|
const SAMPLE_MLS_RESPONSE = {
|
|
children: [
|
|
{
|
|
name: "Eastern Conference",
|
|
standings: {
|
|
entries: [
|
|
{
|
|
team: { id: "371", displayName: "Inter Miami CF", shortDisplayName: "Inter Miami", abbreviation: "MIA" },
|
|
stats: [
|
|
makeStat("wins", 18),
|
|
makeStat("losses", 8),
|
|
makeStat("ties", 8),
|
|
makeStat("points", 62),
|
|
makeStat("pointsFor", 55),
|
|
makeStat("pointsAgainst", 38),
|
|
makeStat("pointDifferential", 17),
|
|
makeStat("playoffSeed", 1),
|
|
{ name: "Home", value: 0, displayValue: "10-3-3" },
|
|
{ name: "Road", value: 0, displayValue: "8-5-5" },
|
|
],
|
|
},
|
|
{
|
|
team: { id: "928", displayName: "Columbus Crew", shortDisplayName: "Columbus", abbreviation: "CLB" },
|
|
stats: [
|
|
makeStat("wins", 15),
|
|
makeStat("losses", 10),
|
|
makeStat("ties", 9),
|
|
makeStat("points", 54),
|
|
makeStat("pointsFor", 48),
|
|
makeStat("pointsAgainst", 42),
|
|
makeStat("pointDifferential", 6),
|
|
makeStat("playoffSeed", 2),
|
|
{ name: "Home", value: 0, displayValue: "9-4-4" },
|
|
{ name: "Road", value: 0, displayValue: "6-6-5" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "Western Conference",
|
|
standings: {
|
|
entries: [
|
|
{
|
|
team: { id: "339", displayName: "Los Angeles FC", shortDisplayName: "LAFC", abbreviation: "LAFC" },
|
|
stats: [
|
|
makeStat("wins", 20),
|
|
makeStat("losses", 6),
|
|
makeStat("ties", 8),
|
|
makeStat("points", 68),
|
|
makeStat("pointsFor", 62),
|
|
makeStat("pointsAgainst", 30),
|
|
makeStat("pointDifferential", 32),
|
|
makeStat("playoffSeed", 1),
|
|
{ name: "Home", value: 0, displayValue: "12-2-3" },
|
|
{ name: "Road", value: 0, displayValue: "8-4-5" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
describe("MlsStandingsAdapter", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("maps ESPN API response to FetchedStandingsRecord[]", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_MLS_RESPONSE,
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
expect(records).toHaveLength(3);
|
|
});
|
|
|
|
it("maps soccer-specific fields (W/D/L/GF/GA/GD/PTS)", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_MLS_RESPONSE,
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
|
if (!miami) throw new Error("Inter Miami CF not found");
|
|
expect(miami.wins).toBe(18);
|
|
expect(miami.losses).toBe(8);
|
|
expect(miami.ties).toBe(8);
|
|
expect(miami.tablePoints).toBe(62);
|
|
expect(miami.goalsFor).toBe(55);
|
|
expect(miami.goalsAgainst).toBe(38);
|
|
expect(miami.goalDifference).toBe(17);
|
|
expect(miami.gamesPlayed).toBe(34); // 18+8+8
|
|
});
|
|
|
|
it("normalizes conference names to Eastern/Western", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_MLS_RESPONSE,
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
|
const lafc = records.find((r) => r.teamName === "Los Angeles FC");
|
|
if (!miami || !lafc) throw new Error("Teams not found");
|
|
|
|
expect(miami.conference).toBe("Eastern");
|
|
expect(lafc.conference).toBe("Western");
|
|
});
|
|
|
|
it("assigns conferenceRank from playoffSeed stat", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_MLS_RESPONSE,
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
|
const columbus = records.find((r) => r.teamName === "Columbus Crew");
|
|
if (!miami || !columbus) throw new Error("Teams not found");
|
|
|
|
expect(miami.conferenceRank).toBe(1);
|
|
expect(columbus.conferenceRank).toBe(2);
|
|
});
|
|
|
|
it("assigns leagueRank by points descending", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_MLS_RESPONSE,
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
// LAFC has 68 pts, Miami 62, Columbus 54 → ranks 1, 2, 3
|
|
const lafc = records.find((r) => r.teamName === "Los Angeles FC");
|
|
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
|
const columbus = records.find((r) => r.teamName === "Columbus Crew");
|
|
if (!lafc || !miami || !columbus) throw new Error("Teams not found");
|
|
|
|
expect(lafc.leagueRank).toBe(1);
|
|
expect(miami.leagueRank).toBe(2);
|
|
expect(columbus.leagueRank).toBe(3);
|
|
});
|
|
|
|
it("uses externalTeamId from ESPN team id", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_MLS_RESPONSE,
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const lafc = records.find((r) => r.teamName === "Los Angeles FC");
|
|
if (!lafc) throw new Error("LAFC not found");
|
|
expect(lafc.externalTeamId).toBe("339");
|
|
});
|
|
|
|
it("maps home and away records", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => SAMPLE_MLS_RESPONSE,
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
|
|
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
|
if (!miami) throw new Error("Inter Miami CF not found");
|
|
expect(miami.homeRecord).toBe("10-3-3");
|
|
expect(miami.awayRecord).toBe("8-5-5");
|
|
});
|
|
|
|
it("throws on non-ok response", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
statusText: "Too Many Requests",
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
await expect(adapter.fetchStandings()).rejects.toThrow("429");
|
|
});
|
|
|
|
it("throws when no entries returned", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ children: [] }),
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
await expect(adapter.fetchStandings()).rejects.toThrow("no entries");
|
|
});
|
|
|
|
it("passes through unrecognized conference names unchanged", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
children: [
|
|
{
|
|
name: "Southern Division",
|
|
standings: {
|
|
entries: [
|
|
{
|
|
team: { id: "1", displayName: "Test FC" },
|
|
stats: [
|
|
makeStat("wins", 5),
|
|
makeStat("losses", 3),
|
|
makeStat("ties", 2),
|
|
makeStat("points", 17),
|
|
makeStat("pointsFor", 12),
|
|
makeStat("pointsAgainst", 9),
|
|
makeStat("pointDifferential", 3),
|
|
makeStat("playoffSeed", 1),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
expect(records[0].conference).toBe("Southern Division");
|
|
});
|
|
|
|
it("returns winPct of 0 when gamesPlayed is 0", async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
children: [
|
|
{
|
|
name: "Eastern Conference",
|
|
standings: {
|
|
entries: [
|
|
{
|
|
team: { id: "1", displayName: "Preseason FC" },
|
|
stats: [
|
|
makeStat("wins", 0),
|
|
makeStat("losses", 0),
|
|
makeStat("ties", 0),
|
|
makeStat("points", 0),
|
|
makeStat("pointsFor", 0),
|
|
makeStat("pointsAgainst", 0),
|
|
makeStat("pointDifferential", 0),
|
|
makeStat("playoffSeed", 1),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
} as Response);
|
|
|
|
const adapter = new MlsStandingsAdapter();
|
|
const records = await adapter.fetchStandings();
|
|
expect(records[0].winPct).toBe(0);
|
|
expect(records[0].gamesPlayed).toBe(0);
|
|
});
|
|
});
|