- Remove hardcoded SEEDINGS_2026 name→seed map from snooker-simulator.ts - Load worldRanking from participantExpectedValues (same column darts uses) - Top 16 by worldRanking get seeded; fallback to Elo order if none stored - Add /admin/sports-seasons/:id/snooker-elo route with bulk import (name, Elo, world ranking format) and per-player Elo + Rank # form, matching darts-elo UX - Update snooker simulator tests: remove name-based ranking fixtures, add tests for DB-driven rankings and Elo fallback https://claude.ai/code/session_01Fz54vdsLjDVintFguH32To
509 lines
19 KiB
TypeScript
509 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
|
import { frameWinProb, matchWinProb, SnookerSimulator } from "../snooker-simulator";
|
|
|
|
// ─── Pure math: frameWinProb ──────────────────────────────────────────────────
|
|
|
|
describe("frameWinProb", () => {
|
|
it("returns 0.5 for equal Elo", () => {
|
|
expect(frameWinProb(2400, 2400)).toBeCloseTo(0.5, 5);
|
|
expect(frameWinProb(1500, 1500)).toBeCloseTo(0.5, 5);
|
|
});
|
|
|
|
it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => {
|
|
expect(frameWinProb(2600, 2300)).toBeGreaterThan(0.5);
|
|
expect(frameWinProb(2300, 2600)).toBeLessThan(0.5);
|
|
});
|
|
|
|
it("larger Elo gap → higher win probability (monotonic)", () => {
|
|
const p300 = frameWinProb(2600, 2300);
|
|
const p400 = frameWinProb(2700, 2300);
|
|
const p200 = frameWinProb(2500, 2300);
|
|
expect(p400).toBeGreaterThan(p300);
|
|
expect(p300).toBeGreaterThan(p200);
|
|
});
|
|
|
|
it("is symmetric: frameWinProb(a, b) + frameWinProb(b, a) = 1", () => {
|
|
expect(frameWinProb(2600, 2300) + frameWinProb(2300, 2600)).toBeCloseTo(1.0, 5);
|
|
expect(frameWinProb(2837, 2756) + frameWinProb(2756, 2837)).toBeCloseTo(1.0, 5);
|
|
});
|
|
|
|
it("returns value in [0, 1]", () => {
|
|
expect(frameWinProb(3000, 1000)).toBeLessThan(1);
|
|
expect(frameWinProb(3000, 1000)).toBeGreaterThan(0);
|
|
expect(frameWinProb(1000, 3000)).toBeLessThan(1);
|
|
expect(frameWinProb(1000, 3000)).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("is symmetric: p1 vs p2 win probs sum to 1", () => {
|
|
expect(frameWinProb(2500, 2200) + frameWinProb(2200, 2500)).toBeCloseTo(1.0, 5);
|
|
});
|
|
});
|
|
|
|
// ─── Pure math: matchWinProb ──────────────────────────────────────────────────
|
|
|
|
describe("matchWinProb", () => {
|
|
it("returns 0.5 for equal players (p=0.5) at any match length", () => {
|
|
// Best-of-19 (F=10), best-of-25 (F=13), best-of-33 (F=17), best-of-35 (F=18)
|
|
expect(matchWinProb(0.5, 10)).toBeCloseTo(0.5, 3);
|
|
expect(matchWinProb(0.5, 13)).toBeCloseTo(0.5, 3);
|
|
expect(matchWinProb(0.5, 17)).toBeCloseTo(0.5, 3);
|
|
expect(matchWinProb(0.5, 18)).toBeCloseTo(0.5, 3);
|
|
});
|
|
|
|
it("returns ~0.758 for p=0.68, best-of-3 (F=2) — matches reference example", () => {
|
|
// Site example: 68% per-frame win rate → 75.8% match win for best-of-3
|
|
expect(matchWinProb(0.68, 2)).toBeCloseTo(0.758, 2);
|
|
});
|
|
|
|
it("approaches 1.0 as p → 1.0", () => {
|
|
expect(matchWinProb(0.9999, 10)).toBeCloseTo(1.0, 3);
|
|
expect(matchWinProb(0.9999, 18)).toBeCloseTo(1.0, 3);
|
|
});
|
|
|
|
it("approaches 0.0 as p → 0.0", () => {
|
|
expect(matchWinProb(0.0001, 10)).toBeCloseTo(0.0, 3);
|
|
expect(matchWinProb(0.0001, 18)).toBeCloseTo(0.0, 3);
|
|
});
|
|
|
|
it("longer matches amplify the stronger player's advantage", () => {
|
|
// p=0.6: win prob should be higher in best-of-35 than best-of-3
|
|
const bo3 = matchWinProb(0.6, 2);
|
|
const bo35 = matchWinProb(0.6, 18);
|
|
expect(bo35).toBeGreaterThan(bo3);
|
|
});
|
|
|
|
it("match win prob + opponent win prob sums to 1.0", () => {
|
|
// p = 0.65, F = 13
|
|
const p = 0.65;
|
|
const winProb = matchWinProb(p, 13);
|
|
const lossProb = matchWinProb(1 - p, 13);
|
|
expect(winProb + lossProb).toBeCloseTo(1.0, 5);
|
|
});
|
|
});
|
|
|
|
// ─── Integration tests with mocked DB ────────────────────────────────────────
|
|
|
|
const PARTICIPANT_IDS = Array.from({ length: 32 }, (_, i) => `player-${i + 1}`);
|
|
const ELO_BASE = 2400;
|
|
|
|
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 5-round bracket (32 players, 31 matches total). */
|
|
function makeFullBracket(): ReturnType<typeof makeMatch>[] {
|
|
const matches: ReturnType<typeof makeMatch>[] = [];
|
|
|
|
// R32: 16 matches, pairs: (0,1), (2,3), ..., (30,31)
|
|
for (let i = 0; i < 16; i++) {
|
|
matches.push(makeMatch("First Round", i + 1, i * 2, i * 2 + 1));
|
|
}
|
|
// R16: 8 matches
|
|
for (let i = 0; i < 8; i++) {
|
|
matches.push(makeMatch("Second Round", i + 1, 0, 1)); // participants filled in by bracket
|
|
}
|
|
// QF: 4 matches
|
|
for (let i = 0; i < 4; i++) {
|
|
matches.push(makeMatch("Quarter-Finals", i + 1, 0, 1));
|
|
}
|
|
// SF: 2 matches
|
|
for (let i = 0; i < 2; i++) {
|
|
matches.push(makeMatch("Semi-Finals", i + 1, 0, 1));
|
|
}
|
|
// Final: 1 match
|
|
matches.push(makeMatch("Final", 1, 0, 1));
|
|
|
|
return matches;
|
|
}
|
|
|
|
/** Build EV rows with uniform Elo for all 32 participants. */
|
|
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("SnookerSimulator.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 has wrong number of rounds", async () => {
|
|
// Only 4 rounds instead of 5 (missing Final)
|
|
const matches = [
|
|
...Array.from({ length: 16 }, (_, i) => makeMatch("First Round", i + 1, i * 2, i * 2 + 1)),
|
|
...Array.from({ length: 8 }, (_, i) => makeMatch("Second Round", 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 SnookerSimulator();
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 5 rounds/);
|
|
});
|
|
|
|
it("throws if First Round match count is wrong", async () => {
|
|
// Only 8 R32 matches instead of 16
|
|
const matches = [
|
|
...Array.from({ length: 8 }, (_, i) => makeMatch("First Round", i + 1, i * 2, i * 2 + 1)),
|
|
...Array.from({ length: 8 }, (_, i) => makeMatch("Second Round", 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 SnookerSimulator();
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 16 First Round/);
|
|
});
|
|
|
|
it("throws if a First Round match is missing participants", async () => {
|
|
const matches = makeFullBracket();
|
|
// Remove participant2Id from First Round match 5
|
|
const r32Match5 = matches.find(m => m.round === "First Round" && m.matchNumber === 5);
|
|
if (!r32Match5) throw new Error("match not found");
|
|
Object.assign(r32Match5, { participant2Id: null });
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
|
|
|
const sim = new SnookerSimulator();
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/);
|
|
});
|
|
|
|
it("returns 32 results with valid probability distributions", async () => {
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
|
|
|
|
const sim = new SnookerSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
expect(results).toHaveLength(32);
|
|
for (const r of results) {
|
|
const p = r.probabilities;
|
|
expect(p.probFirst).toBeGreaterThanOrEqual(0);
|
|
expect(p.probSecond).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 32 participants", async () => {
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
|
|
|
|
const sim = new SnookerSimulator();
|
|
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, all other players have probFirst=0", async () => {
|
|
const champion = PARTICIPANT_IDS[0]; // player-1
|
|
const finalist = PARTICIPANT_IDS[2]; // player-3
|
|
|
|
// Build a complete bracket where player-1 wins everything
|
|
const r32Matches = Array.from({ length: 16 }, (_, i) => {
|
|
const p1 = PARTICIPANT_IDS[i * 2];
|
|
const p2 = PARTICIPANT_IDS[i * 2 + 1];
|
|
const winner = i === 0 ? p1 : p2; // match 1: p1 wins; rest: p2 wins
|
|
return makeMatch("First Round", i + 1, i * 2, i * 2 + 1, {
|
|
winnerId: winner,
|
|
loserId: winner === p1 ? p2 : p1,
|
|
isComplete: true,
|
|
});
|
|
});
|
|
|
|
// R16: player-1 vs player-4 (p2 winners from R32 were p2s)
|
|
// For simplicity, mark QF/SF/Final all complete with player-1 winning
|
|
const r16Matches = Array.from({ length: 8 }, (_, i) => ({
|
|
...makeMatch("Second Round", i + 1, 0, 1),
|
|
winnerId: i === 0 ? champion : PARTICIPANT_IDS[3],
|
|
loserId: i === 0 ? PARTICIPANT_IDS[3] : champion,
|
|
isComplete: true,
|
|
}));
|
|
|
|
const qfMatches = Array.from({ length: 4 }, (_, i) => ({
|
|
...makeMatch("Quarter-Finals", i + 1, 0, 1),
|
|
winnerId: i === 0 ? champion : PARTICIPANT_IDS[5],
|
|
loserId: i === 0 ? PARTICIPANT_IDS[5] : champion,
|
|
isComplete: true,
|
|
}));
|
|
|
|
const sfMatches = Array.from({ length: 2 }, (_, i) => ({
|
|
...makeMatch("Semi-Finals", i + 1, 0, 1),
|
|
winnerId: i === 0 ? champion : finalist,
|
|
loserId: i === 0 ? finalist : champion,
|
|
isComplete: true,
|
|
}));
|
|
|
|
const finalMatch = {
|
|
...makeMatch("Final", 1, 0, 1),
|
|
winnerId: champion,
|
|
loserId: finalist,
|
|
isComplete: true,
|
|
};
|
|
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue([
|
|
...r32Matches, ...r16Matches, ...qfMatches, ...sfMatches, finalMatch,
|
|
]);
|
|
|
|
const sim = new SnookerSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
const championResult = results.find(r => r.participantId === champion);
|
|
expect(championResult?.probabilities.probFirst).toBeCloseTo(1.0, 2);
|
|
|
|
const finalistResult = results.find(r => r.participantId === finalist);
|
|
expect(finalistResult?.probabilities.probSecond).toBeCloseTo(1.0, 2);
|
|
|
|
// R32 losers (the p2s from matches 2-16) should have all-zero probs
|
|
const r32Loser = PARTICIPANT_IDS[3]; // p2 of match 2, loses in R32
|
|
const loserResult = results.find(r => r.participantId === r32Loser);
|
|
if (!loserResult) throw new Error("loserResult not found");
|
|
const lp = loserResult.probabilities;
|
|
expect(lp.probFirst).toBe(0);
|
|
expect(lp.probSecond).toBe(0);
|
|
const loserSum = lp.probFirst + lp.probSecond + lp.probThird + lp.probFourth +
|
|
lp.probFifth + lp.probSixth + lp.probSeventh + lp.probEighth;
|
|
expect(loserSum).toBe(0);
|
|
});
|
|
|
|
it("falls back to equal Elo (50/50) when no sourceElo stored", async () => {
|
|
// Return empty EV rows — no Elo stored
|
|
mockDb.select.mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
});
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
|
|
|
|
const sim = new SnookerSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
// With equal Elo, probFirst should be roughly equal across all participants
|
|
expect(results).toHaveLength(32);
|
|
// Each player should have a roughly fair shot — not zero for all
|
|
const hasNonZero = results.some(r => r.probabilities.probFirst > 0);
|
|
expect(hasNonZero).toBe(true);
|
|
});
|
|
|
|
it("labels results with snooker_world_championship_monte_carlo source", async () => {
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
|
|
|
|
const sim = new SnookerSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
expect(results.every(r => r.source === "snooker_world_championship_monte_carlo")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Path B: pre-bracket simulation ──────────────────────────────────────────
|
|
|
|
describe("SnookerSimulator.simulate() — Path B (pre-bracket)", () => {
|
|
let mockDb: {
|
|
query: {
|
|
scoringEvents: { findFirst: MockInstance };
|
|
playoffMatches: { findMany: MockInstance };
|
|
};
|
|
select: MockInstance;
|
|
};
|
|
|
|
// 32 participants — names don't drive seeding anymore; worldRanking in EV rows does.
|
|
const SEASON_PARTICIPANTS = Array.from({ length: 32 }, (_, i) => ({
|
|
id: `player-${i + 1}`,
|
|
name: `Player ${i + 1}`,
|
|
}));
|
|
|
|
// Build EV rows including worldRanking (rank 1 = player-1, rank 2 = player-2, etc.)
|
|
function makeEvRows(rankOverride?: Map<string, number | null>) {
|
|
return SEASON_PARTICIPANTS.map((p, i) => ({
|
|
participantId: p.id,
|
|
sourceElo: ELO_BASE,
|
|
worldRanking: rankOverride?.has(p.id) ? rankOverride.get(p.id) : i + 1,
|
|
}));
|
|
}
|
|
|
|
function setupMockDb(evRows: ReturnType<typeof makeEvRows>, participantRows = SEASON_PARTICIPANTS) {
|
|
mockDb = {
|
|
query: {
|
|
scoringEvents: { findFirst: vi.fn() },
|
|
playoffMatches: { findMany: vi.fn() },
|
|
},
|
|
select: vi.fn()
|
|
// First select(): participantExpectedValues (Elo + worldRanking)
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(evRows) }),
|
|
})
|
|
// Second select(): participants (id + name)
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows) }),
|
|
}),
|
|
};
|
|
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
|
|
id: "event-1",
|
|
sportsSeasonId: "season-1",
|
|
eventType: "playoff_game",
|
|
});
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
const { database } = await import("~/database/context");
|
|
setupMockDb(makeEvRows());
|
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
|
});
|
|
|
|
it("returns 32 results when bracket is not yet populated", async () => {
|
|
const sim = new SnookerSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
expect(results).toHaveLength(32);
|
|
});
|
|
|
|
it("each probability column sums to 1.0 across all 32 participants", async () => {
|
|
const sim = new SnookerSimulator();
|
|
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("top-seeded player (rank 1, highest Elo) has highest win probability", async () => {
|
|
const { database } = await import("~/database/context");
|
|
|
|
// Give the seed-1 player a big Elo advantage; everyone else gets low Elo
|
|
const eloRows = SEASON_PARTICIPANTS.map((p, i) => ({
|
|
participantId: p.id,
|
|
sourceElo: i === 0 ? 3000 : 1500,
|
|
worldRanking: i + 1,
|
|
}));
|
|
setupMockDb(eloRows);
|
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
|
|
|
const sim = new SnookerSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
const topSeedResult = results.find(r => r.participantId === "player-1");
|
|
const otherProbFirsts = results
|
|
.filter(r => r.participantId !== "player-1")
|
|
.map(r => r.probabilities.probFirst);
|
|
|
|
expect(topSeedResult).toBeDefined();
|
|
expect(topSeedResult!.probabilities.probFirst).toBeGreaterThan(Math.max(...otherProbFirsts));
|
|
});
|
|
|
|
it("falls back to Elo order when no world rankings are stored", async () => {
|
|
const { database } = await import("~/database/context");
|
|
|
|
// No worldRanking on any row — simulator should use Elo order
|
|
const eloRows = SEASON_PARTICIPANTS.map((p, i) => ({
|
|
participantId: p.id,
|
|
sourceElo: i === 0 ? 3000 : 1500, // player-1 has highest Elo
|
|
worldRanking: null,
|
|
}));
|
|
setupMockDb(eloRows);
|
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
|
|
|
const sim = new SnookerSimulator();
|
|
const results = await sim.simulate("season-1");
|
|
|
|
// player-1 with the highest Elo should be treated as seed 1
|
|
const topEloResult = results.find(r => r.participantId === "player-1");
|
|
const otherProbFirsts = results
|
|
.filter(r => r.participantId !== "player-1")
|
|
.map(r => r.probabilities.probFirst);
|
|
|
|
expect(topEloResult).toBeDefined();
|
|
expect(topEloResult!.probabilities.probFirst).toBeGreaterThan(Math.max(...otherProbFirsts));
|
|
});
|
|
|
|
it("throws if fewer than 17 participants exist", async () => {
|
|
const { database } = await import("~/database/context");
|
|
const tooFew = SEASON_PARTICIPANTS.slice(0, 10);
|
|
const eloRows = tooFew.map((p, i) => ({ participantId: p.id, sourceElo: ELO_BASE, worldRanking: i + 1 }));
|
|
|
|
(database as unknown as MockInstance).mockReturnValue({
|
|
...mockDb,
|
|
select: vi.fn()
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRows) }),
|
|
})
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(tooFew) }),
|
|
}),
|
|
});
|
|
|
|
const sim = new SnookerSimulator();
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/at least 17 participants/);
|
|
});
|
|
});
|