Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #90
271 lines
12 KiB
TypeScript
271 lines
12 KiB
TypeScript
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 1–10) ─────────────────────────────────────────
|
||
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);
|
||
});
|
||
});
|
||
});
|