204 lines
7.4 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|