450 lines
19 KiB
TypeScript
450 lines
19 KiB
TypeScript
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; d2–d4 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]]);
|
||
}
|
||
}
|
||
});
|
||
});
|