233 lines
8.1 KiB
TypeScript
233 lines
8.1 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||
|
|
import { NCAAFootballSimulator } from "../ncaa-football-simulator";
|
||
|
|
|
||
|
|
vi.mock("~/database/context", () => ({
|
||
|
|
database: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Use real implementations of pure math functions to avoid mock call accumulation
|
||
|
|
// across 50k-iteration simulations. Only mock convertFuturesToElo (complex behavior).
|
||
|
|
vi.mock("~/services/probability-engine", async (importOriginal) => {
|
||
|
|
const actual = await importOriginal() as Record<string, unknown>;
|
||
|
|
return {
|
||
|
|
...actual,
|
||
|
|
convertFuturesToElo: vi.fn((oddsInput: { participantId: string; odds: number }[]) => {
|
||
|
|
return new Map(oddsInput.map((o, i) => [o.participantId, 1600 - i * 50]));
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const TEAM_IDS = Array.from({ length: 12 }, (_, i) => `team-${i + 1}`);
|
||
|
|
|
||
|
|
/** Build EV rows with sourceElo ratings (descending — team-1 is best). */
|
||
|
|
function makeEvRows(
|
||
|
|
ids: string[],
|
||
|
|
opts: { includeOdds?: boolean } = {}
|
||
|
|
) {
|
||
|
|
return ids.map((participantId, i) => ({
|
||
|
|
participantId,
|
||
|
|
sourceElo: 1700 - i * 50, // 1700, 1650, 1600, …
|
||
|
|
sourceOdds: opts.includeOdds ? (i === 0 ? -200 : 300 + i * 50) : null,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Build participant rows. */
|
||
|
|
function makeParticipants(ids: string[]) {
|
||
|
|
return ids.map((id) => ({ id }));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe("NCAAFootballSimulator", () => {
|
||
|
|
let mockDb: {
|
||
|
|
select: MockInstance;
|
||
|
|
};
|
||
|
|
|
||
|
|
// select() returns a chain: .from().where() → resolves to an array
|
||
|
|
// We need two calls: one for participants, one for EV rows.
|
||
|
|
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(
|
||
|
|
participantIds: string[],
|
||
|
|
opts: { includeOdds?: boolean } = {}
|
||
|
|
) {
|
||
|
|
const participants = makeParticipants(participantIds);
|
||
|
|
const evRows = makeEvRows(participantIds, opts);
|
||
|
|
|
||
|
|
mockDb.select.mockImplementation(() => {
|
||
|
|
const callIndex = selectCallCount++;
|
||
|
|
const data = callIndex === 0 ? participants : evRows;
|
||
|
|
return {
|
||
|
|
from: vi.fn().mockReturnValue({
|
||
|
|
where: vi.fn().mockResolvedValue(data),
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
it("returns one result per participant", async () => {
|
||
|
|
setupMockDb(TEAM_IDS);
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
expect(results).toHaveLength(12);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("all probabilities are between 0 and 1", async () => {
|
||
|
|
setupMockDb(TEAM_IDS);
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
for (const r of results) {
|
||
|
|
const p = r.probabilities;
|
||
|
|
expect(p.probFirst).toBeGreaterThanOrEqual(0);
|
||
|
|
expect(p.probFirst).toBeLessThanOrEqual(1);
|
||
|
|
expect(p.probSecond).toBeGreaterThanOrEqual(0);
|
||
|
|
expect(p.probSecond).toBeLessThanOrEqual(1);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("top-rated team has highest champion probability", async () => {
|
||
|
|
setupMockDb(TEAM_IDS);
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
const byId = new Map(results.map((r) => [r.participantId, r]));
|
||
|
|
const team1Prob = byId.get("team-1")!.probabilities.probFirst;
|
||
|
|
const team12Prob = byId.get("team-12")!.probabilities.probFirst;
|
||
|
|
|
||
|
|
expect(team1Prob).toBeGreaterThan(team12Prob);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("probFirst sums to approximately 1.0 across all teams", async () => {
|
||
|
|
setupMockDb(TEAM_IDS);
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
const total = results.reduce((sum, r) => sum + r.probabilities.probFirst, 0);
|
||
|
|
expect(total).toBeCloseTo(1.0, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("probSecond sums to approximately 1.0 across all teams", async () => {
|
||
|
|
setupMockDb(TEAM_IDS);
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
const total = results.reduce((sum, r) => sum + r.probabilities.probSecond, 0);
|
||
|
|
expect(total).toBeCloseTo(1.0, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("probThird equals probFourth for every team (tied SF placement)", async () => {
|
||
|
|
setupMockDb(TEAM_IDS);
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
for (const r of results) {
|
||
|
|
expect(r.probabilities.probThird).toBeCloseTo(r.probabilities.probFourth, 10);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("probFifth through probEighth are equal for every team (tied QF placement)", async () => {
|
||
|
|
setupMockDb(TEAM_IDS);
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.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);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("weakest team has very low champion probability", async () => {
|
||
|
|
setupMockDb(TEAM_IDS);
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
// team-12 (Elo 1150) vs team-1 (Elo 1700): team-12 should rarely win
|
||
|
|
const byId = new Map(results.map((r) => [r.participantId, r]));
|
||
|
|
expect(byId.get("team-12")!.probabilities.probFirst).toBeLessThan(0.02);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("source is cfp_monte_carlo", async () => {
|
||
|
|
setupMockDb(TEAM_IDS);
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
for (const r of results) {
|
||
|
|
expect(r.source).toBe("cfp_monte_carlo");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("throws when no participants found", async () => {
|
||
|
|
mockDb.select.mockReturnValue({
|
||
|
|
from: vi.fn().mockReturnValue({
|
||
|
|
where: vi.fn().mockResolvedValue([]),
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("throws when fewer than 12 participants are provided", async () => {
|
||
|
|
const elevenTeams = TEAM_IDS.slice(0, 11);
|
||
|
|
let call = 0;
|
||
|
|
mockDb.select.mockImplementation(() => {
|
||
|
|
const data = call++ === 0 ? makeParticipants(elevenTeams) : makeEvRows(elevenTeams);
|
||
|
|
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
|
||
|
|
});
|
||
|
|
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/12 participants/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("uses futures odds when sourceOdds present (does not throw)", async () => {
|
||
|
|
setupMockDb(TEAM_IDS, { includeOdds: true });
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
// Should still return 12 results with valid probabilities
|
||
|
|
expect(results).toHaveLength(12);
|
||
|
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||
|
|
expect(total).toBeCloseTo(1.0, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("teams outside top 12 have all-zero probabilities (15-team season)", async () => {
|
||
|
|
// 15 participants — only top 12 enter the bracket, bottom 3 stay at 0 EV
|
||
|
|
const fifteenTeams = Array.from({ length: 15 }, (_, i) => `team-${i + 1}`);
|
||
|
|
let call = 0;
|
||
|
|
mockDb.select.mockImplementation(() => {
|
||
|
|
const data = call++ === 0 ? makeParticipants(fifteenTeams) : makeEvRows(fifteenTeams);
|
||
|
|
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
|
||
|
|
});
|
||
|
|
|
||
|
|
const sim = new NCAAFootballSimulator();
|
||
|
|
const results = await sim.simulate("season-1");
|
||
|
|
|
||
|
|
expect(results).toHaveLength(15);
|
||
|
|
|
||
|
|
for (const id of ["team-13", "team-14", "team-15"]) {
|
||
|
|
const r = results.find((x) => x.participantId === id)!;
|
||
|
|
expect(r.probabilities.probFirst).toBe(0);
|
||
|
|
expect(r.probabilities.probSecond).toBe(0);
|
||
|
|
expect(r.probabilities.probThird).toBe(0);
|
||
|
|
expect(r.probabilities.probEighth).toBe(0);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|