brackt/app/services/simulations/__tests__/epl-simulator.test.ts
2026-05-13 15:02:23 -07:00

159 lines
5.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { EPLSimulator, eplWinProbability, simEplMatch } from "../epl-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn(),
}));
vi.mock("~/models/simulator", () => ({
getSportsSeasonSimulatorConfig: vi.fn(),
}));
const EPL_TEAMS = [
"Arsenal", "Manchester City", "Liverpool", "Chelsea", "Tottenham Hotspur",
"Manchester United", "Newcastle United", "Aston Villa", "Brighton & Hove Albion", "West Ham United",
"Crystal Palace", "Fulham", "Everton", "Bournemouth", "Brentford",
"Wolverhampton Wanderers", "Nottingham Forest", "Leeds United", "Burnley", "Sunderland",
];
const PARTICIPANT_ROWS = EPL_TEAMS.map((name, i) => ({ id: `team-${i + 1}`, name }));
const EV_ROWS = PARTICIPANT_ROWS.map((team, i) => ({
participantId: team.id,
sourceElo: 1760 - i * 25,
sourceOdds: null,
}));
function mockSelects(participantRows = PARTICIPANT_ROWS, evRows = EV_ROWS) {
let selectCallCount = 0;
return vi.fn().mockImplementation(() => {
selectCallCount++;
if (selectCallCount === 1) {
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows) }) };
}
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(evRows) }) };
});
}
describe("EPL match helpers", () => {
it("returns 0.5 win probability for equal Elo ratings", () => {
expect(eplWinProbability(1500, 1500, 400)).toBeCloseTo(0.5, 6);
});
it("supports a configurable match parity factor", () => {
const standard = eplWinProbability(1700, 1500, 400);
const higherParity = eplWinProbability(1700, 1500, 1000);
expect(standard).toBeGreaterThan(higherParity);
expect(higherParity).toBeGreaterThan(0.5);
});
it("equal Elo teams draw at a realistic rate", () => {
let draws = 0;
const N = 5000;
for (let i = 0; i < N; i++) {
if (simEplMatch(1500, 1500, { matchParityFactor: 400 }) === "draw") draws++;
}
const drawRate = draws / N;
expect(drawRate).toBeGreaterThan(0.21);
expect(drawRate).toBeLessThan(0.31);
});
});
describe("EPLSimulator.simulate()", () => {
let mockDb: { select: MockInstance };
beforeEach(async () => {
const { database } = await import("~/database/context");
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
const { getSportsSeasonSimulatorConfig } = await import("~/models/simulator");
mockDb = { select: mockSelects() };
(database as unknown as MockInstance).mockReturnValue(mockDb);
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
(getSportsSeasonSimulatorConfig as unknown as MockInstance).mockResolvedValue({
config: { iterations: 10_000, matchParityFactor: 400 },
});
});
it("throws if no participants found", async () => {
mockDb.select = mockSelects([], []);
await expect(new EPLSimulator().simulate("season-1")).rejects.toThrow(/No participants found/);
});
it("returns one result per EPL club and normalizes top-eight columns", async () => {
const results = await new EPLSimulator().simulate("season-1");
expect(results).toHaveLength(20);
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((sum, result) => sum + result.probabilities[key], 0);
expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 8);
}
for (const result of results) {
expect(result.source).toBe("epl_standings_monte_carlo");
for (const value of Object.values(result.probabilities)) {
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThanOrEqual(1);
}
}
});
it("higher Elo teams have stronger title probability", async () => {
const results = await new EPLSimulator().simulate("season-1");
const top = results.find((result) => result.participantId === "team-1");
const bottom = results.find((result) => result.participantId === "team-20");
if (!top || !bottom) throw new Error("Expected test teams not found");
expect(top.probabilities.probFirst).toBeGreaterThan(bottom.probabilities.probFirst);
});
it("uses current EPL standings when present", async () => {
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([
{
participantId: "team-20",
wins: 25,
ties: 5,
losses: 0,
gamesPlayed: 30,
tablePoints: 80,
goalsFor: 80,
goalsAgainst: 20,
goalDifference: 60,
leagueRank: 1,
},
...PARTICIPANT_ROWS.slice(0, 19).map((team) => ({
participantId: team.id,
wins: 8,
ties: 5,
losses: 17,
gamesPlayed: 30,
tablePoints: 29,
goalsFor: 35,
goalsAgainst: 55,
goalDifference: -20,
leagueRank: 10,
})),
]);
const results = await new EPLSimulator().simulate("season-1");
const leader = results.find((result) => result.participantId === "team-20");
if (!leader) throw new Error("Expected standings leader not found");
expect(leader.probabilities.probFirst).toBeGreaterThan(0.95);
});
it("throws when Elo/projected points are missing", async () => {
mockDb.select = mockSelects(PARTICIPANT_ROWS, []);
await expect(new EPLSimulator().simulate("season-1")).rejects.toThrow(/Missing Elo\/projected points/);
});
});