Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.
MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators
All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.
NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.
NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.
WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* 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
* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts
TeamEntry requires resolvedElo after the simulator refactor.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix lint: replace non-null assertions with null-safe alternatives in NHL test
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
|
|
|
|
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
2026-03-22 00:14:18 -07:00
|
|
|
|
import {
|
|
|
|
|
|
normalizeTeamName,
|
|
|
|
|
|
getTeamData,
|
|
|
|
|
|
eloWinProbability,
|
2026-04-08 21:33:33 -04:00
|
|
|
|
simulateProjectedPoints,
|
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.
MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators
All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.
NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.
NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.
WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* 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
* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts
TeamEntry requires resolvedElo after the simulator refactor.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix lint: replace non-null assertions with null-safe alternatives in NHL test
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
|
|
|
|
NHLSimulator,
|
2026-03-22 00:14:18 -07:00
|
|
|
|
} from "../nhl-simulator";
|
|
|
|
|
|
|
|
|
|
|
|
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("normalizeTeamName", () => {
|
|
|
|
|
|
it("lowercases and trims", () => {
|
|
|
|
|
|
expect(normalizeTeamName(" Colorado Avalanche ")).toBe("colorado avalanche");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("collapses internal whitespace", () => {
|
|
|
|
|
|
expect(normalizeTeamName("Tampa Bay Lightning")).toBe("tampa bay lightning");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is already-normalized identity", () => {
|
|
|
|
|
|
expect(normalizeTeamName("dallas stars")).toBe("dallas stars");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── getTeamData ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("getTeamData", () => {
|
|
|
|
|
|
it("returns data for an exact match", () => {
|
|
|
|
|
|
const d = getTeamData("Colorado Avalanche");
|
|
|
|
|
|
expect(d).toBeDefined();
|
|
|
|
|
|
expect(d?.conference).toBe("Western");
|
|
|
|
|
|
expect(d?.division).toBe("Central");
|
|
|
|
|
|
expect(d?.elo).toBeGreaterThan(1500);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is case-insensitive", () => {
|
|
|
|
|
|
expect(getTeamData("colorado avalanche")).toEqual(getTeamData("Colorado Avalanche"));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns undefined for an unknown team", () => {
|
|
|
|
|
|
expect(getTeamData("Springfield Ice Hounds")).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("all 32 teams are present", () => {
|
|
|
|
|
|
const allTeams = [
|
|
|
|
|
|
// Eastern — Atlantic
|
|
|
|
|
|
"Tampa Bay Lightning", "Buffalo Sabres", "Montreal Canadiens", "Ottawa Senators",
|
|
|
|
|
|
"Detroit Red Wings", "Boston Bruins", "Florida Panthers", "Toronto Maple Leafs",
|
|
|
|
|
|
// Eastern — Metropolitan
|
|
|
|
|
|
"Carolina Hurricanes", "Columbus Blue Jackets", "Pittsburgh Penguins",
|
|
|
|
|
|
"New York Islanders", "Philadelphia Flyers", "Washington Capitals",
|
|
|
|
|
|
"New Jersey Devils", "New York Rangers",
|
|
|
|
|
|
// Western — Central
|
|
|
|
|
|
"Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth",
|
|
|
|
|
|
"Nashville Predators", "Winnipeg Jets", "St. Louis Blues", "Chicago Blackhawks",
|
|
|
|
|
|
// Western — Pacific
|
|
|
|
|
|
"Anaheim Ducks", "Edmonton Oilers", "Vegas Golden Knights", "Los Angeles Kings",
|
|
|
|
|
|
"San Jose Sharks", "Seattle Kraken", "Calgary Flames", "Vancouver Canucks",
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const name of allTeams) {
|
|
|
|
|
|
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-04-08 21:33:33 -04:00
|
|
|
|
|
|
|
|
|
|
it("all 32 teams have a valid conference and division", () => {
|
|
|
|
|
|
const validConferences = new Set(["Eastern", "Western"]);
|
|
|
|
|
|
const validDivisions = new Set(["Atlantic", "Metropolitan", "Central", "Pacific"]);
|
|
|
|
|
|
const allTeams = [
|
|
|
|
|
|
"Tampa Bay Lightning", "Buffalo Sabres", "Montreal Canadiens", "Ottawa Senators",
|
|
|
|
|
|
"Detroit Red Wings", "Boston Bruins", "Florida Panthers", "Toronto Maple Leafs",
|
|
|
|
|
|
"Carolina Hurricanes", "Columbus Blue Jackets", "Pittsburgh Penguins",
|
|
|
|
|
|
"New York Islanders", "Philadelphia Flyers", "Washington Capitals",
|
|
|
|
|
|
"New Jersey Devils", "New York Rangers",
|
|
|
|
|
|
"Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth",
|
|
|
|
|
|
"Nashville Predators", "Winnipeg Jets", "St. Louis Blues", "Chicago Blackhawks",
|
|
|
|
|
|
"Anaheim Ducks", "Edmonton Oilers", "Vegas Golden Knights", "Los Angeles Kings",
|
|
|
|
|
|
"San Jose Sharks", "Seattle Kraken", "Calgary Flames", "Vancouver Canucks",
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const name of allTeams) {
|
|
|
|
|
|
const data = getTeamData(name);
|
|
|
|
|
|
expect(validConferences.has(data?.conference ?? ""), `${name}: invalid conference`).toBe(true);
|
|
|
|
|
|
expect(validDivisions.has(data?.division ?? ""), `${name}: invalid division`).toBe(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("conference/division assignments are correct for spot-checked teams", () => {
|
|
|
|
|
|
expect(getTeamData("Carolina Hurricanes")?.division).toBe("Metropolitan");
|
|
|
|
|
|
expect(getTeamData("Colorado Avalanche")?.division).toBe("Central");
|
|
|
|
|
|
expect(getTeamData("Vegas Golden Knights")?.division).toBe("Pacific");
|
|
|
|
|
|
expect(getTeamData("Buffalo Sabres")?.conference).toBe("Eastern");
|
|
|
|
|
|
expect(getTeamData("Edmonton Oilers")?.conference).toBe("Western");
|
|
|
|
|
|
});
|
2026-03-22 00:14:18 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── eloWinProbability ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
|
|
|
|
|
|
it("returns 0.5 for equal Elo ratings", () => {
|
|
|
|
|
|
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("favors the higher-rated team", () => {
|
|
|
|
|
|
expect(eloWinProbability(1594, 1500)).toBeGreaterThan(0.5);
|
|
|
|
|
|
expect(eloWinProbability(1500, 1594)).toBeLessThan(0.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
|
|
|
|
|
|
const p = eloWinProbability(1580, 1520);
|
|
|
|
|
|
expect(p + eloWinProbability(1520, 1580)).toBeCloseTo(1.0, 10);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("a 100-pt gap gives ~55.7% win prob per game", () => {
|
|
|
|
|
|
const p = eloWinProbability(1600, 1500);
|
|
|
|
|
|
expect(p).toBeCloseTo(0.557, 2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("a 200-pt gap gives ~61.3% win prob per game", () => {
|
|
|
|
|
|
const p = eloWinProbability(1700, 1500);
|
|
|
|
|
|
expect(p).toBeCloseTo(0.613, 2);
|
|
|
|
|
|
});
|
2026-06-30 23:24:48 +00:00
|
|
|
|
|
|
|
|
|
|
it("a higher parity factor flattens the per-game win probability toward 0.5", () => {
|
|
|
|
|
|
const standard = eloWinProbability(1700, 1500); // default parity 1000
|
|
|
|
|
|
const flatter = eloWinProbability(1700, 1500, 2500); // season config override
|
|
|
|
|
|
expect(flatter).toBeLessThan(standard);
|
|
|
|
|
|
expect(flatter).toBeGreaterThan(0.5);
|
|
|
|
|
|
});
|
2026-03-22 00:14:18 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-08 21:33:33 -04:00
|
|
|
|
// ─── simulateProjectedPoints ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const makeEntry = (overrides: Partial<{
|
|
|
|
|
|
currentPoints: number;
|
|
|
|
|
|
remainingGames: number;
|
|
|
|
|
|
winProb: number;
|
|
|
|
|
|
}> = {}) => ({
|
|
|
|
|
|
id: "test",
|
|
|
|
|
|
name: "Test Team",
|
|
|
|
|
|
data: undefined,
|
|
|
|
|
|
conference: "Western" as const,
|
|
|
|
|
|
division: "Central",
|
|
|
|
|
|
currentPoints: overrides.currentPoints ?? 80,
|
|
|
|
|
|
remainingGames: overrides.remainingGames ?? 10,
|
|
|
|
|
|
winProb: overrides.winProb ?? 0.55,
|
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.
MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators
All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.
NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.
NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.
WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* 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
* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts
TeamEntry requires resolvedElo after the simulator refactor.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix lint: replace non-null assertions with null-safe alternatives in NHL test
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
|
|
|
|
resolvedElo: 1500,
|
2026-04-08 21:33:33 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("simulateProjectedPoints", () => {
|
2026-03-22 00:14:18 -07:00
|
|
|
|
|
2026-04-08 21:33:33 -04:00
|
|
|
|
it("returns exactly currentPoints when no games remain", () => {
|
|
|
|
|
|
const entry = makeEntry({ currentPoints: 95, remainingGames: 0 });
|
|
|
|
|
|
expect(simulateProjectedPoints(entry)).toBe(95);
|
2026-03-22 00:14:18 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-08 21:33:33 -04:00
|
|
|
|
it("returns at least currentPoints (points never decrease)", () => {
|
|
|
|
|
|
const entry = makeEntry({ currentPoints: 70, remainingGames: 20, winProb: 0.5 });
|
|
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
|
|
|
|
expect(simulateProjectedPoints(entry)).toBeGreaterThanOrEqual(70);
|
2026-03-22 00:14:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-08 21:33:33 -04:00
|
|
|
|
it("returns at most currentPoints + remainingGames * 2 (can't exceed max)", () => {
|
|
|
|
|
|
const entry = makeEntry({ currentPoints: 60, remainingGames: 15, winProb: 0.9 });
|
|
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
|
|
|
|
expect(simulateProjectedPoints(entry)).toBeLessThanOrEqual(60 + 15 * 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-22 00:14:18 -07:00
|
|
|
|
|
2026-04-08 21:33:33 -04:00
|
|
|
|
it("higher winProb produces higher projected points on average", () => {
|
|
|
|
|
|
const base = makeEntry({ currentPoints: 0, remainingGames: 82 });
|
|
|
|
|
|
const runs = 2000;
|
|
|
|
|
|
const avg = (winProb: number) => {
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
for (let i = 0; i < runs; i++) {
|
|
|
|
|
|
total += simulateProjectedPoints({ ...base, winProb });
|
|
|
|
|
|
}
|
|
|
|
|
|
return total / runs;
|
|
|
|
|
|
};
|
|
|
|
|
|
expect(avg(0.7)).toBeGreaterThan(avg(0.4));
|
2026-03-22 00:14:18 -07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.
MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators
All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.
NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.
NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.
WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* 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
* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts
TeamEntry requires resolvedElo after the simulator refactor.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix lint: replace non-null assertions with null-safe alternatives in NHL test
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
|
|
|
|
|
|
|
|
|
|
// ─── 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);
|
|
|
|
|
|
if (!r1m3) throw new Error("R1 M3 not found in test fixture");
|
|
|
|
|
|
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");
|
|
|
|
|
|
if (!final) throw new Error("Stanley Cup match not found in test fixture");
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|