Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests
- NHLSimulator.simulateBracket: remove unused nameById variable - NHLSimulator.simulateBracket: validate all R1 participants before entering Monte Carlo loop; throws with a clear message if any slot is missing - wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter on resolveElo() (overrides SRS, overrides futures, overrides fallback, null/undefined fall through to normal priority chain) - nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests) covering fallback to season-projection, structure validation errors, probability distributions, fully-decided champion, and source tag https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
This commit is contained in:
parent
9684ce6b92
commit
2b57b1ef7a
3 changed files with 306 additions and 2 deletions
|
|
@ -1,9 +1,10 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import {
|
||||
normalizeTeamName,
|
||||
getTeamData,
|
||||
eloWinProbability,
|
||||
simulateProjectedPoints,
|
||||
NHLSimulator,
|
||||
} from "../nhl-simulator";
|
||||
|
||||
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
||||
|
|
@ -171,3 +172,277 @@ describe("simulateProjectedPoints", () => {
|
|||
expect(avg(0.7)).toBeGreaterThan(avg(0.4));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── NHLSimulator — bracket-aware mode ───────────────────────────────────────
|
||||
|
||||
const TEAM_IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`);
|
||||
|
||||
function makeMatch(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
opts: {
|
||||
p1?: string | null;
|
||||
p2?: string | null;
|
||||
winnerId?: string | null;
|
||||
loserId?: string | null;
|
||||
isComplete?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
id: `${round.replace(/\s/g, "-").toLowerCase()}-m${matchNumber}`,
|
||||
scoringEventId: "event-1",
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id: opts.p1 ?? null,
|
||||
participant2Id: opts.p2 ?? null,
|
||||
winnerId: opts.winnerId ?? null,
|
||||
loserId: opts.loserId ?? null,
|
||||
isComplete: opts.isComplete ?? false,
|
||||
isScoring: false,
|
||||
templateRound: round,
|
||||
seedInfo: null,
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a full 16-team NHL bracket fixture:
|
||||
* R1: 8 matches (Wild Card, matchNumbers 1–8)
|
||||
* R2: 4 matches (Divisional, matchNumbers 1–4) — participants null (filled from R1 winners)
|
||||
* R3: 2 matches (Conference Finals, matchNumbers 1–2)
|
||||
* Final: 1 match (Stanley Cup, matchNumber 1)
|
||||
*/
|
||||
function buildFullNHLBracket() {
|
||||
const t = TEAM_IDS;
|
||||
return [
|
||||
makeMatch("Wild Card", 1, { p1: t[0], p2: t[15] }),
|
||||
makeMatch("Wild Card", 2, { p1: t[1], p2: t[14] }),
|
||||
makeMatch("Wild Card", 3, { p1: t[2], p2: t[13] }),
|
||||
makeMatch("Wild Card", 4, { p1: t[3], p2: t[12] }),
|
||||
makeMatch("Wild Card", 5, { p1: t[4], p2: t[11] }),
|
||||
makeMatch("Wild Card", 6, { p1: t[5], p2: t[10] }),
|
||||
makeMatch("Wild Card", 7, { p1: t[6], p2: t[9] }),
|
||||
makeMatch("Wild Card", 8, { p1: t[7], p2: t[8] }),
|
||||
makeMatch("Divisional", 1),
|
||||
makeMatch("Divisional", 2),
|
||||
makeMatch("Divisional", 3),
|
||||
makeMatch("Divisional", 4),
|
||||
makeMatch("Conference Finals", 1),
|
||||
makeMatch("Conference Finals", 2),
|
||||
makeMatch("Stanley Cup", 1),
|
||||
];
|
||||
}
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/regular-season-standings", () => ({
|
||||
getRegularSeasonStandings: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
describe("NHLSimulator — bracket-aware mode", () => {
|
||||
let mockDb: {
|
||||
query: {
|
||||
scoringEvents: { findFirst: MockInstance };
|
||||
playoffMatches: { findMany: MockInstance };
|
||||
};
|
||||
select: MockInstance;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
|
||||
mockDb = {
|
||||
query: {
|
||||
scoringEvents: { findFirst: vi.fn() },
|
||||
playoffMatches: { findMany: vi.fn() },
|
||||
},
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(
|
||||
TEAM_IDS.map((id) => ({ id, name: id }))
|
||||
),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
||||
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
|
||||
id: "event-1",
|
||||
sportsSeasonId: "season-1",
|
||||
eventType: "playoff_game",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to season-projection when no bracket event exists", async () => {
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
// Season-projection fails: unknown team IDs → no division data → division check throws
|
||||
await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/);
|
||||
});
|
||||
|
||||
it("falls back to season-projection when bracket matches have no participants", async () => {
|
||||
const emptyMatches = buildFullNHLBracket().map((m) => ({
|
||||
...m,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
}));
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(emptyMatches);
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/);
|
||||
});
|
||||
|
||||
it("throws if bracket has fewer than 4 rounds", async () => {
|
||||
const oneRound = Array.from({ length: 8 }, (_, i) =>
|
||||
makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 8] ?? TEAM_IDS[0] })
|
||||
);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(oneRound);
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
await expect(sim.simulate("season-1")).rejects.toThrow(/unexpected structure/);
|
||||
});
|
||||
|
||||
it("throws if R1 has wrong match count", async () => {
|
||||
const shortR1 = [
|
||||
...Array.from({ length: 4 }, (_, i) =>
|
||||
makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 4] })
|
||||
),
|
||||
makeMatch("Divisional", 1),
|
||||
makeMatch("Divisional", 2),
|
||||
makeMatch("Conference Finals", 1),
|
||||
makeMatch("Stanley Cup", 1),
|
||||
];
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(shortR1);
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 8 first-round matches/);
|
||||
});
|
||||
|
||||
it("throws if an R1 participant slot is missing", async () => {
|
||||
const matches = buildFullNHLBracket();
|
||||
const r1m3 = matches.find((m) => m.round === "Wild Card" && m.matchNumber === 3)!;
|
||||
r1m3.participant2Id = null;
|
||||
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/);
|
||||
});
|
||||
|
||||
it("returns results for all 16 participants", async () => {
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results).toHaveLength(16);
|
||||
});
|
||||
|
||||
it("each probability column sums to 1.0 across all 16 participants", async () => {
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
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, `column ${key} should sum to 1`).toBeCloseTo(1.0, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("all probabilities are non-negative", async () => {
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
for (const r of results) {
|
||||
for (const v of Object.values(r.probabilities)) {
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("champion has probFirst=1 when bracket is fully decided", async () => {
|
||||
const t = TEAM_IDS;
|
||||
const matches = buildFullNHLBracket();
|
||||
|
||||
// R1: p1 always wins
|
||||
const r1 = matches.filter((m) => m.round === "Wild Card");
|
||||
for (const m of r1) {
|
||||
m.winnerId = m.participant1Id;
|
||||
m.loserId = m.participant2Id;
|
||||
m.isComplete = true;
|
||||
}
|
||||
const r1Winners = r1.map((m) => m.winnerId!);
|
||||
|
||||
// R2: fill from R1 winners, p1 always wins
|
||||
const r2 = matches.filter((m) => m.round === "Divisional");
|
||||
for (let i = 0; i < 4; i++) {
|
||||
r2[i].participant1Id = r1Winners[i * 2];
|
||||
r2[i].participant2Id = r1Winners[i * 2 + 1];
|
||||
r2[i].winnerId = r2[i].participant1Id;
|
||||
r2[i].loserId = r2[i].participant2Id;
|
||||
r2[i].isComplete = true;
|
||||
}
|
||||
const r2Winners = r2.map((m) => m.winnerId!);
|
||||
|
||||
// R3: fill from R2 winners, p1 always wins
|
||||
const r3 = matches.filter((m) => m.round === "Conference Finals");
|
||||
for (let i = 0; i < 2; i++) {
|
||||
r3[i].participant1Id = r2Winners[i * 2];
|
||||
r3[i].participant2Id = r2Winners[i * 2 + 1];
|
||||
r3[i].winnerId = r3[i].participant1Id;
|
||||
r3[i].loserId = r3[i].participant2Id;
|
||||
r3[i].isComplete = true;
|
||||
}
|
||||
const r3Winners = r3.map((m) => m.winnerId!);
|
||||
|
||||
// Final: t[0] vs t[1], t[0] wins
|
||||
const champion = t[0];
|
||||
const final = matches.find((m) => m.round === "Stanley Cup")!;
|
||||
final.participant1Id = r3Winners[0];
|
||||
final.participant2Id = r3Winners[1];
|
||||
final.winnerId = champion;
|
||||
final.loserId = r3Winners[1];
|
||||
final.isComplete = true;
|
||||
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
const champResult = results.find((r) => r.participantId === champion);
|
||||
expect(champResult, "champion result should exist").toBeDefined();
|
||||
expect(champResult?.probabilities.probFirst).toBeCloseTo(1.0, 3);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.participantId !== champion) {
|
||||
expect(r.probabilities.probFirst).toBeCloseTo(0, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses source 'nhl_bracket_monte_carlo' on all results", async () => {
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
|
||||
|
||||
const sim = new NHLSimulator();
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
for (const r of results) {
|
||||
expect(r.source).toBe("nhl_bracket_monte_carlo");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,6 +80,26 @@ describe("resolveElo", () => {
|
|||
it("futures mode with no futures: falls back to 1500", () => {
|
||||
expect(resolveElo(8, null, false)).toBe(1500);
|
||||
});
|
||||
|
||||
it("sourceElo overrides SRS when in SRS mode", () => {
|
||||
expect(resolveElo(5, 1600, true, 1750)).toBe(1750);
|
||||
});
|
||||
|
||||
it("sourceElo overrides futures when not in SRS mode", () => {
|
||||
expect(resolveElo(null, 1600, false, 1750)).toBe(1750);
|
||||
});
|
||||
|
||||
it("sourceElo overrides 1500 fallback when all other signals are absent", () => {
|
||||
expect(resolveElo(null, null, false, 1700)).toBe(1700);
|
||||
});
|
||||
|
||||
it("null sourceElo falls through to normal priority chain", () => {
|
||||
expect(resolveElo(5, 1600, true, null)).toBe(srsToElo(5));
|
||||
});
|
||||
|
||||
it("undefined sourceElo falls through to normal priority chain", () => {
|
||||
expect(resolveElo(null, 1620, false, undefined)).toBe(1620);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simSeriesN ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -607,6 +607,16 @@ export class NHLSimulator implements Simulator {
|
|||
);
|
||||
}
|
||||
|
||||
// Validate all R1 matches have participants before entering the hot loop.
|
||||
for (const m of r1Matches) {
|
||||
if (!m.participant1Id || !m.participant2Id) {
|
||||
throw new Error(
|
||||
`Round 1 match ${m.matchNumber} is missing participants. ` +
|
||||
`Seed all 16 teams into the bracket before running simulation.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Load all participants and EV data in parallel.
|
||||
const [participantRows, evRows] = await Promise.all([
|
||||
db
|
||||
|
|
@ -624,7 +634,6 @@ export class NHLSimulator implements Simulator {
|
|||
]);
|
||||
|
||||
const allParticipantIds = participantRows.map((r) => r.id);
|
||||
const nameById = new Map(participantRows.map((r) => [r.id, r.name]));
|
||||
|
||||
const dbEloMap = new Map<string, number>();
|
||||
for (const row of evRows) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue