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 (1250–1750)", () => { 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 5–8 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); }); });