brackt/app/services/simulations/__tests__/auto-racing-simulator.test.ts

451 lines
19 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { AutoRacingSimulator } from "../auto-racing-simulator";
import { F1_RACE_POINTS, INDYCAR_RACE_POINTS } from "../race-points";
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(),
}));
vi.mock("~/models/season-races", () => ({
countSeasonRaces: vi.fn(),
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const DRIVERS = ["d1", "d2", "d3", "d4", "d5"].map((id) => ({ id }));
const PROB_KEYS = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
function makeSeasonResult(participantId: string, currentPoints: string) {
return { participant: { id: participantId }, currentPoints };
}
function makeEv(participantId: string, sourceOdds: number | null) {
return { participantId, sourceOdds };
}
function mockDb(drivers: { id: string }[] = DRIVERS) {
return {
query: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue(drivers),
},
},
};
}
/** Set the race counts the simulator reads from the calendar. */
async function setRaceCounts(completed: number, remaining: number) {
const { countSeasonRaces } = await import("~/models/season-races");
(countSeasonRaces as unknown as MockInstance).mockResolvedValue({
completed,
remaining,
total: completed + remaining,
});
}
async function setStandings(results: ReturnType<typeof makeSeasonResult>[]) {
const { getSeasonResults } = await import("~/models/participant-season-result");
(getSeasonResults as unknown as MockInstance).mockResolvedValue(results);
}
async function setOdds(evs: ReturnType<typeof makeEv>[]) {
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue(evs);
}
async function useDrivers(drivers: { id: string }[]) {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(mockDb(drivers));
}
// ─── Setup ────────────────────────────────────────────────────────────────────
beforeEach(async () => {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(mockDb());
await setStandings([]);
await setOdds([]);
await setRaceCounts(0, 0);
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("AutoRacingSimulator", () => {
it("throws when no participants are found", async () => {
await useDrivers([]);
await expect(
new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1")
).rejects.toThrow(/No participants found/);
});
describe("pre-season path (no races run, none remaining)", () => {
beforeEach(async () => {
await setRaceCounts(0, 0);
// Heavy favourite: d1 at 500, all others at +1000
await setOdds([
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");
for (const key of PROB_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 () => {
await setOdds([]);
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);
}
});
it("prices an unpriced driver at the longest price in the book", async () => {
// d5 has no odds; d2d4 are +1000 long shots. An unpriced driver used to
// be handed 1/N, which rated them above most of the priced field.
await setOdds([
makeEv("d1", -500),
makeEv("d2", 1000),
makeEv("d3", 1000),
makeEv("d4", 1000),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
const unpriced = byId.get("d5") ?? 0;
const longShot = byId.get("d2") ?? 0;
expect(unpriced).toBeCloseTo(longShot, 1);
expect(byId.get("d1") ?? 0).toBeGreaterThan(longShot * 3);
});
it("does not let a thinly priced book flatten the favourite", async () => {
// Only one driver is priced. Anchoring the rest to "the longest price"
// would make that price the whole book and hand out a uniform field, so
// a single-price book keeps the 1/N fallback for the others.
// (Readiness requires odds for every participant, so this is a fallback
// path rather than a supported configuration.)
await setOdds([makeEv("d1", -500)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
expect(byId.get("d1") ?? 0).toBeGreaterThan(0.4);
expect(byId.get("d2") ?? 0).toBeLessThan(0.2);
});
});
describe("season complete (races run, none remaining)", () => {
it("returns the final standings order deterministically", async () => {
await setRaceCounts(17, 0);
// getSeasonResults returns rows already sorted by championship position.
await setStandings([
makeSeasonResult("d3", "601"),
makeSeasonResult("d1", "480"),
makeSeasonResult("d5", "446"),
makeSeasonResult("d2", "420"),
makeSeasonResult("d4", "398"),
]);
// Futures odds disagree entirely — they must be ignored once it is over.
await setOdds([makeEv("d1", -10000), makeEv("d3", 20000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
expect(byId.get("d3")?.probFirst).toBe(1);
expect(byId.get("d1")?.probFirst).toBe(0);
expect(byId.get("d1")?.probSecond).toBe(1);
expect(byId.get("d5")?.probThird).toBe(1);
expect(byId.get("d2")?.probFourth).toBe(1);
expect(byId.get("d4")?.probFifth).toBe(1);
});
it("ranks the whole field, not just the drivers with standings rows", async () => {
// The settled season still has to fill all eight placement columns. Only
// ranking the drivers who have a standings row leaves the trailing
// columns empty, and the residual normalization then dumps a full 1.0
// onto whichever driver happens to be first in the list.
await setRaceCounts(17, 0);
await useDrivers(Array.from({ length: 10 }, (_, i) => ({ id: `d${i + 1}` })));
await setStandings([
makeSeasonResult("d3", "601"),
makeSeasonResult("d1", "480"),
makeSeasonResult("d5", "446"),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
iterations: 500,
});
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
expect(byId.get("d3")?.probFirst).toBe(1);
expect(byId.get("d1")?.probSecond).toBe(1);
expect(byId.get("d5")?.probThird).toBe(1);
// No driver may hold two placements at once.
for (const probs of byId.values()) {
const held = PROB_KEYS.filter((key) => probs[key] > 0.5);
expect(held.length).toBeLessThanOrEqual(1);
}
for (const key of PROB_KEYS) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
});
it("falls back to a points-ranked field when there are no standings rows", async () => {
await setRaceCounts(17, 0);
await setStandings([]);
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
iterations: 500,
});
// Still produces a usable distribution rather than all zeroes.
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 6);
});
});
describe("no race calendar", () => {
it("warns when the season has championship points but no events", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
await setStandings([makeSeasonResult("d1", "400"), makeSeasonResult("d2", "300")]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("championship points but no race calendar")
);
warnSpy.mockRestore();
});
it("stays quiet for a genuine pre-season with no points yet", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
await setStandings([]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe("in-season path (races remaining)", () => {
beforeEach(async () => {
await setRaceCounts(10, 5);
});
it("normalizes each position column to sum to 1.0", async () => {
await setStandings(DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50))));
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
for (const key of PROB_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%
await setRaceCounts(20, 5);
// d1 leads with 400 pts; d2 is a distant 2nd with 50 pts
await setStandings([
makeSeasonResult("d1", "400"),
makeSeasonResult("d2", "50"),
makeSeasonResult("d3", "40"),
makeSeasonResult("d4", "30"),
makeSeasonResult("d5", "20"),
]);
// Futures odds heavily favour d2 (pretend markets disagree)
await setOdds([
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
await setOdds([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 () => {
// Early season → standings gap is small
await setRaceCounts(2, 20);
// d1-d4 have a modest lead; d5 is absent (0 pts, new entry)
await setStandings([
makeSeasonResult("d1", "10"),
makeSeasonResult("d2", "8"),
makeSeasonResult("d3", "6"),
makeSeasonResult("d4", "4"),
// d5 intentionally absent → falls back to odds weight
]);
await setOdds([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(() => {});
// Only 3 of 5 drivers have standings rows
await setStandings([
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();
});
});
describe("IndyCar regression: near-clinched championship leader", () => {
// The reported bug. A 121-point lead with 2 races left is arithmetically
// unassailable (max 100 available, and the leader banks at least 10), but
// the simulator skipped `schedule_event` rows, saw zero remaining races,
// took the pre-season branch and echoed stale futures odds at ~55%.
const POINTS = [
601, 480, 446, 420, 398, 372, 350, 331, 315, 300, 288, 270, 255, 240,
228, 215, 200, 188, 175, 160, 148, 135, 120, 105, 90, 70, 55,
];
const ODDS = [
-300, 450, 700, 1200, 1800, 2500, 4000, 5000, 6000, 8000, 10000, 12000,
15000, 20000, 25000, 30000, 40000, 50000, 50000, 50000, 50000, 50000,
50000, 50000, 50000, 50000, 50000,
];
const FIELD = POINTS.map((_, i) => ({ id: `driver${i}` }));
beforeEach(async () => {
await useDrivers(FIELD);
await setStandings(FIELD.map((d, i) => makeSeasonResult(d.id, String(POINTS[i]))));
await setOdds(FIELD.map((d, i) => makeEv(d.id, ODDS[i])));
});
it("gives the leader ~100% with 2 of 17 races left", async () => {
await setRaceCounts(15, 2);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
expect(leader.probabilities.probFirst).toBeGreaterThan(0.99);
});
it("without a calendar it can only echo the stale odds — the shape of the bug", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
// Nowhere near the truth, which is exactly why the no-calendar warning
// above exists. Power devig keeps the -300 favourite well clear of the
// 55% that proportional devig produced, but odds alone cannot see a
// 121-point lead.
expect(leader.probabilities.probFirst).toBeLessThan(0.9);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("championship points but no race calendar")
);
warnSpy.mockRestore();
});
it("still gives the leader a commanding lead with 5 races left", async () => {
await setRaceCounts(12, 5);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
expect(leader.probabilities.probFirst).toBeGreaterThan(0.9);
});
});
});
describe("race points tables", () => {
it("IndyCar pays 50 for a win and scores down to P26", () => {
expect(INDYCAR_RACE_POINTS[1]).toBe(50);
expect(INDYCAR_RACE_POINTS[2]).toBe(40);
expect(INDYCAR_RACE_POINTS[25]).toBe(5);
expect(INDYCAR_RACE_POINTS[26]).toBe(5);
expect(INDYCAR_RACE_POINTS[27]).toBeUndefined();
});
it("F1 pays 25 for a win and scores down to P10", () => {
expect(F1_RACE_POINTS[1]).toBe(25);
expect(F1_RACE_POINTS[10]).toBe(1);
expect(F1_RACE_POINTS[11]).toBeUndefined();
});
it("both tables decrease monotonically so the points loop never truncates early", () => {
for (const table of [F1_RACE_POINTS, INDYCAR_RACE_POINTS]) {
const positions = Object.keys(table).map(Number).toSorted((a, b) => a - b);
// Contiguous from P1, no gaps — the award loop breaks at the first 0.
positions.forEach((pos, i) => expect(pos).toBe(i + 1));
for (let i = 1; i < positions.length; i++) {
expect(table[positions[i]]).toBeLessThanOrEqual(table[positions[i - 1]]);
}
}
});
});