brackt/app/services/simulations/__tests__/nba-simulator.test.ts
Chris Parsons 040c97137b
feat: NBA playoff bracket template and bracket-aware simulator (#291)
Adds the NBA_20 bracket template (20-team structure with play-in
tournament) and rewrites the NBA simulator to be bracket-aware like UCL.

- Add NBA_20 bracket template: PIR1 (4) → PIR2 (2) → First Round (8)
  → Conference Semifinals → Conference Finals → NBA Finals
- Add participantLabels (East/West 1–10) for admin bracket UI slot labels
- Add generateNBA20Bracket and advanceNBAPlayInWinner in playoff-match.ts
  with custom play-in advancement logic (PIR1 winners go to two different
  destinations)
- Rewrite NBASimulator with two auto-detected modes:
  - Bracket-aware (preferred): reads actual bracket state, simulates only
    remaining rounds forward using 50k Monte Carlo iterations
  - Season-projection (fallback): existing logic for pre-playoff use
- Guard resolveGame/resolveSeries against null participants (throws with
  match ID and slot info instead of silently using empty string)
- Document load-bearing First Round match ordering in generateNBA20Bracket
- Add 25 unit tests covering Elo math, team data, template structure,
  and all simulator integration paths

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:51:53 -07:00

497 lines
19 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { getTeamData, eloWinProbability, NBASimulator } from "../nba-simulator";
import { NBA_20, BRACKET_TEMPLATES } from "~/lib/bracket-templates";
// ─── Pure math / team data ────────────────────────────────────────────────────
describe("eloWinProbability", () => {
it("returns 0.5 for equal Elo ratings", () => {
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 10);
});
it("favors the higher-rated team", () => {
expect(eloWinProbability(1600, 1400)).toBeGreaterThan(0.5);
expect(eloWinProbability(1400, 1600)).toBeLessThan(0.5);
});
it("is symmetric: P(A>B) + P(B>A) = 1", () => {
const a = 1650;
const b = 1480;
expect(eloWinProbability(a, b) + eloWinProbability(b, a)).toBeCloseTo(1.0, 10);
});
it("approaches 1 for a very large Elo advantage", () => {
expect(eloWinProbability(2000, 1000)).toBeGreaterThan(0.99);
});
});
describe("getTeamData", () => {
it("finds teams by full name", () => {
expect(getTeamData("Boston Celtics")).toMatchObject({ conference: "Eastern" });
expect(getTeamData("Oklahoma City Thunder")).toMatchObject({ conference: "Western" });
});
it("is case-insensitive", () => {
expect(getTeamData("boston celtics")).toBeDefined();
expect(getTeamData("OKLAHOMA CITY THUNDER")).toBeDefined();
});
it("returns undefined for unknown teams", () => {
expect(getTeamData("Springfield Nuclears")).toBeUndefined();
});
it("covers all 30 NBA teams", () => {
const allTeams = [
// Eastern Conference
"Detroit Pistons", "Boston Celtics", "New York Knicks", "Cleveland Cavaliers",
"Orlando Magic", "Miami Heat", "Toronto Raptors", "Atlanta Hawks",
"Philadelphia 76ers", "Charlotte Hornets", "Milwaukee Bucks", "Chicago Bulls",
"Brooklyn Nets", "Indiana Pacers", "Washington Wizards",
// Western Conference
"Oklahoma City Thunder", "San Antonio Spurs", "Houston Rockets", "Denver Nuggets",
"LA Lakers", "Minnesota Timberwolves", "Phoenix Suns", "LA Clippers",
"Golden State Warriors", "Portland Trail Blazers", "Dallas Mavericks",
"Memphis Grizzlies", "New Orleans Pelicans", "Sacramento Kings", "Utah Jazz",
];
for (const name of allTeams) {
expect(getTeamData(name), `${name} not found`).toBeDefined();
}
expect(allTeams).toHaveLength(30);
});
it("has exactly 15 Eastern and 15 Western teams", () => {
const allTeams = [
"Detroit Pistons", "Boston Celtics", "New York Knicks", "Cleveland Cavaliers",
"Orlando Magic", "Miami Heat", "Toronto Raptors", "Atlanta Hawks",
"Philadelphia 76ers", "Charlotte Hornets", "Milwaukee Bucks", "Chicago Bulls",
"Brooklyn Nets", "Indiana Pacers", "Washington Wizards",
"Oklahoma City Thunder", "San Antonio Spurs", "Houston Rockets", "Denver Nuggets",
"LA Lakers", "Minnesota Timberwolves", "Phoenix Suns", "LA Clippers",
"Golden State Warriors", "Portland Trail Blazers", "Dallas Mavericks",
"Memphis Grizzlies", "New Orleans Pelicans", "Sacramento Kings", "Utah Jazz",
];
const east = allTeams.filter((n) => getTeamData(n)?.conference === "Eastern");
const west = allTeams.filter((n) => getTeamData(n)?.conference === "Western");
expect(east).toHaveLength(15);
expect(west).toHaveLength(15);
});
});
// ─── NBA_20 bracket template structure ───────────────────────────────────────
describe("NBA_20 bracket template", () => {
it("is registered in BRACKET_TEMPLATES", () => {
expect(BRACKET_TEMPLATES.nba_20).toBe(NBA_20);
});
it("has correct totalTeams and id", () => {
expect(NBA_20.id).toBe("nba_20");
expect(NBA_20.totalTeams).toBe(20);
expect(NBA_20.scoringStartsAtRound).toBe("Conference Semifinals");
});
it("has 6 rounds in the correct order", () => {
const names = NBA_20.rounds.map((r) => r.name);
expect(names).toEqual([
"Play-In Round 1",
"Play-In Round 2",
"First Round",
"Conference Semifinals",
"Conference Finals",
"NBA Finals",
]);
});
it("has correct match counts per round", () => {
const counts = NBA_20.rounds.map((r) => r.matchCount);
expect(counts).toEqual([4, 2, 8, 4, 2, 1]);
});
it("non-scoring rounds have isScoring=false", () => {
const nonScoring = ["Play-In Round 1", "Play-In Round 2", "First Round"];
for (const name of nonScoring) {
const round = NBA_20.rounds.find((r) => r.name === name);
expect(round?.isScoring, `${name} should be non-scoring`).toBe(false);
}
});
it("scoring rounds have isScoring=true", () => {
const scoring = ["Conference Semifinals", "Conference Finals", "NBA Finals"];
for (const name of scoring) {
const round = NBA_20.rounds.find((r) => r.name === name);
expect(round?.isScoring, `${name} should be scoring`).toBe(true);
}
});
it("feedsInto forms a valid chain ending at null", () => {
const roundByName = new Map(NBA_20.rounds.map((r) => [r.name, r]));
let current = NBA_20.rounds[0];
const visited: string[] = [];
while (current.feedsInto !== null) {
visited.push(current.name);
const next = roundByName.get(current.feedsInto);
expect(next, `${current.feedsInto} not found in rounds`).toBeDefined();
if (!next) break;
current = next;
}
visited.push(current.name);
expect(visited).toHaveLength(NBA_20.rounds.length);
expect(current.name).toBe("NBA Finals");
});
it("has 20 participantLabels with correct East/West structure", () => {
const labels = NBA_20.participantLabels ?? [];
expect(labels).toHaveLength(20);
// East seeds 1-10 at indices 0-9
for (let i = 0; i < 10; i++) {
expect(labels[i]).toMatch(/^East \d+$/);
}
// West seeds 1-10 at indices 10-19
for (let i = 10; i < 20; i++) {
expect(labels[i]).toMatch(/^West \d+$/);
}
});
});
// ─── Simulator integration tests (mocked DB) ─────────────────────────────────
const TEAM_IDS = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
/** Build a minimal DB match object */
function makeMatch(
round: string,
matchNumber: number,
opts: {
p1?: string | null;
p2?: string | null;
winnerId?: string | null;
loserId?: string | null;
isComplete?: boolean;
isScoring?: 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: opts.isScoring ?? false,
templateRound: round,
seedInfo: null,
participant1Score: null,
participant2Score: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
/**
* Builds a full 20-team NBA bracket with all Play-In and First Round slots
* filled so the simulator has valid participants on every match.
*
* Team layout matches generateNBA20Bracket:
* PIR1 M1: idx6(E7) vs idx7(E8) PIR1 M2: idx8(E9) vs idx9(E10)
* PIR1 M3: idx16(W7) vs idx17(W8) PIR1 M4: idx18(W9) vs idx19(W10)
* PIR2 M1: e8Cand vs e9e10W PIR2 M2: w8Cand vs w9w10W
* FR M1: idx0(E1) vs e8 FR M2: idx3(E4) vs idx4(E5)
* FR M3: e7 vs idx1(E2) FR M4: idx2(E3) vs idx5(E6)
* FR M5: idx10(W1) vs w8 FR M6: idx13(W4) vs idx14(W5)
* FR M7: w7 vs idx11(W2) FR M8: idx12(W3) vs idx15(W6)
*/
function buildFullBracket(
pir1Results: { m1Winner: string; m2Winner: string; m3Winner: string; m4Winner: string },
pir2Results: { m1Winner: string; m2Winner: string }
) {
const t = TEAM_IDS;
const e7 = pir1Results.m1Winner;
const e8Cand = t[7]; // E8 candidate = PIR1 M1 loser (for simplicity, hard-code)
const e9e10W = pir1Results.m2Winner;
const w7 = pir1Results.m3Winner;
const w8Cand = t[17];
const w9w10W = pir1Results.m4Winner;
const e8Seed = pir2Results.m1Winner;
const w8Seed = pir2Results.m2Winner;
return [
// Play-In Round 1
makeMatch("Play-In Round 1", 1, { p1: t[6], p2: t[7], winnerId: pir1Results.m1Winner, loserId: pir1Results.m1Winner === t[6] ? t[7] : t[6], isComplete: true }),
makeMatch("Play-In Round 1", 2, { p1: t[8], p2: t[9], winnerId: pir1Results.m2Winner, loserId: pir1Results.m2Winner === t[8] ? t[9] : t[8], isComplete: true }),
makeMatch("Play-In Round 1", 3, { p1: t[16], p2: t[17], winnerId: pir1Results.m3Winner, loserId: pir1Results.m3Winner === t[16] ? t[17] : t[16], isComplete: true }),
makeMatch("Play-In Round 1", 4, { p1: t[18], p2: t[19], winnerId: pir1Results.m4Winner, loserId: pir1Results.m4Winner === t[18] ? t[19] : t[18], isComplete: true }),
// Play-In Round 2
makeMatch("Play-In Round 2", 1, { p1: e8Cand, p2: e9e10W, winnerId: pir2Results.m1Winner, loserId: pir2Results.m1Winner === e8Cand ? e9e10W : e8Cand, isComplete: true }),
makeMatch("Play-In Round 2", 2, { p1: w8Cand, p2: w9w10W, winnerId: pir2Results.m2Winner, loserId: pir2Results.m2Winner === w8Cand ? w9w10W : w8Cand, isComplete: true }),
// First Round (play-in slots filled)
makeMatch("First Round", 1, { p1: t[0], p2: e8Seed, isScoring: false }),
makeMatch("First Round", 2, { p1: t[3], p2: t[4], isScoring: false }),
makeMatch("First Round", 3, { p1: e7, p2: t[1], isScoring: false }),
makeMatch("First Round", 4, { p1: t[2], p2: t[5], isScoring: false }),
makeMatch("First Round", 5, { p1: t[10], p2: w8Seed, isScoring: false }),
makeMatch("First Round", 6, { p1: t[13], p2: t[14], isScoring: false }),
makeMatch("First Round", 7, { p1: w7, p2: t[11], isScoring: false }),
makeMatch("First Round", 8, { p1: t[12], p2: t[15], isScoring: false }),
// Conference Semifinals (TBD)
makeMatch("Conference Semifinals", 1, { isScoring: true }),
makeMatch("Conference Semifinals", 2, { isScoring: true }),
makeMatch("Conference Semifinals", 3, { isScoring: true }),
makeMatch("Conference Semifinals", 4, { isScoring: true }),
// Conference Finals (TBD)
makeMatch("Conference Finals", 1, { isScoring: true }),
makeMatch("Conference Finals", 2, { isScoring: true }),
// NBA Finals (TBD)
makeMatch("NBA Finals", 1, { isScoring: true }),
];
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn().mockResolvedValue([]),
}));
describe("NBASimulator — 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 })) // name = id (no real team name → fallback Elo 1400)
),
}),
}),
};
(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 playoff matches exist", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
// Season projection needs standings — mock getRegularSeasonStandings via the model import
// Since season-projection throws when conference split < 10, we just verify the right path:
const sim = new NBASimulator();
// select().from().where() returns 20 teams, but no conference data → East assigned via TEAMS_DATA
// All teams have unknown names so conference defaults to "Eastern" → <10 Western → error
await expect(sim.simulate("season-1")).rejects.toThrow(/Each conference needs at least 10/);
});
it("throws if bracket structure is wrong (wrong match count)", async () => {
// Only return PIR1 matches — missing rest of bracket
const pir1Only = [1, 2, 3, 4].map((n) =>
makeMatch("Play-In Round 1", n, { p1: TEAM_IDS[n - 1], p2: TEAM_IDS[n + 3] })
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(pir1Only);
const sim = new NBASimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/unexpected structure/);
});
it("throws if a participant slot is missing (no override and DB null)", async () => {
const t = TEAM_IDS;
// Build bracket but leave FR M2 with null p2 (no override provided)
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
// Null out FR M2 p2
const frM2 = matches.find((m) => m.round === "First Round" && m.matchNumber === 2);
if (!frM2) throw new Error("FR M2 not found in test fixture");
frM2.participant2Id = null;
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Cannot resolve series/);
});
it("returns results for all 20 participants", async () => {
const t = TEAM_IDS;
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(20);
});
it("each probability column sums to 1.0 across all 20 participants", async () => {
const t = TEAM_IDS;
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
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 () => {
const t = TEAM_IDS;
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
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("completed tournament: champion has probFirst=1, all others probFirst≈0", async () => {
const t = TEAM_IDS;
const champion = t[0]; // E1
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
// Mark all bracket matches complete with t[0] winning everything on the East side
// and t[10] winning West side, then t[0] beats t[10] in finals
const frMatches = matches.filter((m) => m.round === "First Round");
const csMatches = matches.filter((m) => m.round === "Conference Semifinals");
const cfMatches = matches.filter((m) => m.round === "Conference Finals");
const finalMatch = matches.find((m) => m.round === "NBA Finals");
if (!finalMatch) throw new Error("Finals match not found in test fixture");;
// Fully script the bracket:
// FR: top seed wins each series
for (const m of frMatches) {
const w = m.participant1Id ?? "";
m.winnerId = w;
m.loserId = m.participant2Id;
m.isComplete = true;
}
// CS M1: FR M1 winner vs FR M2 winner → FR M1 winner (t[0]) wins
csMatches[0].participant1Id = frMatches[0].winnerId;
csMatches[0].participant2Id = frMatches[1].winnerId;
csMatches[0].winnerId = frMatches[0].winnerId; // t[0]
csMatches[0].loserId = frMatches[1].winnerId;
csMatches[0].isComplete = true;
// CS M2: FR M3 winner vs FR M4 winner
csMatches[1].participant1Id = frMatches[2].winnerId;
csMatches[1].participant2Id = frMatches[3].winnerId;
csMatches[1].winnerId = frMatches[2].winnerId;
csMatches[1].loserId = frMatches[3].winnerId;
csMatches[1].isComplete = true;
// CS M3: FR M5 winner vs FR M6 winner
csMatches[2].participant1Id = frMatches[4].winnerId;
csMatches[2].participant2Id = frMatches[5].winnerId;
csMatches[2].winnerId = frMatches[4].winnerId; // t[10]
csMatches[2].loserId = frMatches[5].winnerId;
csMatches[2].isComplete = true;
// CS M4: FR M7 winner vs FR M8 winner
csMatches[3].participant1Id = frMatches[6].winnerId;
csMatches[3].participant2Id = frMatches[7].winnerId;
csMatches[3].winnerId = frMatches[6].winnerId;
csMatches[3].loserId = frMatches[7].winnerId;
csMatches[3].isComplete = true;
// CF M1: East champion (CS M1 winner = t[0])
cfMatches[0].participant1Id = csMatches[0].winnerId;
cfMatches[0].participant2Id = csMatches[1].winnerId;
cfMatches[0].winnerId = csMatches[0].winnerId; // t[0]
cfMatches[0].loserId = csMatches[1].winnerId;
cfMatches[0].isComplete = true;
// CF M2: West champion (CS M3 winner = t[10])
cfMatches[1].participant1Id = csMatches[2].winnerId;
cfMatches[1].participant2Id = csMatches[3].winnerId;
cfMatches[1].winnerId = csMatches[2].winnerId; // t[10]
cfMatches[1].loserId = csMatches[3].winnerId;
cfMatches[1].isComplete = true;
// Finals: t[0] beats t[10]
finalMatch.participant1Id = cfMatches[0].winnerId;
finalMatch.participant2Id = cfMatches[1].winnerId;
finalMatch.winnerId = champion;
finalMatch.loserId = t[10];
finalMatch.isComplete = true;
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
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: 'nba_bracket_monte_carlo' on all results", async () => {
const t = TEAM_IDS;
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.source).toBe("nba_bracket_monte_carlo");
}
});
});