import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { normalizeTeamName } from "~/lib/normalize-team-name"; import { getTeamData, eloWinProbability, AFLSimulator, readAflBracketSeeds, type BracketMatch, } from "../afl-simulator"; import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator"; // ─── 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); /** * Build the playoff_matches rows generateAFL10Bracket writes, seeded with `seedIds` in * ladder order (index 0 = minor premier). `completed` overrides individual matches with a * recorded result. */ function aflBracketMatches( seedIds: string[], completed: Array<{ round: string; matchNumber: number; winnerId: string; loserId: string }> = [] ): BracketMatch[] { const seed = (n: number) => seedIds[n - 1] ?? null; const rows: BracketMatch[] = [ { round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) }, { round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) }, { round: "Qualifying Finals", matchNumber: 1, participant1Id: seed(1), participant2Id: seed(4) }, { round: "Qualifying Finals", matchNumber: 2, participant1Id: seed(2), participant2Id: seed(3) }, // participant2 is TBD until a Wildcard winner advances into it. { round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null }, { round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null }, { round: "Semi-Finals", matchNumber: 1, participant1Id: null, participant2Id: null }, { round: "Semi-Finals", matchNumber: 2, participant1Id: null, participant2Id: null }, { round: "Preliminary Finals", matchNumber: 1, participant1Id: null, participant2Id: null }, { round: "Preliminary Finals", matchNumber: 2, participant1Id: null, participant2Id: null }, { round: "Grand Final", matchNumber: 1, participant1Id: null, participant2Id: null }, ].map((m) => ({ ...m, winnerId: null, loserId: null, isComplete: false })); for (const done of completed) { const row = rows.find((r) => r.round === done.round && r.matchNumber === done.matchNumber); if (!row) throw new Error(`no such match: ${done.round} #${done.matchNumber}`); row.isComplete = true; row.winnerId = done.winnerId; row.loserId = done.loserId; // A Wildcard winner is advanced into the Elimination Final it feeds. if (done.round === "Wildcard Round") { const ef = rows.find( (r) => r.round === "Elimination Finals" && r.matchNumber === (done.matchNumber === 1 ? 2 : 1) ); if (ef) ef.participant2Id = done.winnerId; } } return rows; } /** The one bracket row for a round/match, failing loudly if the fixture changes shape. */ function matchIn(matches: BracketMatch[], round: string, matchNumber: number): BracketMatch { const found = matches.find((m) => m.round === round && m.matchNumber === matchNumber); if (!found) throw new Error(`no such match: ${round} #${matchNumber}`); return found; } /** Look up one participant's result, failing loudly rather than silently passing on undefined. */ function resultFor(results: T[], participantId: string): T { const found = results.find((r) => r.participantId === participantId); if (!found) throw new Error(`no simulation result for ${participantId}`); return found; } /** EV on the reference scale the runner persists with. */ function evOf(result: { probabilities: ProbabilityDistribution }): number { return calculateEV(result.probabilities, DEFAULT_SCORING_RULES); } describe("AFLSimulator.simulate()", () => { let mockDb: { select: MockInstance; query: { scoringEvents: { findMany: MockInstance }; playoffMatches: { findMany: MockInstance }; }; }; /** Put a seeded afl_10 bracket in front of the simulator. */ function seedBracket(matches: BracketMatch[]) { mockDb.query.scoringEvents.findMany.mockResolvedValue([{ id: "event-1" }]); mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); } beforeEach(async () => { const { database } = await import("~/database/context"); const { getRegularSeasonStandings } = await import("~/models/regular-season-standings"); const participantRows = PARTICIPANT_ROWS; let selectCallCount = 0; mockDb = { // Default: no bracket generated yet, so the ladder-projection path runs. query: { scoringEvents: { findMany: vi.fn().mockResolvedValue([]) }, playoffMatches: { findMany: vi.fn().mockResolvedValue([]) }, }, select: vi.fn().mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows), }), }; } // Second call: sourceElo query (no DB Elo by default) return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }; }), }; (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 () => { let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }; } return { 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); }); it("DB sourceElo overrides hardcoded TEAMS_DATA values", async () => { // Set DB Elo for West Coast (team-18) to 1800 (higher than Bulldogs) let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(PARTICIPANT_ROWS), }), }; } return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([ { participantId: "team-18", sourceElo: 1800 }, ]), }), }; }); const sim = new AFLSimulator(); const results = await sim.simulate("season-1"); const westCoast = results.find((r) => r.participantId === "team-18"); const bulldogs = results.find((r) => r.participantId === "team-1"); if (!westCoast || !bulldogs) throw new Error("Expected results not found"); // With DB Elo 1800, West Coast should now be favored over Bulldogs (1646) expect(westCoast.probabilities.probFirst).toBeGreaterThan(bulldogs.probabilities.probFirst); }); it("falls back to hardcoded TEAMS_DATA when no DB sourceElo exists", async () => { // Default mock already returns no sourceElo rows — should use TEAMS_DATA const sim = new AFLSimulator(); const results = await sim.simulate("season-1"); const bulldogs = results.find((r) => r.participantId === "team-1"); const westCoast = results.find((r) => r.participantId === "team-18"); if (!bulldogs || !westCoast) throw new Error("Expected results not found"); // Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst); }); // ─── Bracket-aware mode ───────────────────────────────────────────────────── // // afl_10 banks points on seeding alone (entryFloor 5 for seeds 1-4, 7 for seeds 5-6) and // on winning a non-scoring round (nonScoringWinnerFloor 7 for the Wildcard Round, 3 for a // Qualifying Final). Those floors are paid out as real fantasy points, so a simulator that // re-draws the ladder every iteration — putting a seeded team back in the Wildcard Round or // out of the finals, where it scores 0 — reports an EV below points already awarded. Each // EV assertion below is that floor. describe("bracket-aware mode", () => { /** * Seeds 1-10 in ladder order, drawn from the ten *weakest* clubs by Elo. Seeding the * strongest ten would let the ladder-projection path produce much the same field by * accident, so the floor assertions below would pass even with the bracket ignored. */ const SEEDS = PARTICIPANT_IDS.slice(8); it("never values a seed below the entry floor its seeding already banked", async () => { seedBracket(aflBracketMatches(SEEDS)); const results = await new AFLSimulator().simulate("season-1"); // Seeds 1-4 enter a Qualifying Final: lose it, lose the Semi-Final, still 5th-6th (25). for (const seed of [1, 2, 3, 4]) { expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(25); } // Seeds 5-6 enter an Elimination Final: lose it and they are 7th-8th (15). for (const seed of [5, 6]) { expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(15); } }); it("keeps a Qualifying Final entrant out of the 7th-8th tier entirely", async () => { seedBracket(aflBracketMatches(SEEDS)); const results = await new AFLSimulator().simulate("season-1"); // A seed 1-4 loses the QF into a Semi-Final, so 5th-6th is its worst finish. The // 7th-8th tier is reachable only by losing an Elimination Final. for (const seed of [1, 2, 3, 4]) { expect(resultFor(results, SEEDS[seed - 1]).probabilities.probSeventh, `seed ${seed}`).toBe(0); } // Seeds 5-10 all reach an Elimination Final only by playing one, so they can. expect(resultFor(results, SEEDS[4]).probabilities.probSeventh).toBeGreaterThan(0); }); it("uses the bracket's draw rather than a re-projected ladder", async () => { // Deliberately inverted: the weakest club is the minor premier and the strongest // scrapes in 10th. On the ladder-projection path Elo decides the seeding, so this only // holds if the bracket's own slots are being read. const inverted = [ "team-18", "team-17", "team-16", "team-15", "team-14", "team-13", "team-12", "team-11", "team-10", "team-1", ]; seedBracket(aflBracketMatches(inverted)); const results = await new AFLSimulator().simulate("season-1"); // West Coast (weakest Elo) is seeded 1, so it holds the double chance and can never // finish 7th-8th, and its EV clears the seed 1-4 floor. expect(resultFor(results, "team-18").probabilities.probSeventh).toBe(0); expect(evOf(resultFor(results, "team-18"))).toBeGreaterThanOrEqual(25); // Western Bulldogs (strongest Elo) is seeded 10, so it starts in the Wildcard Round // with nothing banked and can be knocked out for 0. expect(resultFor(results, "team-1").probabilities.probSeventh).toBeGreaterThan(0); }); it("zeroes every participant outside the bracket", async () => { seedBracket(aflBracketMatches(SEEDS)); const results = await new AFLSimulator().simulate("season-1"); for (const r of results.filter((x) => !SEEDS.includes(x.participantId))) { expect(evOf(r), r.participantId).toBe(0); } expect(results).toHaveLength(18); }); it("still normalizes every column to 1.0 and the field to 340 total EV", async () => { seedBracket(aflBracketMatches(SEEDS)); const results = await new AFLSimulator().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, 6); } expect(results.reduce((s, r) => s + evOf(r), 0)).toBeCloseTo(340, 4); }); it("replays a completed Wildcard Round instead of re-simulating it", async () => { // Seed 10 beat seed 7, which banks seed 10 a 7th-place floor (15 points). seedBracket( aflBracketMatches(SEEDS, [ { round: "Wildcard Round", matchNumber: 1, winnerId: SEEDS[9], loserId: SEEDS[6] }, ]) ); const results = await new AFLSimulator().simulate("season-1"); expect(evOf(resultFor(results, SEEDS[9]))).toBeGreaterThanOrEqual(15); // The loser is out with nothing, in every iteration. expect(evOf(resultFor(results, SEEDS[6]))).toBe(0); }); it("replays a completed Qualifying Final, banking the winner's 3rd-4th floor", async () => { // Seed 1 beat seed 4: the winner byes into a Preliminary Final (floor 3rd, 45 points) // and the loser drops into a Semi-Final (floor 5th, 25 points). seedBracket( aflBracketMatches(SEEDS, [ { round: "Qualifying Finals", matchNumber: 1, winnerId: SEEDS[0], loserId: SEEDS[3] }, ]) ); const results = await new AFLSimulator().simulate("season-1"); const winner = resultFor(results, SEEDS[0]); expect(evOf(winner)).toBeGreaterThanOrEqual(45); // Already through to a Preliminary Final, so the 5th-6th tier is behind it. expect(winner.probabilities.probFifth).toBe(0); expect(evOf(resultFor(results, SEEDS[3]))).toBeGreaterThanOrEqual(25); }); it("falls back to the ladder projection when the bracket carries no seeds", async () => { seedBracket(aflBracketMatches([])); const results = await new AFLSimulator().simulate("season-1"); // Every club is back in contention, so nobody is structurally zeroed. expect(results.filter((r) => evOf(r) > 0).length).toBeGreaterThan(10); }); }); }); // ─── readAflBracketSeeds ────────────────────────────────────────────────────── describe("readAflBracketSeeds", () => { const teamsById = new Map( PARTICIPANT_IDS.map((id) => [id, { id, name: id, elo: 1500, currentWins: 0, remainingGames: 0, winProb: 0.5 }]) ); const SEEDS = PARTICIPANT_IDS.slice(0, 10); it("returns null when there is no bracket at all", () => { expect(readAflBracketSeeds([], teamsById as never)).toBeNull(); }); it("returns null for a generated but unseeded bracket", () => { expect(readAflBracketSeeds(aflBracketMatches([]), teamsById as never)).toBeNull(); }); it("reads the 10 seeds in ladder order", () => { const bracket = readAflBracketSeeds(aflBracketMatches(SEEDS), teamsById as never); expect(bracket?.seeds.map((t) => t.id)).toEqual(SEEDS); }); it("does not treat the TBD Elimination Final slots as missing seeds", () => { const matches = aflBracketMatches(SEEDS); for (const m of matches.filter((r) => r.round === "Elimination Finals")) { expect(m.participant2Id).toBeNull(); } expect(readAflBracketSeeds(matches, teamsById as never)).not.toBeNull(); }); it("throws on a partially seeded bracket rather than discarding the draw", () => { const matches = aflBracketMatches(SEEDS); // ON DELETE SET NULL empties a slot when a participant is removed and re-added. matchIn(matches, "Qualifying Finals", 1).participant2Id = null; expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/partially seeded.*seed\(s\) 4/s); }); it("throws when one participant holds two slots", () => { const matches = aflBracketMatches(SEEDS); matchIn(matches, "Wildcard Round", 1).participant2Id = SEEDS[0]; expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/more than one slot/); }); it("throws when the bracket references a participant outside the season", () => { const matches = aflBracketMatches(SEEDS); matchIn(matches, "Wildcard Round", 1).participant2Id = "ghost"; expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/); }); });