brackt/app/services/simulations/__tests__/darts-simulator.test.ts
chrisp 03726f1a66
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m27s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m18s
🚀 Deploy / 🔍 Lint (push) Successful in 47s
🚀 Deploy / 🐳 Build (push) Successful in 15m5s
🚀 Deploy / 🚀 Deploy (push) Successful in 32s
Fix simulator correctness bugs and reduce test iteration counts (#61)
## Summary

- **Bug fix**: EPL Elo/odds fallback was gated on \`dbEloMap.size === 0\` — if any team had a direct \`sourceElo\`, teams with only \`sourceOdds\` were never populated and the simulator threw "Missing Elo" instead of using their odds data
- **Iterations**: WorldCupSimulator default 50k→10k; manifest \`defaultConfig\` for \`world_cup\` and \`epl_standings\` now explicitly override \`BASE_CONFIG\` with 10k so the production fallback matches; test iterations reduced (WorldCup 500→100, EPL 10k→500) to fix timeout failures on slower CI
- **Cleanup**: Extracted \`runKnockoutRound\` out of the 10k-iteration simulation loop; replaced two hot-loop \`toSorted()\` calls on 2-element arrays with a conditional string comparison; removed local \`normalizeProbabilities\` in favour of the shared \`normalizeSimulationResultColumns\`

## Test plan

- [ ] \`npm run test:run\` — all simulator unit tests pass without timeout
- [ ] Typecheck passes (\`npm run typecheck\`)
- [ ] EPL simulate with mixed Elo+odds data no longer throws

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #61
2026-05-31 17:39:43 +00:00

455 lines
16 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, vi, beforeEach, type MockInstance } from "vitest";
import {
setWinProb,
matchWinProb,
getSeededMatchOrder,
buildR1Bracket,
DartsSimulator,
} from "../darts-simulator";
// ─── Pure math: setWinProb ────────────────────────────────────────────────────
describe("setWinProb", () => {
it("returns 0.5 for equal Elo", () => {
expect(setWinProb(1800, 1800)).toBeCloseTo(0.5, 5);
expect(setWinProb(2000, 2000)).toBeCloseTo(0.5, 5);
});
it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => {
expect(setWinProb(2000, 1700)).toBeGreaterThan(0.5);
expect(setWinProb(1700, 2000)).toBeLessThan(0.5);
});
it("is symmetric: setWinProb(a, b) + setWinProb(b, a) = 1", () => {
expect(setWinProb(2000, 1700) + setWinProb(1700, 2000)).toBeCloseTo(1.0, 5);
expect(setWinProb(2099, 1952) + setWinProb(1952, 2099)).toBeCloseTo(1.0, 5);
});
it("larger Elo gap → higher win probability (monotonic)", () => {
const p100 = setWinProb(1900, 1800);
const p300 = setWinProb(2100, 1800);
const p500 = setWinProb(2300, 1800);
expect(p300).toBeGreaterThan(p100);
expect(p500).toBeGreaterThan(p300);
});
it("returns value strictly in (0, 1) for any finite Elo", () => {
expect(setWinProb(3000, 1000)).toBeLessThan(1);
expect(setWinProb(3000, 1000)).toBeGreaterThan(0);
expect(setWinProb(1000, 3000)).toBeLessThan(1);
expect(setWinProb(1000, 3000)).toBeGreaterThan(0);
});
it("calibration: 100-pt gap → ~62% per set (ELO_DIVISOR=200)", () => {
// 1 / (1 + e^(-100/200)) ≈ 0.6225
expect(setWinProb(1900, 1800)).toBeCloseTo(0.6225, 2);
});
});
// ─── Pure math: matchWinProb ──────────────────────────────────────────────────
describe("matchWinProb", () => {
it("returns 0.5 for equal players (p=0.5) at any match length", () => {
// PDC formats: bo3 (S=2), bo5 (S=3), bo7 (S=4), bo11 (S=6), bo13 (S=7)
expect(matchWinProb(0.5, 2)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 3)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 4)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 6)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 7)).toBeCloseTo(0.5, 3);
});
it("approaches 1.0 as p → 1.0", () => {
expect(matchWinProb(0.9999, 2)).toBeCloseTo(1.0, 3);
expect(matchWinProb(0.9999, 7)).toBeCloseTo(1.0, 3);
});
it("approaches 0.0 as p → 0.0", () => {
expect(matchWinProb(0.0001, 2)).toBeCloseTo(0.0, 3);
expect(matchWinProb(0.0001, 7)).toBeCloseTo(0.0, 3);
});
it("longer matches amplify stronger player's advantage", () => {
// p=0.6: win prob should be higher in best-of-13 than best-of-3
const bo3 = matchWinProb(0.6, 2);
const bo13 = matchWinProb(0.6, 7);
expect(bo13).toBeGreaterThan(bo3);
});
it("match win prob + opponent win prob sums to 1.0", () => {
const p = 0.63;
expect(matchWinProb(p, 3) + matchWinProb(1 - p, 3)).toBeCloseTo(1.0, 5);
expect(matchWinProb(p, 6) + matchWinProb(1 - p, 6)).toBeCloseTo(1.0, 5);
});
it("best-of-3 (S=2): reference check for p=0.6 → ~0.648", () => {
// P(2-0) = 0.6^2 = 0.36; P(2-1) = 2 * 0.6^2 * 0.4 = 0.288; total = 0.648
expect(matchWinProb(0.6, 2)).toBeCloseTo(0.648, 3);
});
});
// ─── Bracket structure: getSeededMatchOrder ──────────────────────────────────
describe("getSeededMatchOrder", () => {
it("for n=2, returns [1, 2]", () => {
expect(getSeededMatchOrder(2)).toEqual([1, 2]);
});
it("for n=4, seed 1 vs 4 in top half, seed 2 vs 3 in bottom half", () => {
// Algorithm: start [1,2] → expand to [1,4,2,3]
expect(getSeededMatchOrder(4)).toEqual([1, 4, 2, 3]);
});
it("for n=8, seed 1 and seed 2 are in opposite halves", () => {
const order = getSeededMatchOrder(8);
expect(order).toHaveLength(8);
const idx1 = order.indexOf(1);
const idx2 = order.indexOf(2);
// Seeds 1 and 2 should be in different halves (0-3 vs 4-7)
expect(Math.floor(idx1 / 4)).not.toBe(Math.floor(idx2 / 4));
});
it("for n=32, contains all seeds 132 exactly once", () => {
const order = getSeededMatchOrder(32);
expect(order).toHaveLength(32);
const sorted = [...order].toSorted((a, b) => a - b);
expect(sorted).toEqual(Array.from({ length: 32 }, (_, i) => i + 1));
});
it("for n=32, seed 1 and seed 2 are in opposite halves", () => {
const order = getSeededMatchOrder(32);
const idx1 = order.indexOf(1);
const idx2 = order.indexOf(2);
// Opposite halves: one in [0,15], other in [16,31]
expect(Math.floor(idx1 / 16)).not.toBe(Math.floor(idx2 / 16));
});
});
// ─── Bracket structure: buildR1Bracket ───────────────────────────────────────
describe("buildR1Bracket", () => {
const seeded = Array.from({ length: 32 }, (_, i) => `seed-${i + 1}`);
const unseeded = Array.from({ length: 96 }, (_, i) => `unseed-${i}`);
it("returns exactly 64 pairs", () => {
const pairs = buildR1Bracket(seeded, unseeded);
expect(pairs).toHaveLength(64);
});
it("covers all 128 players (32 seeded + 96 unseeded)", () => {
const pairs = buildR1Bracket(seeded, unseeded);
const allPlayers = pairs.flat();
expect(allPlayers).toHaveLength(128);
for (const [a, b] of pairs) {
expect(a).toBeTruthy();
expect(b).toBeTruthy();
}
// All seeded players appear exactly once
for (const s of seeded) {
expect(allPlayers.filter(p => p === s)).toHaveLength(1);
}
// All unseeded players appear exactly once
for (const u of unseeded) {
expect(allPlayers.filter(p => p === u)).toHaveLength(1);
}
});
it("each of the 32 seeded players faces an unseeded opponent in their R1 match", () => {
const pairs = buildR1Bracket(seeded, unseeded);
const seededSet = new Set(seeded);
// Even-indexed pairs (0, 2, 4, ...) are seeded vs unseeded
for (let i = 0; i < 64; i += 2) {
const [a, b] = pairs[i];
const aSeeded = seededSet.has(a);
const bSeeded = seededSet.has(b);
expect(aSeeded !== bSeeded).toBe(true);
}
});
it("odd-indexed pairs are all unseeded vs unseeded (interleaved bracket structure)", () => {
const pairs = buildR1Bracket(seeded, unseeded);
const seededSet = new Set(seeded);
// Odd-indexed pairs (1, 3, 5, ...) are unseeded vs unseeded
for (let i = 1; i < 64; i += 2) {
const [a, b] = pairs[i];
expect(seededSet.has(a)).toBe(false);
expect(seededSet.has(b)).toBe(false);
}
});
});
// ─── Integration tests with mocked DB ────────────────────────────────────────
const PARTICIPANT_IDS = Array.from({ length: 128 }, (_, i) => `player-${i + 1}`);
const ELO_BASE = 1750;
function makeMatch(
round: string,
matchNumber: number,
p1Index: number,
p2Index: number,
opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}
) {
return {
id: `${round}-match-${matchNumber}`,
scoringEventId: "event-1",
round,
matchNumber,
participant1Id: PARTICIPANT_IDS[p1Index],
participant2Id: PARTICIPANT_IDS[p2Index],
winnerId: opts.winnerId ?? null,
loserId: opts.loserId ?? null,
isComplete: opts.isComplete ?? false,
isScoring: true,
templateRound: round,
seedInfo: null,
participant1Score: null,
participant2Score: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
/** Build a full 7-round bracket (128 players, 127 matches). */
function makeFullBracket(): ReturnType<typeof makeMatch>[] {
const matches: ReturnType<typeof makeMatch>[] = [];
for (let i = 0; i < 64; i++) matches.push(makeMatch("Round 1", i + 1, i * 2, i * 2 + 1));
for (let i = 0; i < 32; i++) matches.push(makeMatch("Round 2", i + 1, 0, 1));
for (let i = 0; i < 16; i++) matches.push(makeMatch("Round 3", i + 1, 0, 1));
for (let i = 0; i < 8; i++) matches.push(makeMatch("Round 4", i + 1, 0, 1));
for (let i = 0; i < 4; i++) matches.push(makeMatch("Quarter-Finals", i + 1, 0, 1));
for (let i = 0; i < 2; i++) matches.push(makeMatch("Semi-Finals", i + 1, 0, 1));
matches.push(makeMatch("Final", 1, 0, 1));
return matches;
}
function makeEVRows(eloOverride?: Map<string, number>) {
return PARTICIPANT_IDS.map((participantId) => ({
participantId,
sourceElo: eloOverride?.get(participantId) ?? ELO_BASE,
worldRanking: null,
}));
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
describe("DartsSimulator.simulate() — Path A (bracket populated)", () => {
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(makeEVRows()),
}),
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
id: "event-1",
sportsSeasonId: "season-1",
eventType: "playoff_game",
});
});
it("throws if bracket does not have exactly 7 rounds", async () => {
// Only 6 rounds (missing Final)
const matches = [
...Array.from({ length: 64 }, (_, i) => makeMatch("Round 1", i + 1, i * 2, i * 2 + 1)),
...Array.from({ length: 32 }, (_, i) => makeMatch("Round 2", i + 1, 0, 1)),
...Array.from({ length: 16 }, (_, i) => makeMatch("Round 3", i + 1, 0, 1)),
...Array.from({ length: 8 }, (_, i) => makeMatch("Round 4", i + 1, 0, 1)),
...Array.from({ length: 4 }, (_, i) => makeMatch("Quarter-Finals", i + 1, 0, 1)),
...Array.from({ length: 2 }, (_, i) => makeMatch("Semi-Finals", i + 1, 0, 1)),
];
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new DartsSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 7 rounds/);
});
it("throws if R1 does not have 64 matches", async () => {
const matches = [
...Array.from({ length: 32 }, (_, i) => makeMatch("Round 1", i + 1, i * 2, i * 2 + 1)),
...Array.from({ length: 32 }, (_, i) => makeMatch("Round 2", i + 1, 0, 1)),
...Array.from({ length: 16 }, (_, i) => makeMatch("Round 3", i + 1, 0, 1)),
...Array.from({ length: 8 }, (_, i) => makeMatch("Round 4", i + 1, 0, 1)),
...Array.from({ length: 4 }, (_, i) => makeMatch("Quarter-Finals", i + 1, 0, 1)),
...Array.from({ length: 2 }, (_, i) => makeMatch("Semi-Finals", i + 1, 0, 1)),
makeMatch("Final", 1, 0, 1),
];
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new DartsSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 64 R1 matches/);
});
it("returns 128 results with valid probability distributions", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new DartsSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(128);
for (const r of results) {
const p = r.probabilities;
expect(p.probFirst).toBeGreaterThanOrEqual(0);
expect(p.probFirst).toBeLessThanOrEqual(1);
const sum = p.probFirst + p.probSecond + p.probThird + p.probFourth +
p.probFifth + p.probSixth + p.probSeventh + p.probEighth;
expect(sum).toBeGreaterThanOrEqual(0);
expect(sum).toBeLessThanOrEqual(1.001);
}
});
it("each probability column sums to 1.0 across all 128 participants", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new DartsSimulator(200);
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("labels results with darts_world_championship_monte_carlo source", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new DartsSimulator();
const results = await sim.simulate("season-1");
expect(results.every(r => r.source === "darts_world_championship_monte_carlo")).toBe(true);
});
});
// ─── Path B: pre-bracket simulation ──────────────────────────────────────────
describe("DartsSimulator.simulate() — Path B (pre-bracket)", () => {
let mockDb: {
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
select: MockInstance;
};
// 128 participants with world rankings
const SEASON_PARTICIPANTS = Array.from({ length: 128 }, (_, i) => ({
id: `player-${i + 1}`,
name: `Player ${i + 1}`,
}));
function makeEVRowsWithRankings() {
return SEASON_PARTICIPANTS.map((p, i) => ({
participantId: p.id,
sourceElo: ELO_BASE - i * 100, // higher seed = higher Elo; large gap needed for reliable MC ordering
worldRanking: i + 1,
}));
}
beforeEach(async () => {
const { database } = await import("~/database/context");
const eloRows = makeEVRowsWithRankings();
const participantRows = SEASON_PARTICIPANTS;
mockDb = {
query: {
scoringEvents: { findFirst: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRows) }),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows) }),
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
});
it("returns 128 results when bracket is not yet populated", async () => {
const sim = new DartsSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(128);
}, 15_000);
it("each probability column sums to 1.0 across all 128 participants", async () => {
const sim = new DartsSimulator();
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);
}
}, 15_000);
it("world #1 (highest Elo) has the highest probFirst", async () => {
const sim = new DartsSimulator();
const results = await sim.simulate("season-1");
const sorted = [...results].toSorted((a, b) => b.probabilities.probFirst - a.probabilities.probFirst);
// player-1 is world #1 with the highest Elo — should have highest win probability
expect(sorted[0].participantId).toBe("player-1");
}, 15_000);
it("throws if fewer than 2 participants exist", async () => {
const { database } = await import("~/database/context");
const tooFew = [{ id: "player-1", name: "Solo" }];
const eloRowsFew = [{ participantId: "player-1", sourceElo: ELO_BASE, worldRanking: 1 }];
(database as unknown as MockInstance).mockReturnValue({
...mockDb,
query: {
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
},
select: vi.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRowsFew) }),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(tooFew) }),
}),
});
const sim = new DartsSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/at least 2 participants/);
});
});