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[]) { const { getSeasonResults } = await import("~/models/participant-season-result"); (getSeasonResults as unknown as MockInstance).mockResolvedValue(results); } async function setOdds(evs: ReturnType[]) { 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("floors an unpriced driver at the bottom of the priced market", async () => { // d5 has no odds at all; d2–d4 are +1000 long shots. Before power devig // an unpriced driver was handed 1/N, which rated them above the 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 unpriced = results.find((r) => r.participantId === "d5"); const longShot = results.find((r) => r.participantId === "d2"); expect(unpriced).toBeDefined(); expect(longShot).toBeDefined(); if (!unpriced || !longShot) return; expect(unpriced.probabilities.probFirst).toBeLessThanOrEqual( longShot.probabilities.probFirst + 0.02 ); }); }); 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("warns and falls back to odds when there are no standings rows", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 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"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("no races left but no standings rows") ); // 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); warnSpy.mockRestore(); }); }); 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]]); } } }); });