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>
272 lines
11 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|