brackt/app/services/simulations/__tests__/afl-simulator.test.ts

289 lines
11 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { normalizeTeamName } from "~/lib/normalize-team-name";
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
// ─── normalizeTeamName ────────────────────────────────────────────────────────
describe("normalizeTeamName", () => {
it("lowercases and trims", () => {
expect(normalizeTeamName(" Western Bulldogs ")).toBe("western bulldogs");
});
it("collapses internal whitespace", () => {
expect(normalizeTeamName("Greater Western Sydney")).toBe("greater western sydney");
});
it("is already-normalized identity", () => {
expect(normalizeTeamName("gold coast")).toBe("gold coast");
});
});
// ─── getTeamData ──────────────────────────────────────────────────────────────
describe("getTeamData", () => {
it("returns data for an exact match", () => {
const d = getTeamData("Western Bulldogs");
expect(d).toBeDefined();
expect(d?.elo).toBe(1646);
});
it("is case-insensitive", () => {
expect(getTeamData("western bulldogs")).toEqual(getTeamData("Western Bulldogs"));
});
it("returns undefined for an unknown team", () => {
expect(getTeamData("Springfield Koalas")).toBeUndefined();
});
it("all 18 AFL clubs are present", () => {
const allTeams = [
"Western Bulldogs", "Gold Coast", "Hawthorn", "Geelong",
"Adelaide", "Sydney", "Fremantle", "Collingwood",
"Brisbane Lions", "Greater Western Sydney", "Carlton", "Port Adelaide",
"St Kilda", "North Melbourne", "Melbourne", "Essendon",
"Richmond", "West Coast",
];
for (const name of allTeams) {
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
}
});
it("Western Bulldogs has the highest Elo", () => {
const bulldogs = getTeamData("Western Bulldogs")?.elo ?? 0;
const westCoast = getTeamData("West Coast")?.elo ?? 0;
expect(bulldogs).toBeGreaterThan(westCoast);
});
it("Elo ratings are in the expected range (12501750)", () => {
const allTeams = [
"Western Bulldogs", "Gold Coast", "Hawthorn", "Geelong",
"Adelaide", "Sydney", "Fremantle", "Collingwood",
"Brisbane Lions", "Greater Western Sydney", "Carlton", "Port Adelaide",
"St Kilda", "North Melbourne", "Melbourne", "Essendon",
"Richmond", "West Coast",
];
for (const name of allTeams) {
const elo = getTeamData(name)?.elo ?? 0;
expect(elo, `${name} elo out of range`).toBeGreaterThanOrEqual(1250);
expect(elo, `${name} elo out of range`).toBeLessThanOrEqual(1750);
}
});
});
// ─── eloWinProbability ────────────────────────────────────────────────────────
describe("eloWinProbability (PARITY_FACTOR = 450)", () => {
it("returns 0.5 for equal Elo ratings", () => {
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
});
it("favors the higher-rated team", () => {
expect(eloWinProbability(1706, 1500)).toBeGreaterThan(0.5);
expect(eloWinProbability(1295, 1500)).toBeLessThan(0.5);
});
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
const p = eloWinProbability(1706, 1295);
expect(p + eloWinProbability(1295, 1706)).toBeCloseTo(1.0, 10);
});
it("a 450-pt gap gives ~90.9% win probability", () => {
// P = 1 / (1 + 10^(-450/450)) = 1 / (1 + 10^-1) = 1/1.1 ≈ 0.909
const p = eloWinProbability(1950, 1500);
expect(p).toBeCloseTo(1 / 1.1, 5);
});
it("Bulldogs (1646) vs Essendon (1342): strongly favors Bulldogs", () => {
// 304-pt gap at parity 450: P = 1/(1+10^(-304/450)) ≈ 0.826
const p = eloWinProbability(1646, 1342);
expect(p).toBeGreaterThan(0.80);
});
});
// ─── AFLSimulator.simulate() integration tests ───────────────────────────────
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn(),
}));
const AFL_TEAMS = [
"Western Bulldogs", "Gold Coast", "Hawthorn", "Geelong",
"Adelaide", "Sydney", "Fremantle", "Collingwood",
"Brisbane Lions", "Greater Western Sydney", "Carlton", "Port Adelaide",
"St Kilda", "North Melbourne", "Melbourne", "Essendon",
"Richmond", "West Coast",
];
const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
id: `team-${i + 1}`,
name,
}));
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
describe("AFLSimulator.simulate()", () => {
let mockDb: { select: MockInstance };
beforeEach(async () => {
const { database } = await import("~/database/context");
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(PARTICIPANT_ROWS),
}),
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
// Default: no standings (pre-season)
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
});
it("throws if no participants found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
const sim = new AFLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/);
});
it("returns 18 results — one per AFL club", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(18);
});
it("all probability values are non-negative", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
for (const val of Object.values(r.probabilities)) {
expect(val).toBeGreaterThanOrEqual(0);
}
}
});
it("each column (probFirst through probEighth) sums to 1.0 across all participants", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 2);
}
});
it("P5/P6 and P7/P8 are distinct tiers (separate column sums, not a combined 58 pool)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
// probFifth should sum to 1.0 (SF losers only) — NOT 2.0 (which would happen if EF losers were mixed in)
const fifthSum = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
const seventhSum = results.reduce((s, r) => s + r.probabilities.probSeventh, 0);
expect(fifthSum).toBeCloseTo(1.0, 2);
expect(seventhSum).toBeCloseTo(1.0, 2);
});
it("probThird === probFourth for every participant (3rd/4th share same points in AFL)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probThird).toBeCloseTo(r.probabilities.probFourth, 10);
}
});
it("probFifth === probSixth for every participant (5th/6th share same points in AFL)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probFifth).toBeCloseTo(r.probabilities.probSixth, 10);
}
});
it("probSeventh === probEighth for every participant (7th/8th share same points in AFL)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probSeventh).toBeCloseTo(r.probabilities.probEighth, 10);
}
});
it("uses source: 'afl_bracket_monte_carlo' on all results", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.source).toBe("afl_bracket_monte_carlo");
}
});
it("all result participant IDs match input participant IDs", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const resultIds = new Set(results.map((r) => r.participantId));
for (const id of PARTICIPANT_IDS) {
expect(resultIds.has(id), `missing participant: ${id}`).toBe(true);
}
});
it("Western Bulldogs (highest Elo) has the highest championship probability", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const bulldogsResult = results.find((r) => r.participantId === "team-1"); // Western Bulldogs (highest Elo)
const westCoastResult = results.find((r) => r.participantId === "team-18"); // West Coast (near-lowest Elo)
if (!bulldogsResult || !westCoastResult) throw new Error("Expected results not found");
// The #1 Elo team should win the championship more often than the last-ranked team
expect(bulldogsResult.probabilities.probFirst).toBeGreaterThan(westCoastResult.probabilities.probFirst);
});
it("bottom-ranked teams rarely make finals (low combined probability)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
// West Coast and Richmond (16th/17th Elo) should have very low combined finals probability
const westCoast = results.find((r) => r.participantId === "team-18");
const richmond = results.find((r) => r.participantId === "team-17");
if (!westCoast || !richmond) throw new Error("Expected results not found");
const wcTotal = Object.values(westCoast.probabilities).reduce((a, b) => a + b, 0);
const ricTotal = Object.values(richmond.probabilities).reduce((a, b) => a + b, 0);
// Combined probability for a bottom team should be well below 1.0
expect(wcTotal).toBeLessThan(0.5);
expect(ricTotal).toBeLessThan(0.5);
});
it("mid-season standings: team with most wins has elevated finals probability", async () => {
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
// Give Western Bulldogs (team-1) 15 wins from 18 games — top of ladder
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([
{ participantId: "team-1", wins: 15, gamesPlayed: 18, losses: 3 },
// All other teams have 5 wins
...PARTICIPANT_IDS.slice(1).map((id) => ({ participantId: id, wins: 5, gamesPlayed: 18, losses: 13 })),
]);
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const leader = results.find((r) => r.participantId === "team-1");
const bottom = results.find((r) => r.participantId === "team-18");
if (!leader || !bottom) throw new Error("Expected results not found");
expect(leader.probabilities.probFirst).toBeGreaterThan(bottom.probabilities.probFirst);
});
});