brackt/app/services/simulations/__tests__/college-hockey-simulator.test.ts
2026-05-08 12:29:39 -07:00

204 lines
7.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import {
buildCollegeHockeyTeams,
CollegeHockeySimulator,
npiRankSelectionWeight,
npiRankToElo,
} from "../college-hockey-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
const IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`);
const POOL_IDS = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
function makeParticipants(ids: string[]) {
return ids.map((id) => ({ id }));
}
function makeEvRows(ids: string[]) {
return ids.map((participantId, i) => ({
participantId,
sourceElo: 1700 - i * 20,
sourceOdds: null,
worldRanking: i + 1,
}));
}
function makeMatch(round: string, matchNumber: number, p1?: string, p2?: string, winner?: string, loser?: string) {
return {
id: `${round}-${matchNumber}`,
scoringEventId: "event-1",
round,
templateRound: null,
matchNumber,
participant1Id: p1 ?? null,
participant2Id: p2 ?? null,
winnerId: winner ?? null,
loserId: loser ?? null,
isComplete: Boolean(winner),
participant1Score: null,
participant2Score: null,
isScoring: round !== "Regional Semifinals",
seedInfo: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
function makeCompletedBracket() {
const r16Winners = IDS.slice(0, 8);
const qfWinners = IDS.slice(0, 4);
const sfWinners = IDS.slice(0, 2);
return [
...Array.from({ length: 8 }, (_, i) =>
makeMatch("Regional Semifinals", i + 1, IDS[i], IDS[15 - i], r16Winners[i], IDS[15 - i])
),
...Array.from({ length: 4 }, (_, i) =>
makeMatch("Regional Finals", i + 1, r16Winners[i * 2], r16Winners[i * 2 + 1], qfWinners[i], r16Winners[i * 2 + 1])
),
...Array.from({ length: 2 }, (_, i) =>
makeMatch("Frozen Four Semifinals", i + 1, qfWinners[i * 2], qfWinners[i * 2 + 1], sfWinners[i], qfWinners[i * 2 + 1])
),
makeMatch("National Championship", 1, sfWinners[0], sfWinners[1], sfWinners[0], sfWinners[1]),
];
}
function makeBracketWithStaleFutureSlots() {
return [
...Array.from({ length: 8 }, (_, i) =>
makeMatch("Regional Semifinals", i + 1, IDS[i], IDS[15 - i])
),
// These incomplete downstream slots intentionally contain stale participants.
// The simulator should ignore them and use the simulated/completed upstream winners.
makeMatch("Regional Finals", 1, "team-9", "team-10"),
makeMatch("Regional Finals", 2, "team-11", "team-12"),
makeMatch("Regional Finals", 3, "team-13", "team-14"),
makeMatch("Regional Finals", 4, "team-15", "team-16"),
makeMatch("Frozen Four Semifinals", 1),
makeMatch("Frozen Four Semifinals", 2),
makeMatch("National Championship", 1),
];
}
describe("college hockey input helpers", () => {
it("maps better NPI ranks to higher Elo ratings", () => {
expect(npiRankToElo(1, 64)).toBeGreaterThan(npiRankToElo(16, 64));
});
it("maps better NPI ranks to higher Zipf selection weights", () => {
expect(npiRankSelectionWeight(1)).toBeGreaterThan(npiRankSelectionWeight(10));
});
it("builds team strength from admin-editable NPI rank when Elo is absent", () => {
const teams = buildCollegeHockeyTeams(["one", "two"], [
{ participantId: "one", sourceElo: null, sourceOdds: null, worldRanking: 1 },
{ participantId: "two", sourceElo: null, sourceOdds: null, worldRanking: 20 },
]);
expect(teams[0].elo).toBeGreaterThan(teams[1].elo);
expect(teams[0].selectionWeight).toBeGreaterThan(teams[1].selectionWeight);
});
it("uses NPI rank before Elo for seeding when both are present", () => {
const teams = buildCollegeHockeyTeams(["strong-elo", "better-npi"], [
{ participantId: "strong-elo", sourceElo: 1800, sourceOdds: null, worldRanking: 12 },
{ participantId: "better-npi", sourceElo: 1500, sourceOdds: null, worldRanking: 2 },
]);
const byId = new Map(teams.map((team) => [team.participantId, team]));
expect(byId.get("better-npi")?.seedScore).toBeGreaterThan(byId.get("strong-elo")?.seedScore ?? 0);
});
it("gives no-odds teams a fallback field-selection weight when odds are partial", () => {
const teams = buildCollegeHockeyTeams(["with-odds", "no-odds"], [
{ participantId: "with-odds", sourceElo: null, sourceOdds: 200, worldRanking: 1 },
{ participantId: "no-odds", sourceElo: null, sourceOdds: null, worldRanking: 2 },
]);
const noOdds = teams.find((team) => team.participantId === "no-odds");
expect(noOdds?.selectionWeight).toBeGreaterThan(0);
});
});
describe("CollegeHockeySimulator", () => {
let mockDb: {
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
select: MockInstance;
};
let selectCallCount: number;
beforeEach(async () => {
selectCallCount = 0;
const { database } = await import("~/database/context");
mockDb = {
query: {
scoringEvents: { findFirst: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn(),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
});
function setupPreBracket(ids: string[]) {
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.select.mockImplementation(() => {
const data = selectCallCount++ === 0 ? makeParticipants(ids) : makeEvRows(ids);
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
}
function setupExistingBracket(matches = makeCompletedBracket()) {
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
mockDb.select.mockImplementation(() => ({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(makeEvRows(IDS)) }),
}));
}
it("simulates a pre-bracket 20-team pool and returns all participants", async () => {
setupPreBracket(POOL_IDS);
const results = await new CollegeHockeySimulator().simulate("season-1");
expect(results).toHaveLength(20);
expect(results.every((r) => r.source === "college_hockey_monte_carlo")).toBe(true);
});
it("normalizes champion and finalist probability buckets", async () => {
setupPreBracket(IDS);
const results = await new CollegeHockeySimulator().simulate("season-1");
expect(results.reduce((sum, r) => sum + r.probabilities.probFirst, 0)).toBeCloseTo(1, 1);
expect(results.reduce((sum, r) => sum + r.probabilities.probSecond, 0)).toBeCloseTo(1, 1);
});
it("honors completed results in an existing Frozen Four bracket", async () => {
setupExistingBracket();
const results = await new CollegeHockeySimulator().simulate("season-1");
const byId = new Map(results.map((result) => [result.participantId, result]));
expect(byId.get("team-1")?.probabilities.probFirst).toBe(1);
expect(byId.get("team-2")?.probabilities.probSecond).toBe(1);
});
it("ignores stale future-round slots when upstream rounds are unresolved", async () => {
setupExistingBracket(makeBracketWithStaleFutureSlots());
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
try {
const results = await new CollegeHockeySimulator().simulate("season-1");
const byId = new Map(results.map((result) => [result.participantId, result]));
expect(byId.get("team-1")?.probabilities.probFirst).toBe(1);
expect(byId.get("team-9")?.probabilities.probFirst).toBe(0);
} finally {
randomSpy.mockRestore();
}
});
});