brackt/app/services/simulations/__tests__/auto-racing-simulator.test.ts
chrisp dc94403160
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m23s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m24s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
claude/trusting-heisenberg-r6xnfi (#90)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #90
2026-06-15 03:34:44 +00:00

271 lines
12 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 { AutoRacingSimulator } from "../auto-racing-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/participant-season-result", () => ({
getSeasonResults: vi.fn(),
}));
vi.mock("~/models/participant-expected-value", () => ({
getAllParticipantEVsForSeason: vi.fn(),
}));
// ─── F1 race points (positions 110) ─────────────────────────────────────────
const F1_RACE_POINTS: Record<number, number> = {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
};
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const DRIVERS = ["d1", "d2", "d3", "d4", "d5"].map((id) => ({ id }));
function makeEvent(isComplete: boolean, eventType = "race") {
return { isComplete, eventType };
}
function makeSeasonResult(participantId: string, currentPoints: string) {
return { participant: { id: participantId }, currentPoints };
}
function makeEv(participantId: string, sourceOdds: number | null) {
return { participantId, sourceOdds };
}
function mockDb(events: ReturnType<typeof makeEvent>[]) {
return {
query: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue(DRIVERS),
},
scoringEvents: {
findMany: vi.fn().mockResolvedValue(events),
},
},
};
}
// ─── Setup ────────────────────────────────────────────────────────────────────
let db: ReturnType<typeof mockDb>;
beforeEach(async () => {
const { database } = await import("~/database/context");
const { getSeasonResults } = await import("~/models/participant-season-result");
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
db = mockDb([]);
(database as unknown as MockInstance).mockReturnValue(db);
(getSeasonResults as unknown as MockInstance).mockResolvedValue([]);
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("AutoRacingSimulator", () => {
it("throws when no participants are found", async () => {
db.query.seasonParticipants.findMany.mockResolvedValue([]);
await expect(
new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1")
).rejects.toThrow(/No participants found/);
});
describe("pre-season path (remainingRaces === 0)", () => {
beforeEach(async () => {
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
// No scoring events → remainingRaces = 0
db = mockDb([]);
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(db);
// Heavy favourite: d1 at 500, all others at +1000
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d1", -500),
makeEv("d2", 1000),
makeEv("d3", 1000),
makeEv("d4", 1000),
makeEv("d5", 1000),
]);
});
it("returns one result per driver", async () => {
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(results).toHaveLength(5);
});
it("normalizes each position column to sum to 1.0", async () => {
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
});
it("heavy favourite ranks first more often than long shots", async () => {
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const favourite = results.find((r) => r.participantId === "d1");
const longShot = results.find((r) => r.participantId === "d2");
expect(favourite).toBeDefined();
expect(longShot).toBeDefined();
if (!favourite || !longShot) return;
expect(favourite.probabilities.probFirst).toBeGreaterThan(longShot.probabilities.probFirst);
});
it("drivers without odds get equal fallback probability", async () => {
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
// With equal weights all 5 drivers should finish 1st roughly equally
for (const r of results) {
expect(r.probabilities.probFirst).toBeGreaterThan(0.1);
expect(r.probabilities.probFirst).toBeLessThan(0.3);
}
});
});
describe("in-season path (remainingRaces > 0)", () => {
beforeEach(async () => {
const { database } = await import("~/database/context");
// 10 completed races, 5 remaining
db = mockDb([
...Array.from({ length: 10 }, () => makeEvent(true)),
...Array.from({ length: 5 }, () => makeEvent(false)),
]);
(database as unknown as MockInstance).mockReturnValue(db);
});
it("normalizes each position column to sum to 1.0", async () => {
const { getSeasonResults } = await import("~/models/participant-season-result");
(getSeasonResults as unknown as MockInstance).mockResolvedValue(
DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50)))
);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
});
it("standings leader ranks higher than a driver far behind when standings dominate", async () => {
// 20/25 races done → seasonProgress = 0.8 → standings weighted 80%
const { database } = await import("~/database/context");
db = mockDb([
...Array.from({ length: 20 }, () => makeEvent(true)),
...Array.from({ length: 5 }, () => makeEvent(false)),
]);
(database as unknown as MockInstance).mockReturnValue(db);
const { getSeasonResults } = await import("~/models/participant-season-result");
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
// d1 leads with 400 pts; d2 is a distant 2nd with 50 pts
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
makeSeasonResult("d1", "400"),
makeSeasonResult("d2", "50"),
makeSeasonResult("d3", "40"),
makeSeasonResult("d4", "30"),
makeSeasonResult("d5", "20"),
]);
// Futures odds heavily favour d2 (pretend markets disagree)
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d1", 5000), // very long shot per futures
makeEv("d2", -500), // heavy favourite per futures
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const leader = results.find((r) => r.participantId === "d1");
const distant = results.find((r) => r.participantId === "d2");
expect(leader).toBeDefined();
expect(distant).toBeDefined();
if (!leader || !distant) return;
// Standings signal should dominate: d1's massive points lead wins out
expect(leader.probabilities.probFirst).toBeGreaterThan(distant.probabilities.probFirst);
});
it("falls back to odds for all drivers when no standings data exists", async () => {
// totalCurrentPoints = 0 → standings signal disabled, odds take over
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d1", -500),
makeEv("d2", 1000),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const fav = results.find((r) => r.participantId === "d1");
const longShot = results.find((r) => r.participantId === "d2");
expect(fav).toBeDefined();
expect(longShot).toBeDefined();
if (!fav || !longShot) return;
// Without standings, odds-favoured driver should still rank higher
expect(fav.probabilities.probFirst).toBeGreaterThan(longShot.probabilities.probFirst);
});
it("a driver with 0 points mid-season is not penalized beyond their odds weight", async () => {
// Use 2 completed / 20 remaining → early season, standings gap is small
const { database } = await import("~/database/context");
db = mockDb([
...Array.from({ length: 2 }, () => makeEvent(true)),
...Array.from({ length: 20 }, () => makeEvent(false)),
]);
(database as unknown as MockInstance).mockReturnValue(db);
const { getSeasonResults } = await import("~/models/participant-season-result");
// d1-d4 have a modest lead; d5 is absent (0 pts, new entry)
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
makeSeasonResult("d1", "10"),
makeSeasonResult("d2", "8"),
makeSeasonResult("d3", "6"),
makeSeasonResult("d4", "4"),
// d5 intentionally absent → falls back to odds weight
]);
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d5", -500), // strong odds favourite despite 0 pts
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
// d5 should win championships at a non-trivial rate given their strong odds weight
const d5 = results.find((r) => r.participantId === "d5");
expect(d5).toBeDefined();
if (!d5) return;
expect(d5.probabilities.probFirst).toBeGreaterThan(0.1);
});
it("emits a warning when participants are missing from standings", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { getSeasonResults } = await import("~/models/participant-season-result");
// Only 3 of 5 drivers have standings rows
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
makeSeasonResult("d1", "100"),
makeSeasonResult("d2", "80"),
makeSeasonResult("d3", "60"),
]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("2 participant(s) missing from standings")
);
warnSpy.mockRestore();
});
it("schedule_event entries are excluded from race counts", async () => {
const { database } = await import("~/database/context");
// 5 real races + 3 schedule_events (should be ignored)
db = mockDb([
...Array.from({ length: 5 }, () => makeEvent(true)),
...Array.from({ length: 3 }, () => makeEvent(false, "schedule_event")),
makeEvent(false), // 1 real remaining
]);
(database as unknown as MockInstance).mockReturnValue(db);
// Should not throw and should use seasonProgress = 5/6
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(results).toHaveLength(5);
});
});
});