## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #62
287 lines
14 KiB
TypeScript
287 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
|
import { LLWSSimulator } from "../llws-simulator";
|
|
|
|
vi.mock("~/database/context", () => ({
|
|
database: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/services/probability-engine", async (importOriginal) => {
|
|
const actual = await importOriginal() as Record<string, unknown>;
|
|
return { ...actual };
|
|
});
|
|
|
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
const US_IDS = Array.from({ length: 10 }, (_, i) => `us-${i + 1}`);
|
|
const INTL_IDS = Array.from({ length: 10 }, (_, i) => `intl-${i + 1}`);
|
|
const ALL_IDS = [...US_IDS, ...INTL_IDS];
|
|
|
|
/**
|
|
* Build EV rows with descending odds favouring the first team per side.
|
|
* ids[0] is the strongest (best odds → lowest American number for favorites).
|
|
*/
|
|
function makeEvRows(ids: string[], opts: { includeOdds?: boolean } = {}) {
|
|
return ids.map((participantId, i) => ({
|
|
participantId,
|
|
sourceOdds: opts.includeOdds ? (i === 0 ? -300 : 200 + i * 100) : null,
|
|
}));
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("LLWSSimulator", () => {
|
|
let mockDb: { select: MockInstance };
|
|
let selectCallCount: number;
|
|
|
|
beforeEach(async () => {
|
|
selectCallCount = 0;
|
|
const { database } = await import("~/database/context");
|
|
mockDb = { select: vi.fn() };
|
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
|
});
|
|
|
|
function setupMockDb(
|
|
participants: { id: string; name?: string; externalId: string | null }[],
|
|
evRows: { participantId: string; sourceOdds: number | null }[]
|
|
) {
|
|
mockDb.select.mockImplementation(() => {
|
|
const callIndex = selectCallCount++;
|
|
const data = callIndex === 0 ? participants : evRows;
|
|
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
|
|
});
|
|
}
|
|
|
|
function defaultParticipants(mode: "randomized" | "fixed" = "randomized") {
|
|
if (mode === "fixed") {
|
|
const usA = US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" }));
|
|
const usB = US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" }));
|
|
const intlA = INTL_IDS.slice(0, 5).map((id) => ({ id, name: `Team ${id}`, externalId: "Intl:A" }));
|
|
const intlB = INTL_IDS.slice(5).map((id) => ({ id, name: `Team ${id}`, externalId: "Intl:B" }));
|
|
return [...usA, ...usB, ...intlA, ...intlB];
|
|
}
|
|
return [
|
|
...US_IDS.map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
|
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
|
];
|
|
}
|
|
|
|
// ── Core output structure ─────────────────────────────────────────────────
|
|
|
|
describe("output structure", () => {
|
|
it("returns one result per participant (20 total)", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
expect(results).toHaveLength(20);
|
|
});
|
|
|
|
it("every result has source 'llws_monte_carlo'", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
for (const r of results) {
|
|
expect(r.source).toBe("llws_monte_carlo");
|
|
}
|
|
});
|
|
|
|
it("all probability values are between 0 and 1", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
for (const r of results) {
|
|
const p = r.probabilities;
|
|
for (const v of Object.values(p)) {
|
|
expect(v).toBeGreaterThanOrEqual(0);
|
|
expect(v).toBeLessThanOrEqual(1);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Probability conservation ──────────────────────────────────────────────
|
|
|
|
describe("probability conservation (one winner per sim)", () => {
|
|
it("probFirst sums to ~1.0 across all participants", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it("probSecond sums to ~1.0 across all participants", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
const total = results.reduce((s, r) => s + r.probabilities.probSecond, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it("probThird sums to ~1.0 across all participants", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
const total = results.reduce((s, r) => s + r.probabilities.probThird, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it("probFourth sums to ~1.0 across all participants", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
const total = results.reduce((s, r) => s + r.probabilities.probFourth, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
// 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0
|
|
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
for (const r of results) {
|
|
const p = r.probabilities;
|
|
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
|
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
|
|
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Odds-driven probability ───────────────────────────────────────────────
|
|
|
|
describe("odds-driven win probability", () => {
|
|
it("strong favourite (us-1) has higher probFirst than a weak team", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
const byId = new Map(results.map((r) => [r.participantId, r]));
|
|
const us1prob = byId.get("us-1")?.probabilities.probFirst ?? 0;
|
|
const us10prob = byId.get("us-10")?.probabilities.probFirst ?? 0;
|
|
expect(us1prob).toBeGreaterThan(us10prob);
|
|
});
|
|
|
|
it("works when no odds are entered (all 50/50 fallback)", async () => {
|
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); // no odds
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it("with equal odds, each team wins the championship roughly equally", async () => {
|
|
// Equal positive odds (+5000 for every team) → vig-removed prob ≈ 1/20 each.
|
|
const eqOddsRows = ALL_IDS.map((id) => ({ participantId: id, sourceOdds: 5000 }));
|
|
setupMockDb(defaultParticipants(), eqOddsRows);
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
for (const r of results) {
|
|
// With equal odds and random pools, each team should win ~5% of the time.
|
|
// Allow a generous band given Monte Carlo variance.
|
|
expect(r.probabilities.probFirst).toBeGreaterThan(0.01);
|
|
expect(r.probabilities.probFirst).toBeLessThan(0.15);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Pool assignment modes ─────────────────────────────────────────────────
|
|
|
|
describe("pool assignment modes", () => {
|
|
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
|
|
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
expect(results).toHaveLength(20);
|
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it("mixed mode: US fixed pools, Intl randomized", async () => {
|
|
const participants = [
|
|
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
|
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
|
];
|
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
expect(results).toHaveLength(20);
|
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
});
|
|
|
|
// ── Error cases ───────────────────────────────────────────────────────────
|
|
|
|
describe("error cases", () => {
|
|
it("throws when participant count is not 20", async () => {
|
|
const nineteen = [...US_IDS, ...INTL_IDS.slice(0, 9)];
|
|
const participants = nineteen.map((id, i) => ({
|
|
id,
|
|
name: i < 10 ? `US Team ${id}` : `Team ${id}`,
|
|
externalId: i < 10 ? "US" : "Intl",
|
|
}));
|
|
setupMockDb(participants, makeEvRows(nineteen));
|
|
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 20/);
|
|
});
|
|
|
|
it("infers US side from name prefix when externalId is null", async () => {
|
|
const participants = [
|
|
...US_IDS.map((id) => ({ id, name: `US Team ${id}`, externalId: null })),
|
|
...INTL_IDS.map((id) => ({ id, name: `Japan ${id}`, externalId: null })),
|
|
];
|
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
expect(results).toHaveLength(20);
|
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it("infers US side from exact name 'US' when externalId is null", async () => {
|
|
const participants = [
|
|
...US_IDS.map((id) => ({ id, name: "US", externalId: null })),
|
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: null })),
|
|
];
|
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
|
expect(results).toHaveLength(20);
|
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
|
expect(total).toBeCloseTo(1.0, 1);
|
|
});
|
|
|
|
it("throws when a participant has an unrecognized non-null externalId", async () => {
|
|
const participants = [
|
|
...US_IDS.map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
|
|
...INTL_IDS.slice(0, 9).map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
|
{ id: "intl-10", name: "Team intl-10", externalId: "CANADA" }, // unrecognized
|
|
];
|
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
|
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/invalid externalId/);
|
|
});
|
|
|
|
it("throws when US team count is not 10", async () => {
|
|
// 11 US teams, 9 International
|
|
const participants = [
|
|
...Array.from({ length: 11 }, (_, i) => ({ id: `us-${i + 1}`, name: `US Team ${i + 1}`, externalId: "US" })),
|
|
...Array.from({ length: 9 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
|
|
];
|
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
|
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
|
});
|
|
|
|
it("throws when fixed pools have unequal A/B split", async () => {
|
|
const participants = [
|
|
// 6 in Pool A, 4 in Pool B
|
|
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
|
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
|
];
|
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
|
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 5 teams each/);
|
|
});
|
|
|
|
it("throws when US externalIds mix pool suffixes and bare side", async () => {
|
|
const participants = [
|
|
// Some US:A, some "US" (no pool suffix) → mixed
|
|
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
|
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })), // no pool
|
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
|
];
|
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
|
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/mixed externalId formats/);
|
|
});
|
|
});
|
|
});
|