brackt/app/services/simulations/__tests__/nba-simulator.test.ts
Chris Parsons 66b0235678
fix: NBA play-in bracket advancement bugs (#292)
* fix: NBA play-in advancement writes E7/W7 to wrong participant slot

advanceNBAPlayInWinner was writing the PIR1 7v8 winner to participant1Id
of First Round M3/M7, but those slots already hold E2/W2 (seeded at
generation time). The "already filled" guard fired, silently swallowing
both the winner advancement and the loser's placement into Play-In
Round 2 — so the 7/8 loser never appeared in the second play-in game.

Fix: write the 7v8 winner to participant2Id (the correct null slot) and
update the guard, doc comment, and load-bearing comment accordingly.
Also swap the p2Override arguments in the simulator and test fixture to
match the corrected slot layout (FR M3: E2=p1, E7=p2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: hasGroupStage check for templates without groupStage property

undefined !== null is true, so templates that omit groupStage (like
nba_20) were incorrectly treated as group-stage templates, triggering
the generate-groups action and the "Invalid template or template has
no group stage" error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 22:25:32 -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: t[1], p2: e7, 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: t[11], p2: w7, 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");
}
});
});