- The settled-season branch only ranked drivers that had a standings row, so with fewer than eight rows the trailing placement columns were empty and step 9's residual normalization dumped a full 1.0 onto whichever driver came first. Ten drivers with five standings rows gave d1 probSecond, probSixth, probSeventh and probEighth all at 1. Removed the branch: the in-season path awards no points when no races remain, so it already ranks the final standings, and it ranks the whole field. - The unpriced-driver floor was taken from a distribution normalized over the priced subset alone. With one priced driver devigPower returns [1], so every unpriced driver was seeded at 1 and the field came back flat — a lone -500 favourite fell to 0.117. Floor on the implied scale and devig the whole field instead, keeping the 1/N fallback when a book has a single price and no tail to anchor to. - hasRaceRun treated a race as run at the green flag, so remainingRaces hit 0 the moment the finale started and the pre-race leader was published as champion at 100% from standings that did not include that race. Require the race to have had time to finish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
192 lines
5.4 KiB
TypeScript
192 lines
5.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", () => {
|
|
// Started yesterday and long finished, even though eventDate is unset.
|
|
expect(
|
|
hasRaceRun(
|
|
{
|
|
isComplete: false,
|
|
eventDate: null,
|
|
eventStartsAt: new Date("2026-08-16T18: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("does not call a race run the moment it goes green", () => {
|
|
// Declaring the finale finished at the green flag would publish the
|
|
// pre-race leader as champion at 100%, from standings without that race.
|
|
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
|
expect(
|
|
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
|
).toBe(false);
|
|
});
|
|
|
|
it("counts a race run once it has had time to finish", () => {
|
|
const greenFlag = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
|
|
expect(
|
|
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("still honours isComplete for a race that just started", () => {
|
|
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
|
expect(
|
|
hasRaceRun({ isComplete: true, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
|
).toBe(true);
|
|
});
|
|
|
|
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}` })
|
|
),
|
|
// The next race goes green in a few hours — still remaining.
|
|
makeEvent({ eventStartsAt: new Date("2026-08-17T18:00:00.000Z") }),
|
|
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
|
|
makeEvent({ eventType: "final_standings" }),
|
|
];
|
|
await mockEvents(calendar);
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
completed: 15,
|
|
remaining: 2,
|
|
total: 17,
|
|
});
|
|
});
|
|
});
|