The IndyCar simulator gave a near-clinched championship leader ~60% to win the title. It was reporting the futures odds and nothing else. `event_type` has no race value, so a season_standings calendar (F1, IndyCar) is stored as `schedule_event` rows — the admin default for that scoring pattern. The simulator skipped exactly those rows when counting races, so it saw zero remaining races, took the branch labelled "pre-season", and never looked at `participant_season_results`. Since `sourceOdds` is an admin input that is never auto-refreshed, the output was whatever the market said months ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races left, stale futures at -300) the old path returns ~55%; counting the races returns 100.0%. - Add `countSeasonRaces` in a new leaf model. A race is every event except `final_standings`, and completion is inferred from the event date, since nobody marks rows labelled "Non-Scoring" complete. Its own module because `scoring-event.ts` reaches back into `simulator.ts` through `scoring-calculator`. - Split "season over" from "pre-season". Both had `remainingRaces === 0`, so a finished season reverted to an odds draw instead of reporting the final standings. - Warn when a season has championship points but no calendar, in the simulator and as a non-blocking readiness warning on the setup page. - Replace proportional vig removal with a power devig. Dividing every runner by the same book sum guts the favourite in a 27-driver market: a 75% implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the market instead of being handed 1/N. - Move the race points tables to their own module so tests can read them without tripping the manifest/registry import cycle. The existing tests missed all of this because their event fixtures used `eventType: "race"`, which is not a value the enum has. Rebuilt on real enum values, plus a regression test for the reported case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
168 lines
4.4 KiB
TypeScript
168 lines
4.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
|
import { countSeasonRaces, hasRaceRun } from "../season-races";
|
|
|
|
vi.mock("~/database/context", () => ({
|
|
database: vi.fn(),
|
|
}));
|
|
|
|
const NOW = new Date("2026-08-17T12:00:00.000Z");
|
|
const TODAY = "2026-08-17";
|
|
|
|
interface EventRow {
|
|
eventType: string;
|
|
isComplete: boolean;
|
|
eventDate: string | null;
|
|
eventStartsAt: Date | null;
|
|
}
|
|
|
|
function makeEvent(overrides: Partial<EventRow> = {}): EventRow {
|
|
return {
|
|
eventType: "schedule_event",
|
|
isComplete: false,
|
|
eventDate: null,
|
|
eventStartsAt: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function mockEvents(events: EventRow[]) {
|
|
const { database } = await import("~/database/context");
|
|
(database as unknown as MockInstance).mockReturnValue({
|
|
query: {
|
|
scoringEvents: {
|
|
findMany: vi.fn().mockResolvedValue(events),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await mockEvents([]);
|
|
});
|
|
|
|
describe("hasRaceRun", () => {
|
|
it("trusts isComplete when an admin has set it", () => {
|
|
expect(
|
|
hasRaceRun(
|
|
{ isComplete: true, eventDate: "2026-12-31", eventStartsAt: null },
|
|
NOW,
|
|
TODAY
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("prefers eventStartsAt over eventDate", () => {
|
|
// Same day, but the green flag already dropped this morning.
|
|
expect(
|
|
hasRaceRun(
|
|
{
|
|
isComplete: false,
|
|
eventDate: TODAY,
|
|
eventStartsAt: new Date("2026-08-17T09:00:00.000Z"),
|
|
},
|
|
NOW,
|
|
TODAY
|
|
)
|
|
).toBe(true);
|
|
|
|
expect(
|
|
hasRaceRun(
|
|
{
|
|
isComplete: false,
|
|
eventDate: TODAY,
|
|
eventStartsAt: new Date("2026-08-17T18:00:00.000Z"),
|
|
},
|
|
NOW,
|
|
TODAY
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it("treats a past date as run even when nobody marked it complete", () => {
|
|
expect(
|
|
hasRaceRun(
|
|
{ isComplete: false, eventDate: "2026-08-16", eventStartsAt: null },
|
|
NOW,
|
|
TODAY
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("treats a race happening today as still upcoming", () => {
|
|
expect(
|
|
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: null }, NOW, TODAY)
|
|
).toBe(false);
|
|
});
|
|
|
|
it("treats an undated row as upcoming", () => {
|
|
expect(
|
|
hasRaceRun({ isComplete: false, eventDate: null, eventStartsAt: null }, NOW, TODAY)
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("countSeasonRaces", () => {
|
|
it("counts schedule_event rows as races", async () => {
|
|
// This is the whole bug: a season_standings calendar is stored as
|
|
// schedule_event rows, and the simulator used to skip them.
|
|
await mockEvents([
|
|
makeEvent({ eventDate: "2026-03-01" }),
|
|
makeEvent({ eventDate: "2026-04-01" }),
|
|
makeEvent({ eventDate: "2026-09-01" }),
|
|
]);
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
completed: 2,
|
|
remaining: 1,
|
|
total: 3,
|
|
});
|
|
});
|
|
|
|
it("excludes the final_standings scoring row", async () => {
|
|
await mockEvents([
|
|
makeEvent({ eventDate: "2026-03-01" }),
|
|
makeEvent({ eventType: "final_standings", eventDate: "2026-11-01" }),
|
|
]);
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
completed: 1,
|
|
remaining: 0,
|
|
total: 1,
|
|
});
|
|
});
|
|
|
|
it("counts other event types too, whichever type the admin used", async () => {
|
|
await mockEvents([
|
|
makeEvent({ eventType: "major_tournament", eventDate: "2026-03-01" }),
|
|
makeEvent({ eventType: "playoff_game", eventDate: "2026-09-01" }),
|
|
]);
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
completed: 1,
|
|
remaining: 1,
|
|
total: 2,
|
|
});
|
|
});
|
|
|
|
it("returns zeroes when the season has no events", async () => {
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
completed: 0,
|
|
remaining: 0,
|
|
total: 0,
|
|
});
|
|
});
|
|
|
|
it("counts a realistic late-season IndyCar calendar", async () => {
|
|
const calendar = [
|
|
...Array.from({ length: 15 }, (_, i) =>
|
|
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
|
|
),
|
|
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
|
|
makeEvent({ eventStartsAt: new Date("2026-09-13T18:00:00.000Z") }),
|
|
makeEvent({ eventType: "final_standings" }),
|
|
];
|
|
await mockEvents(calendar);
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
completed: 15,
|
|
remaining: 2,
|
|
total: 17,
|
|
});
|
|
});
|
|
});
|