brackt/app/services/simulations/__tests__/ncaam-simulator.test.ts
Chris Parsons e2b178221a
Add oxlint linting setup with zero errors (#194)
* Add oxlint and fix all lint errors

- Install oxlint, add .oxlintrc.json with rules for TypeScript/React
- Add npm run lint / lint:fix scripts
- Add Claude PostToolUse hook to run oxlint on every edited file
- Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array
- Fix no-array-index-key (use stable keys or suppress positional cases)
- Fix exhaustive-deps missing dependency in useEffect
- Promote exhaustive-deps and no-array-index-key to errors
- Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined)

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

* Fix no-explicit-any warnings and upgrade tsconfig to ES2023

- Replace all `any` types with proper types or `unknown` across ~20 files
- Add typed socket payload interfaces in draft route and useDraftSocket
- Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch)
- Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed
- Fix cascading type errors uncovered by removing any: Map.get narrowing,
  participant relation types, ChartDataPoint, Partial<NewSeason> indexing
- Add ParticipantResultWithParticipant type to participant-result model
- Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult)
- Fix duplicate getQPStandings import in sportsSeasonId.server.ts

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

* Promote no-explicit-any to error

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00

287 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
import { kenpomWinProbability, getNetRating, normalizeTeamName } from "../ncaam-simulator";
// ─── KenPom win probability formula ──────────────────────────────────────────
describe("kenpomWinProbability", () => {
it("returns 0.5 for equal teams", () => {
expect(kenpomWinProbability(25, 25)).toBeCloseTo(0.5, 10);
});
it("returns >0.5 for team A with higher netrtg", () => {
expect(kenpomWinProbability(30, 20)).toBeGreaterThan(0.5);
});
it("returns <0.5 for team A with lower netrtg", () => {
expect(kenpomWinProbability(20, 30)).toBeLessThan(0.5);
});
it("is symmetric: P(A>B) + P(B>A) = 1", () => {
const pAB = kenpomWinProbability(35, 20);
const pBA = kenpomWinProbability(20, 35);
expect(pAB + pBA).toBeCloseTo(1.0, 10);
});
it("matches the logistic formula exactly for known values", () => {
// netrtgA=38.90 (Duke 2025-26), netrtgB=0 → 1/(1+exp(-38.90/7.5))
const expected = 1 / (1 + Math.exp(-38.90 / 7.5));
expect(kenpomWinProbability(38.90, 0)).toBeCloseTo(expected, 10);
});
it("handles negative netrtg (weak team vs average)", () => {
// negative netrtg is valid (below-average team)
const p = kenpomWinProbability(-2, 0);
expect(p).toBeGreaterThan(0);
expect(p).toBeLessThan(0.5);
});
it("large AEM gap pushes probability toward 1", () => {
// 38 point gap (Duke-caliber vs near-zero) → high win prob
expect(kenpomWinProbability(38, 0)).toBeGreaterThan(0.99);
});
it("scale factor: a 7.5-point gap yields ~73% win probability", () => {
// 1 / (1 + exp(-7.5/7.5)) = 1/(1+exp(-1)) ≈ 0.7311
expect(kenpomWinProbability(7.5, 0)).toBeCloseTo(0.7311, 3);
});
});
// ─── Team name normalization ──────────────────────────────────────────────────
describe("normalizeTeamName", () => {
it("converts to lowercase", () => {
expect(normalizeTeamName("Duke")).toBe("duke");
});
it("trims leading/trailing whitespace", () => {
expect(normalizeTeamName(" Duke ")).toBe("duke");
});
it("collapses internal whitespace", () => {
expect(normalizeTeamName("North Carolina")).toBe("north carolina");
});
it("preserves punctuation", () => {
expect(normalizeTeamName("St. John's")).toBe("st. john's");
});
});
// ─── KenPom data lookup ───────────────────────────────────────────────────────
describe("getNetRating", () => {
it("returns correct netrtg for exact known name (lowercase)", () => {
expect(getNetRating("duke")).toBeCloseTo(38.90, 2);
});
it("is case-insensitive", () => {
expect(getNetRating("Duke")).toBeCloseTo(38.90, 2);
expect(getNetRating("DUKE")).toBeCloseTo(38.90, 2);
});
it("handles abbreviated name variants", () => {
// Both 'michigan st.' and 'michigan state' should resolve
expect(getNetRating("Michigan St.")).toBeCloseTo(28.31, 2);
expect(getNetRating("Michigan State")).toBeCloseTo(28.31, 2);
});
it("handles name with apostrophe", () => {
expect(getNetRating("St. John's")).toBeCloseTo(25.91, 2);
});
it("returns 0.0 for an unknown team name", () => {
expect(getNetRating("Fictional University")).toBe(0.0);
});
it("returns 0.0 for empty string", () => {
expect(getNetRating("")).toBe(0.0);
});
it("handles negative netrtg (below-average teams)", () => {
expect(getNetRating("Idaho")).toBeCloseTo(1.53, 2);
expect(getNetRating("Eastern Washington")).toBeCloseTo(0.00, 2);
});
});
// ─── Bracket path logic ───────────────────────────────────────────────────────
describe("NCAAM bracket advancement path", () => {
it("R64 matches 1 and 2 feed R32 match 1 (Math.ceil convention)", () => {
expect(Math.ceil(1 / 2)).toBe(1);
expect(Math.ceil(2 / 2)).toBe(1);
});
it("R64 matches 3 and 4 feed R32 match 2", () => {
expect(Math.ceil(3 / 2)).toBe(2);
expect(Math.ceil(4 / 2)).toBe(2);
});
it("R64 matches 31 and 32 feed R32 match 16", () => {
expect(Math.ceil(31 / 2)).toBe(16);
expect(Math.ceil(32 / 2)).toBe(16);
});
it("32 R64 matches produce exactly 16 R32 slots", () => {
const r32Slots = new Set<number>();
for (let i = 1; i <= 32; i++) r32Slots.add(Math.ceil(i / 2));
expect(r32Slots.size).toBe(16);
});
it("R32 match i uses 0-indexed winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 16; i++) {
const p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1;
expect(p1).toBeGreaterThanOrEqual(0);
expect(p2).toBeLessThan(32);
}
});
it("S16 match i uses 0-indexed R32 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 8; i++) {
const p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1;
expect(p1).toBeGreaterThanOrEqual(0);
expect(p2).toBeLessThan(16);
}
});
it("E8 match i uses 0-indexed S16 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 4; i++) {
const _p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1;
expect(p2).toBeLessThan(8);
}
});
it("FF match i uses 0-indexed E8 winners at (i-1)*2 and (i-1)*2+1", () => {
for (let i = 1; i <= 2; i++) {
const _p1 = (i - 1) * 2;
const p2 = (i - 1) * 2 + 1;
expect(p2).toBeLessThan(4);
}
});
});
// ─── Probability bucket math ──────────────────────────────────────────────────
describe("NCAAM placement bucket probability math", () => {
const N = 50_000;
it("champion: count/N = 1.0 when a team wins every simulation", () => {
const c = N;
expect(c / N).toBeCloseTo(1.0, 10);
});
it("finalist: count/N = 1.0 when a team reaches final every simulation", () => {
expect(N / N).toBeCloseTo(1.0, 10);
});
it("FF loser slots: count/(2*N) sums to 1.0 across teams whose counts total 2*N", () => {
// 2 FF losers per sim → total across all teams = 2*N
// e.g. 2 teams each appearing N times: (N + N) / (2*N) = 1.0
const counts = [N, N];
const sum = counts.reduce((s, c) => s + c / (2 * N), 0);
expect(sum).toBeCloseTo(1.0, 10);
});
it("E8 loser slots: count/(4*N) sums to 1.0 across teams whose counts total 4*N", () => {
// 4 E8 losers per sim → total across all teams = 4*N
// e.g. 4 teams each appearing N times: (4*N) / (4*N) = 1.0
const counts = [N, N, N, N];
const sum = counts.reduce((s, c) => s + c / (4 * N), 0);
expect(sum).toBeCloseTo(1.0, 10);
});
it("probThird equals probFourth for the same team (same ff count / 2N)", () => {
const ffCount = 12500;
const probThird = ffCount / (2 * N);
const probFourth = ffCount / (2 * N);
expect(probThird).toBe(probFourth);
});
it("probFifth through probEighth are equal for the same team (same e8 count / 4N)", () => {
const e8Count = 6250;
const probs = [e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N), e8Count / (4 * N)];
expect(probs[0]).toBe(probs[1]);
expect(probs[1]).toBe(probs[2]);
expect(probs[2]).toBe(probs[3]);
});
it("a team exiting in R64 has all-zero probabilities", () => {
// R64 losers are never counted in any bucket → all counts stay 0
const c = 0, f = 0, ff = 0, e8 = 0;
expect(c / N).toBe(0);
expect(f / N).toBe(0);
expect(ff / (2 * N)).toBe(0);
expect(e8 / (4 * N)).toBe(0);
});
});
// ─── EV alignment with scoring rules ─────────────────────────────────────────
describe("NCAAM EV calculation alignment with scoring rules", () => {
// DEFAULT_SCORING_RULES: Champion=100, Finalist=70, FF loser=45, E8 loser=20, rest=0
// (sum = 340; must match DEFAULT_SCORING_RULES in admin.sports-seasons.$id.simulate.tsx)
const SCORING = {
first: 100,
second: 70,
thirdFourth: 45,
fifthToEighth: 20,
};
function computeEV(probs: {
probFirst: number; probSecond: number;
probThird: number; probFourth: number;
probFifth: number; probSixth: number; probSeventh: number; probEighth: number;
}): number {
return (
probs.probFirst * SCORING.first +
probs.probSecond * SCORING.second +
probs.probThird * SCORING.thirdFourth +
probs.probFourth * SCORING.thirdFourth +
probs.probFifth * SCORING.fifthToEighth +
probs.probSixth * SCORING.fifthToEighth +
probs.probSeventh * SCORING.fifthToEighth +
probs.probEighth * SCORING.fifthToEighth
);
}
it("champion with probFirst=1 earns 100 EV", () => {
const probs = {
probFirst: 1, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
expect(computeEV(probs)).toBeCloseTo(100, 10);
});
it("finalist with probSecond=1 earns 70 EV", () => {
const probs = {
probFirst: 0, probSecond: 1, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
expect(computeEV(probs)).toBeCloseTo(70, 10);
});
it("confirmed FF loser (probThird=probFourth=0.5 each) earns 45 EV", () => {
const probs = {
probFirst: 0, probSecond: 0, probThird: 0.5, probFourth: 0.5,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
expect(computeEV(probs)).toBeCloseTo(45, 10);
});
it("confirmed E8 loser (probFifthEighth=0.25 each) earns 20 EV", () => {
const probs = {
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0.25, probSixth: 0.25, probSeventh: 0.25, probEighth: 0.25,
};
expect(computeEV(probs)).toBeCloseTo(20, 10);
});
it("R64 loser (all zeros) earns 0 EV", () => {
const probs = {
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
expect(computeEV(probs)).toBe(0);
});
});