143 lines
5 KiB
TypeScript
143 lines
5 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(),
|
|
}));
|
|
|
|
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)).toBeCloseTo(0.5, 6);
|
|
});
|
|
|
|
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) === "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");
|
|
|
|
mockDb = { select: mockSelects() };
|
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
|
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|