* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
|
import { americanToImpliedProb, UCLSimulator } from "../ucl-simulator";
|
|
|
|
// ─── Pure math tests (no DB) ─────────────────────────────────────────────────
|
|
|
|
describe("americanToImpliedProb", () => {
|
|
it("converts positive (underdog) American odds correctly", () => {
|
|
// +300 → 100 / (300 + 100) = 0.25
|
|
expect(americanToImpliedProb(300)).toBeCloseTo(0.25, 5);
|
|
// +100 → 100 / 200 = 0.5
|
|
expect(americanToImpliedProb(100)).toBeCloseTo(0.5, 5);
|
|
});
|
|
|
|
it("converts negative (favorite) American odds correctly", () => {
|
|
// -200 → 200 / 300 ≈ 0.6667
|
|
expect(americanToImpliedProb(-200)).toBeCloseTo(0.6667, 3);
|
|
// -450 (Arsenal-like) → 450 / 550 ≈ 0.8182
|
|
expect(americanToImpliedProb(-450)).toBeCloseTo(0.8182, 3);
|
|
});
|
|
|
|
it("sums to more than 1.0 for a two-sided market (reflects vig)", () => {
|
|
// -110 / -110 line: each side ≈ 0.5238, total ≈ 1.0476
|
|
const total = americanToImpliedProb(-110) + americanToImpliedProb(-110);
|
|
expect(total).toBeGreaterThan(1.0);
|
|
});
|
|
});
|
|
|
|
// ─── Bracket path logic (pure math, verified against advanceWinnerTemplate) ──
|
|
|
|
describe("UCL bracket path convention", () => {
|
|
it("R16 match 1 and match 2 winners feed QF match 1", () => {
|
|
// advanceWinnerTemplate: nextMatchNumber = Math.ceil(matchNumber / 2)
|
|
// QF match for R16 match N:
|
|
expect(Math.ceil(1 / 2)).toBe(1); // R16 match 1 → QF match 1
|
|
expect(Math.ceil(2 / 2)).toBe(1); // R16 match 2 → QF match 1
|
|
expect(Math.ceil(3 / 2)).toBe(2); // R16 match 3 → QF match 2
|
|
expect(Math.ceil(4 / 2)).toBe(2); // R16 match 4 → QF match 2
|
|
expect(Math.ceil(7 / 2)).toBe(4); // R16 match 7 → QF match 4
|
|
expect(Math.ceil(8 / 2)).toBe(4); // R16 match 8 → QF match 4
|
|
});
|
|
|
|
it("QF match N uses r16Winners[(N-1)*2] and [(N-1)*2+1] — same as simulator", () => {
|
|
// r16Winners is 0-indexed from R16 match 1..8 in order
|
|
// QF match 1 uses r16Winners[0] (R16 match 1 winner) and r16Winners[1] (R16 match 2 winner)
|
|
for (let qfMatch = 1; qfMatch <= 4; qfMatch++) {
|
|
const p1Index = (qfMatch - 1) * 2;
|
|
const p2Index = (qfMatch - 1) * 2 + 1;
|
|
expect(p1Index).toBe((qfMatch - 1) * 2);
|
|
expect(p2Index).toBe((qfMatch - 1) * 2 + 1);
|
|
expect(p1Index).toBeLessThan(8);
|
|
expect(p2Index).toBeLessThan(8);
|
|
}
|
|
});
|
|
|
|
it("SF match N uses qfWinners[(N-1)*2] and [(N-1)*2+1]", () => {
|
|
for (let sfMatch = 1; sfMatch <= 2; sfMatch++) {
|
|
const p1Index = (sfMatch - 1) * 2;
|
|
const p2Index = (sfMatch - 1) * 2 + 1;
|
|
expect(p1Index).toBeLessThan(4);
|
|
expect(p2Index).toBeLessThan(4);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Placement bucket math ────────────────────────────────────────────────────
|
|
|
|
describe("UCL placement bucket EV alignment", () => {
|
|
const DEFAULT_SCORING = {
|
|
pointsFor1st: 100,
|
|
pointsFor2nd: 70,
|
|
pointsFor3rd: 50,
|
|
pointsFor4th: 40,
|
|
pointsFor5th: 25,
|
|
pointsFor6th: 25,
|
|
pointsFor7th: 15,
|
|
pointsFor8th: 15,
|
|
};
|
|
|
|
it("champion slot gives EV = 100 with DEFAULT_SCORING_RULES", () => {
|
|
const probs = { probFirst: 1, probSecond: 0, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0 };
|
|
const ev = probs.probFirst * DEFAULT_SCORING.pointsFor1st;
|
|
expect(ev).toBe(100);
|
|
});
|
|
|
|
it("finalist slot gives EV = 70 with DEFAULT_SCORING_RULES", () => {
|
|
const probs = { probFirst: 0, probSecond: 1, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0 };
|
|
const ev = probs.probSecond * DEFAULT_SCORING.pointsFor2nd;
|
|
expect(ev).toBe(70);
|
|
});
|
|
|
|
it("SF loser split (0.5/0.5 for 3rd/4th) gives EV = 45 with DEFAULT_SCORING_RULES", () => {
|
|
const ev = 0.5 * DEFAULT_SCORING.pointsFor3rd + 0.5 * DEFAULT_SCORING.pointsFor4th;
|
|
expect(ev).toBe(45); // (50 + 40) / 2
|
|
});
|
|
|
|
it("QF loser split (0.25 each for 5th-8th) gives EV = 20 with DEFAULT_SCORING_RULES", () => {
|
|
const ev =
|
|
0.25 * DEFAULT_SCORING.pointsFor5th +
|
|
0.25 * DEFAULT_SCORING.pointsFor6th +
|
|
0.25 * DEFAULT_SCORING.pointsFor7th +
|
|
0.25 * DEFAULT_SCORING.pointsFor8th;
|
|
expect(ev).toBe(20); // (25 + 25 + 15 + 15) / 4
|
|
});
|
|
|
|
it("R16 loser (all zeros) gives EV = 0", () => {
|
|
const ev = 0 * DEFAULT_SCORING.pointsFor1st; // all slots 0
|
|
expect(ev).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Integration test with mocked DB ─────────────────────────────────────────
|
|
|
|
// Build 16 participant IDs for the test
|
|
const PARTICIPANT_IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`);
|
|
|
|
/** Build a minimal R16 match object */
|
|
function makeR16Match(
|
|
matchNumber: number,
|
|
opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}
|
|
) {
|
|
const p1 = PARTICIPANT_IDS[(matchNumber - 1) * 2];
|
|
const p2 = PARTICIPANT_IDS[(matchNumber - 1) * 2 + 1];
|
|
return {
|
|
id: `r16-match-${matchNumber}`,
|
|
scoringEventId: "event-1",
|
|
round: "Round of 16",
|
|
matchNumber,
|
|
participant1Id: p1,
|
|
participant2Id: p2,
|
|
winnerId: opts.winnerId ?? null,
|
|
loserId: opts.loserId ?? null,
|
|
isComplete: opts.isComplete ?? false,
|
|
isScoring: false,
|
|
templateRound: "Round of 16",
|
|
seedInfo: null,
|
|
participant1Score: null,
|
|
participant2Score: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
}
|
|
|
|
function makeQFMatch(matchNumber: number, opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}) {
|
|
return {
|
|
id: `qf-match-${matchNumber}`,
|
|
scoringEventId: "event-1",
|
|
round: "Quarterfinals",
|
|
matchNumber,
|
|
participant1Id: null,
|
|
participant2Id: null,
|
|
winnerId: opts.winnerId ?? null,
|
|
loserId: opts.loserId ?? null,
|
|
isComplete: opts.isComplete ?? false,
|
|
isScoring: true,
|
|
templateRound: "Quarterfinals",
|
|
seedInfo: null,
|
|
participant1Score: null,
|
|
participant2Score: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
}
|
|
|
|
function makeSFMatch(matchNumber: number, opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}) {
|
|
return { ...makeQFMatch(matchNumber, opts), id: `sf-match-${matchNumber}`, round: "Semifinals", templateRound: "Semifinals" };
|
|
}
|
|
|
|
function makeFinalMatch(opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}) {
|
|
return { ...makeQFMatch(1, opts), id: "final-match-1", round: "Finals", templateRound: "Finals" };
|
|
}
|
|
|
|
vi.mock("~/database/context", () => ({
|
|
database: vi.fn(),
|
|
}));
|
|
|
|
describe("UCLSimulator.simulate()", () => {
|
|
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().from().where() chain returning empty odds rows
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}),
|
|
};
|
|
|
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
|
|
|
// Default bracket event
|
|
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
|
|
id: "event-1",
|
|
sportsSeasonId: "season-1",
|
|
eventType: "playoff_game",
|
|
});
|
|
});
|
|
|
|
it("throws if no bracket event found", async () => {
|
|
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
|
const sim = new UCLSimulator();
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/No bracket event found/);
|
|
});
|
|
|
|
it("throws if no playoff matches found", async () => {
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
|
const sim = new UCLSimulator();
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/No playoff matches found/);
|
|
});
|
|
|
|
it("throws if R16 match is missing a participant", async () => {
|
|
const matches = [
|
|
...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)),
|
|
...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)),
|
|
...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)),
|
|
makeFinalMatch(),
|
|
];
|
|
// Remove participant2Id from match 3 (simulates incomplete draw data)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
matches[2] = { ...matches[2], participant2Id: null } as any;
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
|
|
|
const sim = new UCLSimulator();
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/);
|
|
});
|
|
|
|
it("returns 16 results with valid probability distributions (no odds, coin-flip)", async () => {
|
|
const allMatches = [
|
|
...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)),
|
|
...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)),
|
|
...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)),
|
|
makeFinalMatch(),
|
|
];
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(allMatches);
|
|
|
|
const sim = new UCLSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
expect(results).toHaveLength(16);
|
|
|
|
for (const result of results) {
|
|
const p = result.probabilities;
|
|
const sum = p.probFirst + p.probSecond + p.probThird + p.probFourth +
|
|
p.probFifth + p.probSixth + p.probSeventh + p.probEighth;
|
|
// Each team's probabilities should sum to at most ~1.0 (many will sum to < 1 as R16 losers have 0)
|
|
expect(sum).toBeGreaterThanOrEqual(0);
|
|
expect(sum).toBeLessThanOrEqual(1.001);
|
|
// All probabilities are non-negative
|
|
expect(p.probFirst).toBeGreaterThanOrEqual(0);
|
|
expect(p.probSecond).toBeGreaterThanOrEqual(0);
|
|
}
|
|
});
|
|
|
|
it("each probability column sums to 1.0 across all 16 participants", async () => {
|
|
const allMatches = [
|
|
...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)),
|
|
...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)),
|
|
...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)),
|
|
makeFinalMatch(),
|
|
];
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(allMatches);
|
|
|
|
const sim = new UCLSimulator();
|
|
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).toBeCloseTo(1.0, 2);
|
|
}
|
|
});
|
|
|
|
it("completed tournament: champion has probFirst=1, eliminated teams have all zeros", async () => {
|
|
// Full tournament completed: 8 R16 losers, 4 QF losers, 2 SF losers, 1 finalist, 1 champion
|
|
// Use team-1 as champion path: R16m1→QFm1→SFm1→Final
|
|
const champion = PARTICIPANT_IDS[0]; // team-1, participant1 of R16 match 1
|
|
const _finalist = PARTICIPANT_IDS[2]; // team-3, participant1 of R16 match 2
|
|
|
|
const r16Matches = Array.from({ length: 8 }, (_, i) => {
|
|
const matchNum = i + 1;
|
|
const p1 = PARTICIPANT_IDS[(matchNum - 1) * 2];
|
|
const p2 = PARTICIPANT_IDS[(matchNum - 1) * 2 + 1];
|
|
// Match 1: team-1 beats team-2. Rest: p2 wins.
|
|
const winner = matchNum === 1 ? p1 : p2;
|
|
const loser = matchNum === 1 ? p2 : p1;
|
|
return makeR16Match(matchNum, { winnerId: winner, loserId: loser, isComplete: true });
|
|
});
|
|
|
|
// QF: R16 match 1 winner (team-1) + R16 match 2 winner (team-4) → QF match 1: team-1 wins
|
|
// R16 match 3 winner (team-6) + R16 match 4 winner (team-8) → QF match 2: team-6 wins
|
|
// QF match 3: team-10 wins, QF match 4: team-14 wins
|
|
const _qfWinners = [champion, PARTICIPANT_IDS[3], PARTICIPANT_IDS[9], PARTICIPANT_IDS[13]];
|
|
const _qfLosers = [PARTICIPANT_IDS[3], PARTICIPANT_IDS[5], PARTICIPANT_IDS[7], PARTICIPANT_IDS[11]];
|
|
// Actually: QF1: team-1 beats team-4; QF2: team-6 beats team-8; etc.
|
|
const qfMatches = Array.from({ length: 4 }, (_, i) => {
|
|
const matchNum = i + 1;
|
|
const r16w1 = r16Matches[(matchNum - 1) * 2].winnerId ?? "";
|
|
const r16w2 = r16Matches[(matchNum - 1) * 2 + 1].winnerId ?? "";
|
|
const winner = matchNum === 1 ? r16w1 : r16w2;
|
|
const loser = matchNum === 1 ? r16w2 : r16w1;
|
|
return makeQFMatch(matchNum, { winnerId: winner, loserId: loser, isComplete: true });
|
|
});
|
|
|
|
// SF: QF1 winner (team-1) + QF2 winner → SF1: team-1 wins
|
|
const sf1Winner = qfMatches[0].winnerId ?? "";
|
|
const sf1Loser = qfMatches[1].winnerId ?? "";
|
|
const sf2Winner = qfMatches[2].winnerId ?? "";
|
|
const sf2Loser = qfMatches[3].winnerId ?? "";
|
|
const sfMatches = [
|
|
makeSFMatch(1, { winnerId: sf1Winner, loserId: sf1Loser, isComplete: true }),
|
|
makeSFMatch(2, { winnerId: sf2Winner, loserId: sf2Loser, isComplete: true }),
|
|
];
|
|
|
|
// Final: sf1Winner (team-1) vs sf2Winner
|
|
const actualChampion = sf1Winner; // team-1
|
|
const actualFinalist = sf2Winner;
|
|
const finalMatch = makeFinalMatch({ winnerId: actualChampion, loserId: actualFinalist, isComplete: true });
|
|
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue([
|
|
...r16Matches, ...qfMatches, ...sfMatches, finalMatch,
|
|
]);
|
|
|
|
const sim = new UCLSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
const championResult = results.find((r) => r.participantId === actualChampion);
|
|
const finalistResult = results.find((r) => r.participantId === actualFinalist);
|
|
if (!championResult || !finalistResult) throw new Error('Champion or finalist result not found');
|
|
|
|
// Champion must have probFirst = 1.0
|
|
expect(championResult.probabilities.probFirst).toBeCloseTo(1.0, 3);
|
|
expect(championResult.probabilities.probSecond).toBeCloseTo(0, 3);
|
|
|
|
// Finalist must have probSecond = 1.0
|
|
expect(finalistResult.probabilities.probFirst).toBeCloseTo(0, 3);
|
|
expect(finalistResult.probabilities.probSecond).toBeCloseTo(1.0, 3);
|
|
|
|
// R16 losers (teams that lost in R16) must have all probs = 0
|
|
const r16LoserId = r16Matches[0].loserId ?? ""; // team-2 lost in R16 match 1
|
|
const r16LoserResult = results.find((r) => r.participantId === r16LoserId);
|
|
if (!r16LoserResult) throw new Error('R16 loser result not found');
|
|
const r16Sum = Object.values(r16LoserResult.probabilities).reduce((a, b) => a + b, 0);
|
|
expect(r16Sum).toBeCloseTo(0, 5);
|
|
|
|
// Verify source label
|
|
expect(championResult.source).toBe("ucl_bracket_monte_carlo");
|
|
});
|
|
|
|
it("uses source: 'ucl_bracket_monte_carlo' on all results", async () => {
|
|
const allMatches = [
|
|
...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)),
|
|
...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)),
|
|
...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)),
|
|
makeFinalMatch(),
|
|
];
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(allMatches);
|
|
|
|
const sim = new UCLSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
for (const r of results) {
|
|
expect(r.source).toBe("ucl_bracket_monte_carlo");
|
|
}
|
|
});
|
|
});
|