455 lines
16 KiB
TypeScript
455 lines
16 KiB
TypeScript
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 1–32 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();
|
||
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/);
|
||
});
|
||
});
|