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 1–9 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(); }); });