brackt/app/services/simulations/__tests__/afl-simulator.test.ts
Chris Parsons 7fa88fc0ef
Add projected-wins input mode to admin Elo Ratings page (#306)
* Add projected-wins input mode to admin Elo Ratings page

Admins can now enter projected season win totals instead of raw Elo
numbers on the Elo Ratings page. Wins are auto-converted to Elo using
the inverse formula and stored as sourceElo, keeping the rest of the
simulation pipeline unchanged.

- Add `projectedWinsToElo` / `eloToProjectedWins` to probability-engine
- Add `simulator-config.ts` centralising per-sport season length, parity
  factor, and average opponent Elo (AFL, NFL, NBA, NHL, MLB, WNBA)
- Admin Elo Ratings page: toggle between Elo and Projected Wins input
  modes; bulk import parses wins format; existing sourceElo back-fills
  the wins field on load
- AFL simulator now reads sourceElo from participantExpectedValues first,
  falling back to hardcoded TEAMS_DATA then 1400

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix lint errors in simulator-config tests and probability-engine

- Replace non-null assertions (`!`) with optional chaining (`?.`) in simulator-config tests
- Remove redundant type annotations on default parameters in probability-engine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 12:40:08 -07:00

358 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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");
const participantRows = PARTICIPANT_ROWS;
let selectCallCount = 0;
mockDb = {
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 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);
});
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);
});
});