brackt/app/models/__tests__/surface-elo.test.ts
Chris Parsons d7ba20d9ea
Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.

New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.

Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)

Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00

131 lines
4.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the database context before importing any model
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { database } from "~/database/context";
import { getSurfaceEloMap, batchUpsertSurfaceElos, getSurfaceElosForSeason } from "../surface-elo";
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockReturnThis(),
};
beforeEach(() => {
vi.clearAllMocks();
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
// Reset chain to return mock itself (supports fluent chaining)
mockDb.select.mockReturnValue(mockDb);
mockDb.from.mockReturnValue(mockDb);
mockDb.where.mockReturnValue(mockDb);
mockDb.innerJoin.mockReturnValue(mockDb);
mockDb.orderBy.mockReturnValue(mockDb);
mockDb.insert.mockReturnValue(mockDb);
mockDb.values.mockReturnValue(mockDb);
mockDb.onConflictDoUpdate.mockResolvedValue(undefined);
});
describe("getSurfaceEloMap", () => {
it("returns an empty Map when no records exist", async () => {
mockDb.where.mockResolvedValue([]);
const result = await getSurfaceEloMap("season-1");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it("returns a Map keyed by participantId", async () => {
mockDb.where.mockResolvedValue([
{ participantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
{ participantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null },
]);
const result = await getSurfaceEloMap("season-1");
expect(result.size).toBe(2);
expect(result.get("p1")).toEqual({ worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 });
expect(result.get("p2")).toEqual({ worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null });
});
it("preserves null surface Elos (does not coerce to 0)", async () => {
mockDb.where.mockResolvedValue([
{ participantId: "p1", worldRanking: null, eloHard: null, eloClay: null, eloGrass: null },
]);
const result = await getSurfaceEloMap("season-1");
const entry = result.get("p1");
expect(entry?.worldRanking).toBeNull();
expect(entry?.eloHard).toBeNull();
expect(entry?.eloClay).toBeNull();
expect(entry?.eloGrass).toBeNull();
});
});
describe("batchUpsertSurfaceElos", () => {
it("does nothing when given an empty array", async () => {
await batchUpsertSurfaceElos([]);
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("calls insert with the correct values", async () => {
const inputs = [
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
{ participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined },
];
await batchUpsertSurfaceElos(inputs);
expect(mockDb.insert).toHaveBeenCalledOnce();
expect(mockDb.values).toHaveBeenCalledOnce();
const passedValues = mockDb.values.mock.calls[0][0] as Array<{
participantId: string;
eloHard: number | null;
eloClay: number | null;
eloGrass: number | null;
}>;
expect(passedValues).toHaveLength(2);
expect(passedValues[0].participantId).toBe("p1");
expect(passedValues[0].eloHard).toBe(1800);
expect(passedValues[1].eloHard).toBeNull();
expect(passedValues[1].eloGrass).toBeNull(); // undefined → null
});
it("uses onConflictDoUpdate for upsert behaviour", async () => {
await batchUpsertSurfaceElos([
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 },
]);
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
});
});
describe("getSurfaceElosForSeason", () => {
it("returns records joined with participant names", async () => {
mockDb.orderBy.mockResolvedValue([
{
id: "rec-1",
participantId: "p1",
sportsSeasonId: "s1",
worldRanking: 1,
eloHard: 1800,
eloClay: 1700,
eloGrass: 1600,
updatedAt: new Date("2025-01-01"),
participantName: "Carlos Alcaraz",
},
]);
const result = await getSurfaceElosForSeason("s1");
expect(result).toHaveLength(1);
expect(result[0].participantName).toBe("Carlos Alcaraz");
expect(result[0].eloHard).toBe(1800);
});
it("returns empty array when no Elo records exist", async () => {
mockDb.orderBy.mockResolvedValue([]);
const result = await getSurfaceElosForSeason("s1");
expect(result).toEqual([]);
});
});