brackt/app/services/simulations/__tests__/mls-simulator.test.ts
Chris Parsons 766ba948e1
Add MLS standings sync and fix simulator conference resolution (#423)
* 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>
2026-05-14 15:39:40 -07:00

394 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
import {
mlsWinProbability,
simulateMlsKnockoutGame,
simulateMlsBestOfThree,
simulateMlsRegularSeason,
simulateMlsPlayoffs,
parseConferenceFromExternalId,
type MlsConferenceSeed,
type MlsTeamEntry,
} from "../mls-simulator";
import { SIMULATOR_TYPES } from "../registry";
import { SIMULATOR_MANIFEST } from "../manifest";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeSeed(
participantId: string,
seed: number,
conference: "Eastern" | "Western" = "Eastern",
elo = 1500
): MlsConferenceSeed {
return { participantId, elo, conference, seed };
}
function make15Teams(
conference: "Eastern" | "Western",
eloFn: (i: number) => number = () => 1500
): MlsTeamEntry[] {
return Array.from({ length: 15 }, (_, i) => ({
participantId: `${conference}-team-${i + 1}`,
elo: eloFn(i),
conference,
currentPoints: 0,
currentGoalsFor: 0,
currentGoalDifference: 0,
remainingGames: 34,
}));
}
function make9Seeds(conference: "Eastern" | "Western", eloFn: (i: number) => number = () => 1500): MlsConferenceSeed[] {
return Array.from({ length: 9 }, (_, i) =>
makeSeed(`${conference}-t${i + 1}`, i + 1, conference, eloFn(i))
);
}
// ─── mlsWinProbability ────────────────────────────────────────────────────────
describe("mlsWinProbability", () => {
it("returns 0.5 for equal Elo", () => {
expect(mlsWinProbability(1500, 1500)).toBeCloseTo(0.5);
});
it("favours the higher-Elo team", () => {
expect(mlsWinProbability(1600, 1400)).toBeGreaterThan(0.5);
});
it("returns a probability in (0, 1)", () => {
const p = mlsWinProbability(2000, 1000);
expect(p).toBeGreaterThan(0);
expect(p).toBeLessThan(1);
});
});
// ─── simulateMlsKnockoutGame ──────────────────────────────────────────────────
describe("simulateMlsKnockoutGame", () => {
it("returns one winner and one loser from the two teams", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
const result = simulateMlsKnockoutGame(a, b);
const ids = [result.winner.participantId, result.loser.participantId].toSorted();
expect(ids).toEqual(["A", "B"]);
});
it("winner and loser are always different", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
for (let i = 0; i < 200; i++) {
const result = simulateMlsKnockoutGame(a, b);
expect(result.winner.participantId).not.toBe(result.loser.participantId);
}
});
it("over many trials, higher-Elo team wins more often", () => {
const strong = makeSeed("strong", 1, "Eastern", 1700);
const weak = makeSeed("weak", 2, "Eastern", 1300);
let wins = 0;
for (let i = 0; i < 10_000; i++) {
if (simulateMlsKnockoutGame(strong, weak).winner.participantId === "strong") wins++;
}
expect(wins).toBeGreaterThan(7000);
});
});
// ─── simulateMlsBestOfThree ────────────────────────────────────────────────────
describe("simulateMlsBestOfThree", () => {
it("returns one winner and one loser", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
const result = simulateMlsBestOfThree(a, b);
expect(result.winner.participantId).not.toBe(result.loser.participantId);
const ids = [result.winner.participantId, result.loser.participantId].toSorted();
expect(ids).toEqual(["A", "B"]);
});
it("gamesPlayed is between 2 and 3", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
for (let i = 0; i < 500; i++) {
const { gamesPlayed } = simulateMlsBestOfThree(a, b);
expect(gamesPlayed).toBeGreaterThanOrEqual(2);
expect(gamesPlayed).toBeLessThanOrEqual(3);
}
});
it("from a 1-1 state always plays exactly 1 more game", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
for (let i = 0; i < 500; i++) {
const { gamesPlayed } = simulateMlsBestOfThree(a, b, undefined, { winsHigher: 1, winsLower: 1 });
expect(gamesPlayed).toBe(1);
}
});
it("respects existing state: team with 2 wins is declared winner immediately", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
const result = simulateMlsBestOfThree(a, b, undefined, { winsHigher: 2, winsLower: 0 });
expect(result.winner.participantId).toBe("A");
expect(result.gamesPlayed).toBe(0);
});
it("higher-Elo team wins series more often", () => {
const strong = makeSeed("strong", 1, "Eastern", 1800);
const weak = makeSeed("weak", 2, "Eastern", 1200);
let wins = 0;
for (let i = 0; i < 5_000; i++) {
if (simulateMlsBestOfThree(strong, weak).winner.participantId === "strong") wins++;
}
expect(wins).toBeGreaterThan(4000);
});
});
// ─── simulateMlsRegularSeason ─────────────────────────────────────────────────
describe("simulateMlsRegularSeason", () => {
it("returns exactly 9 seeds per conference from a 30-team input", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
const { east, west } = simulateMlsRegularSeason(teams);
expect(east).toHaveLength(9);
expect(west).toHaveLength(9);
});
it("seeds are numbered 19 with no duplicates within each conference", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
const { east, west } = simulateMlsRegularSeason(teams);
const eastNums = east.map((s) => s.seed).toSorted((a, b) => a - b);
const westNums = west.map((s) => s.seed).toSorted((a, b) => a - b);
expect(eastNums).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(westNums).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it("conference separation holds — no Eastern team appears in Western seeds", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
const { east, west } = simulateMlsRegularSeason(teams);
for (const s of east) expect(s.participantId).toMatch(/^Eastern-/);
for (const s of west) expect(s.participantId).toMatch(/^Western-/);
});
it("all returned participant IDs are from the input teams", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
const ids = new Set(teams.map((t) => t.participantId));
const { east, west } = simulateMlsRegularSeason(teams);
for (const s of [...east, ...west]) {
expect(ids.has(s.participantId)).toBe(true);
}
});
it("higher-Elo Eastern team qualifies more often", () => {
const teams = [
...make15Teams("Eastern", (i) => i === 0 ? 1900 : 1400),
...make15Teams("Western"),
];
let qualified = 0;
for (let i = 0; i < 500; i++) {
const { east } = simulateMlsRegularSeason(teams);
if (east.some((s) => s.participantId === "Eastern-team-1")) qualified++;
}
expect(qualified).toBeGreaterThan(450);
});
it("works with a full preseason 0-0 state", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
expect(() => simulateMlsRegularSeason(teams)).not.toThrow();
});
it("respects current points as a floor", () => {
const teams = [
...make15Teams("Eastern").map((t, i) =>
i === 0 ? { ...t, currentPoints: 90, remainingGames: 0 } : t
),
...make15Teams("Western"),
];
// A team with 90 points and 0 remaining games always tops the East
for (let i = 0; i < 50; i++) {
const { east } = simulateMlsRegularSeason(teams);
expect(east[0].participantId).toBe("Eastern-team-1");
}
});
});
// ─── simulateMlsPlayoffs ──────────────────────────────────────────────────────
describe("simulateMlsPlayoffs", () => {
const east = make9Seeds("Eastern");
const west = make9Seeds("Western");
it("returns exactly one champion", () => {
const result = simulateMlsPlayoffs(east, west);
expect(typeof result.champion).toBe("string");
});
it("returns exactly one finalist", () => {
const result = simulateMlsPlayoffs(east, west);
expect(typeof result.finalist).toBe("string");
});
it("champion and finalist are different", () => {
const result = simulateMlsPlayoffs(east, west);
expect(result.champion).not.toBe(result.finalist);
});
it("confFinalsLosers has exactly 2 unique entries", () => {
const result = simulateMlsPlayoffs(east, west);
expect(result.confFinalsLosers).toHaveLength(2);
expect(new Set(result.confFinalsLosers).size).toBe(2);
});
it("confSemiLosers has exactly 4 unique entries", () => {
const result = simulateMlsPlayoffs(east, west);
expect(result.confSemiLosers).toHaveLength(4);
expect(new Set(result.confSemiLosers).size).toBe(4);
});
it("champion comes from the playoff field", () => {
const allIds = new Set([...east, ...west].map((s) => s.participantId));
const result = simulateMlsPlayoffs(east, west);
expect(allIds.has(result.champion)).toBe(true);
});
it("MLS Cup never pits two teams from the same conference", () => {
const eastIds = new Set(east.map((s) => s.participantId));
const westIds = new Set(west.map((s) => s.participantId));
for (let i = 0; i < 100; i++) {
const { champion, finalist } = simulateMlsPlayoffs(east, west);
const champIsEast = eastIds.has(champion);
const champIsWest = westIds.has(champion);
const finalIsEast = eastIds.has(finalist);
const finalIsWest = westIds.has(finalist);
// Champion and finalist must be from opposite conferences
expect(champIsEast !== finalIsEast || champIsWest !== finalIsWest).toBe(true);
}
});
it("all 8 placed participants are unique", () => {
const result = simulateMlsPlayoffs(east, west);
const all = [
result.champion,
result.finalist,
...result.confFinalsLosers,
...result.confSemiLosers,
];
expect(new Set(all).size).toBe(8);
});
it("higher-Elo seed-1 East team wins MLS Cup more often than lower-Elo seed-9 East team", () => {
const strongEast = make9Seeds("Eastern", (i) => 1800 - i * 30);
let seed1Wins = 0;
for (let i = 0; i < 3_000; i++) {
if (simulateMlsPlayoffs(strongEast, west).champion === "Eastern-t1") seed1Wins++;
}
expect(seed1Wins).toBeGreaterThan(500);
});
});
// ─── Probability column sums ───────────────────────────────────────────────────
describe("simulateMlsPlayoffs probability column sums", () => {
const east = make9Seeds("Eastern");
const west = make9Seeds("Western");
const N = 1000;
it("probFirst sums to exactly N across all participants", () => {
const counts = new Map([...east, ...west].map((s) => [s.participantId, 0]));
for (let i = 0; i < N; i++) {
const r = simulateMlsPlayoffs(east, west);
counts.set(r.champion, (counts.get(r.champion) ?? 0) + 1);
}
const total = [...counts.values()].reduce((s, c) => s + c, 0);
expect(total).toBe(N);
});
it("confFinalsLosers: 2 per sim → total = 2*N", () => {
let total = 0;
for (let i = 0; i < N; i++) {
total += simulateMlsPlayoffs(east, west).confFinalsLosers.length;
}
expect(total).toBe(2 * N);
});
it("confSemiLosers: 4 per sim → total = 4*N", () => {
let total = 0;
for (let i = 0; i < N; i++) {
total += simulateMlsPlayoffs(east, west).confSemiLosers.length;
}
expect(total).toBe(4 * N);
});
});
// ─── Manifest, registry, and schema enum sync ────────────────────────────────
describe("mls_bracket simulator sync", () => {
it("mls_bracket is in SIMULATOR_TYPES", () => {
expect(SIMULATOR_TYPES).toContain("mls_bracket");
});
it("mls_bracket has a manifest profile", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"]).toBeDefined();
});
it("manifest profile requires sourceElo", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"].requiredInputs).toContain("sourceElo");
});
it("manifest profile has projectedTablePoints as optional input", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"].optionalInputs).toContain("projectedTablePoints");
});
it("manifest profile has region as optional input", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"].optionalInputs).toContain("region");
});
it("manifest profile lists regularStandings and eloRatings setup sections", () => {
const sections = SIMULATOR_MANIFEST["mls_bracket"].setupSections;
expect(sections).toContain("regularStandings");
expect(sections).toContain("eloRatings");
});
it("manifest profile has sourceElo derivable from projectedTablePoints and sourceOdds", () => {
const derivable = SIMULATOR_MANIFEST["mls_bracket"].derivableInputs;
expect(derivable?.sourceElo).toContain("projectedTablePoints");
expect(derivable?.sourceElo).toContain("sourceOdds");
});
it("manifest defaultConfig has soccer-appropriate parameters", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"].defaultConfig).toMatchObject({
seasonGames: 34,
parityFactor: 400,
baseDrawRate: 0.26,
});
});
});
// ─── parseConferenceFromExternalId ────────────────────────────────────────────
describe("parseConferenceFromExternalId", () => {
it('returns "Eastern" for exact match', () => {
expect(parseConferenceFromExternalId("Eastern")).toBe("Eastern");
});
it('returns "Western" for exact match', () => {
expect(parseConferenceFromExternalId("Western")).toBe("Western");
});
it("is case-insensitive", () => {
expect(parseConferenceFromExternalId("eastern")).toBe("Eastern");
expect(parseConferenceFromExternalId("WESTERN")).toBe("Western");
});
it("returns null for a numeric ESPN team id", () => {
expect(parseConferenceFromExternalId("339")).toBeNull();
});
it("returns null for null/undefined", () => {
expect(parseConferenceFromExternalId(null)).toBeNull();
expect(parseConferenceFromExternalId(undefined)).toBeNull();
});
it("returns null for an unrecognized string", () => {
expect(parseConferenceFromExternalId("Northeast")).toBeNull();
});
});