brackt/app/services/simulations/__tests__/world-cup-simulator.test.ts
Chris Parsons b5b60a6093
fix(tests): update mock query keys after participants table rename
Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.

Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 19:09:28 +00:00

272 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { simGroupMatch, WorldCupSimulator } from "../world-cup-simulator";
vi.mock("~/lib/logger", () => ({
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// ─── Pure math: simGroupMatch ─────────────────────────────────────────────────
describe("simGroupMatch", () => {
it("returns win, draw, or loss", () => {
const results = new Set<string>();
for (let i = 0; i < 300; i++) {
results.add(simGroupMatch(1800, 1600));
}
// All three outcomes should appear in 300 trials
expect(results.has("win")).toBe(true);
expect(results.has("draw")).toBe(true);
expect(results.has("loss")).toBe(true);
});
it("equal teams draw ≈28% of the time", () => {
let draws = 0;
const N = 5_000;
for (let i = 0; i < N; i++) {
if (simGroupMatch(1500, 1500) === "draw") draws++;
}
const drawRate = draws / N;
// BASE_DRAW_RATE = 0.28 at eloDiff=0; accept ±5% from sampling noise at N=5k
expect(drawRate).toBeGreaterThan(0.23);
expect(drawRate).toBeLessThan(0.33);
});
it("draw rate decays with large Elo gap", () => {
let draws = 0;
const N = 5_000;
for (let i = 0; i < N; i++) {
if (simGroupMatch(2000, 1400) === "draw") draws++;
}
const drawRate = draws / N;
// eloDiff = 600 → pDraw ≈ 0.28 * exp(-1.2) ≈ 0.084; accept ±5%
expect(drawRate).toBeLessThan(0.15);
});
it("strong favorite wins more often than underdog", () => {
let wins = 0;
let losses = 0;
const N = 5_000;
for (let i = 0; i < N; i++) {
const r = simGroupMatch(1900, 1600);
if (r === "win") wins++;
if (r === "loss") losses++;
}
expect(wins).toBeGreaterThan(losses);
});
it("results sum to 1.0 (no probability mass lost)", () => {
let wins = 0, draws = 0, losses = 0;
const N = 10_000;
for (let i = 0; i < N; i++) {
const r = simGroupMatch(1700, 1700);
if (r === "win") wins++;
else if (r === "draw") draws++;
else losses++;
}
expect(wins + draws + losses).toBe(N);
});
});
// ─── WorldCupSimulator (with mocked DB) ───────────────────────────────────────
// Build 48 mock participants (ids p0..p47)
function makeParticipants(count = 48) {
return Array.from({ length: count }, (_, i) => ({
id: `p${i}`,
name: `Team${i}`,
sportsSeasonId: "season-1",
}));
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { database } from "~/database/context";
const mockDb = {
query: {
seasonParticipants: { findMany: vi.fn() },
scoringEvents: { findFirst: vi.fn() },
tournamentGroups: { findMany: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
};
beforeEach(() => {
vi.mocked(database).mockReturnValue(mockDb as never);
mockDb.query.seasonParticipants.findMany.mockReset();
mockDb.query.scoringEvents.findFirst.mockReset();
mockDb.query.tournamentGroups.findMany.mockReset();
mockDb.query.playoffMatches.findMany.mockReset();
mockDb.select.mockReturnValue(mockDb);
mockDb.from.mockReturnValue(mockDb);
mockDb.where.mockResolvedValue([]);
});
describe("WorldCupSimulator", () => {
it("throws when no participants are found", async () => {
mockDb.query.seasonParticipants.findMany.mockResolvedValue([]);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
await expect(sim.simulate("season-1")).rejects.toThrow("No participants found");
});
it("returns one result per participant", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
expect(results).toHaveLength(48);
const ids = new Set(results.map((r) => r.participantId));
for (const p of participants) {
expect(ids.has(p.id)).toBe(true);
}
});
it("column sums for champion, runner-up, 3rd, 4th are each ≈1.0", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
const sumSecond = results.reduce((s, r) => s + r.probabilities.probSecond, 0);
const sumThird = results.reduce((s, r) => s + r.probabilities.probThird, 0);
const sumFourth = results.reduce((s, r) => s + r.probabilities.probFourth, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
expect(sumSecond).toBeCloseTo(1.0, 2);
expect(sumThird).toBeCloseTo(1.0, 2);
expect(sumFourth).toBeCloseTo(1.0, 2);
});
it("probabilities are all non-negative", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
for (const r of results) {
const { probFirst, probSecond, probThird, probFourth, probFifth } = r.probabilities;
expect(probFirst).toBeGreaterThanOrEqual(0);
expect(probSecond).toBeGreaterThanOrEqual(0);
expect(probThird).toBeGreaterThanOrEqual(0);
expect(probFourth).toBeGreaterThanOrEqual(0);
expect(probFifth).toBeGreaterThanOrEqual(0);
}
});
it("SF losers land in 3rd or 4th, never 1st or 2nd", async () => {
// Set up 8 participants (small bracket, 2 groups of 4)
const participants = makeParticipants(8);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
// probFirst + probSecond + probThird + probFourth should cover all probability mass
// No team should have probThird or probFourth < 0
for (const r of results) {
expect(r.probabilities.probFirst).toBeGreaterThanOrEqual(0);
expect(r.probabilities.probThird).toBeGreaterThanOrEqual(0);
expect(r.probabilities.probFourth).toBeGreaterThanOrEqual(0);
// A champion should have 0 chance at 3rd/4th AND vice versa
// (not guaranteed in aggregate but probFirst + probThird can't both be 1)
expect(r.probabilities.probFirst + r.probabilities.probThird).toBeLessThanOrEqual(1.01);
}
});
it("a team with pre-completed group stage result is fixed in simulation", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
// One group fully complete: p0 wins everything, p3 loses everything
const group = {
id: "group-a",
groupName: "A",
scoringEventId: "event-1",
members: [
{ participantId: "p0" }, { participantId: "p1" },
{ participantId: "p2" }, { participantId: "p3" },
],
matches: [
{ participant1Id: "p0", participant2Id: "p1", participant1Score: 3, participant2Score: 0, isComplete: true, matchday: 1 },
{ participant1Id: "p2", participant2Id: "p3", participant1Score: 2, participant2Score: 0, isComplete: true, matchday: 1 },
{ participant1Id: "p0", participant2Id: "p2", participant1Score: 2, participant2Score: 0, isComplete: true, matchday: 2 },
{ participant1Id: "p1", participant2Id: "p3", participant1Score: 1, participant2Score: 0, isComplete: true, matchday: 2 },
{ participant1Id: "p0", participant2Id: "p3", participant1Score: 1, participant2Score: 0, isComplete: true, matchday: 3 },
{ participant1Id: "p1", participant2Id: "p2", participant1Score: 1, participant2Score: 1, isComplete: true, matchday: 3 },
],
};
// Remaining 44 participants in 11 synthetic groups
const remainingGroups = Array.from({ length: 11 }, (_, gi) => ({
id: `group-${gi + 2}`,
groupName: String.fromCharCode(66 + gi),
scoringEventId: "event-1",
members: [
{ participantId: `p${(gi + 1) * 4 + 0}` },
{ participantId: `p${(gi + 1) * 4 + 1}` },
{ participantId: `p${(gi + 1) * 4 + 2}` },
{ participantId: `p${(gi + 1) * 4 + 3}` },
],
matches: [],
}));
mockDb.query.tournamentGroups.findMany.mockResolvedValue([group, ...remainingGroups]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
const p3Result = results.find((r) => r.participantId === "p3");
expect(p3Result).toBeDefined();
// p3 lost all 3 group games (0 pts) — always last in group, never advances
// → probFirst = probSecond = probThird = probFourth = 0
expect(p3Result?.probabilities.probFirst).toBe(0);
expect(p3Result?.probabilities.probSecond).toBe(0);
expect(p3Result?.probabilities.probThird).toBe(0);
expect(p3Result?.probabilities.probFourth).toBe(0);
});
it("probFifth through probEighth are equal for each participant (QF losers split evenly)", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
for (const r of results) {
const { probFifth, probSixth, probSeventh, probEighth } = r.probabilities;
expect(probFifth).toBeCloseTo(probSixth, 10);
expect(probSixth).toBeCloseTo(probSeventh, 10);
expect(probSeventh).toBeCloseTo(probEighth, 10);
}
});
});