brackt/app/models/__tests__/sports-season.clone.test.ts
Chris Parsons e5295812f6
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.

Key additions:
- manifest.ts: per-simulator display names, default configs, required/
  optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
  projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
  supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
  derived inputs, normalises result columns, zeroes omitted participants,
  snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
  summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
  falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
  only copied when explicitly requested

Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
  participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
  readiness failure, empty results, and error recovery with status reset

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00

421 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/database/schema", () => ({
sportsSeasons: { id: "ss.id", sportId: "ss.sport_id" },
seasonParticipants: { sportsSeasonId: "p.sports_season_id" },
scoringEvents: { sportsSeasonId: "se.sports_season_id" },
seasonParticipantExpectedValues: { sportsSeasonId: "pev.sports_season_id" },
sportsSeasonSimulatorConfigs: { sportsSeasonId: "ssc.sports_season_id" },
seasonParticipantSimulatorInputs: { sportsSeasonId: "spi.sports_season_id" },
}));
vi.mock("drizzle-orm", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings, values }),
lte: (col: unknown, val: unknown) => ({ type: "lte", col, val }),
gte: (col: unknown, val: unknown) => ({ type: "gte", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }),
desc: (col: unknown) => ({ type: "desc", col }),
asc: (col: unknown) => ({ type: "asc", col }),
}));
vi.mock("~/models/qualifying-points", () => ({
getQPConfig: vi.fn(),
updateQPConfig: vi.fn(),
}));
import * as schema from "~/database/schema";
import { cloneSportsSeason, type NewSportsSeason } from "../sports-season";
import { database } from "~/database/context";
import { getQPConfig, updateQPConfig } from "~/models/qualifying-points";
const SOURCE_ID = "source-season-id";
const NEW_ID = "new-season-id";
const sourceSeason = {
id: SOURCE_ID,
sportId: "sport-1",
name: "2025 F1 Season",
year: 2025,
startDate: "2025-03-16",
endDate: "2025-12-07",
draftOn: "2025-01-01",
draftOff: "2025-03-15",
status: "completed",
scoringType: "majors",
scoringPattern: "season_standings",
totalMajors: null,
majorsCompleted: 5,
qualifyingPointsFinalized: true,
eloCalibrationExponent: "0.33",
eloMinRating: 1250,
eloMaxRating: 1750,
simulationStatus: "idle",
};
const newSeasonData: NewSportsSeason = {
sportId: "sport-1",
name: "2026 F1 Season",
year: 2026,
startDate: "2026-03-16",
endDate: "2026-12-07",
draftOn: "2026-01-01",
draftOff: "2026-03-15",
status: "upcoming",
simulationStatus: "idle",
majorsCompleted: 0,
qualifyingPointsFinalized: false,
scoringType: "majors",
scoringPattern: "season_standings",
};
function makeMockDb({
sourceSeason: ss = sourceSeason,
participants = [] as object[],
scoringEvents = [] as object[],
sourceEvRows = [] as object[],
sourceSimulatorConfig = null as object | null,
sourceSimulatorInputs = [] as object[],
insertedSeason = { ...newSeasonData, id: NEW_ID } as object,
// New participants returned by the INSERT ... RETURNING — mirrors sources with new IDs by default
newParticipantRows = (participants as Array<Record<string, unknown>>).map((p, i) => ({
...p,
id: `new-p${i + 1}`,
sportsSeasonId: NEW_ID,
})) as object[],
} = {}) {
// Track what was inserted into each table independently of call order
const insertedRows: Record<string, unknown> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db: any = {
query: {
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(ss) },
seasonParticipants: { findMany: vi.fn().mockResolvedValue(participants) },
scoringEvents: { findMany: vi.fn().mockResolvedValue(scoringEvents) },
seasonParticipantExpectedValues: { findMany: vi.fn().mockResolvedValue(sourceEvRows) },
sportsSeasonSimulatorConfigs: { findFirst: vi.fn().mockResolvedValue(sourceSimulatorConfig) },
seasonParticipantSimulatorInputs: { findMany: vi.fn().mockResolvedValue(sourceSimulatorInputs) },
},
insert: vi.fn().mockImplementation((table: object) => {
let key: string;
let returnRows: object[];
if (table === schema.sportsSeasons) {
key = "sportsSeasons"; returnRows = [insertedSeason];
} else if (table === schema.seasonParticipants) {
key = "participants"; returnRows = newParticipantRows;
} else if (table === schema.scoringEvents) {
key = "scoringEvents"; returnRows = [];
} else if (table === schema.sportsSeasonSimulatorConfigs) {
key = "sportsSeasonSimulatorConfigs"; returnRows = [];
} else if (table === schema.seasonParticipantSimulatorInputs) {
key = "seasonParticipantSimulatorInputs"; returnRows = [];
} else {
key = "participantExpectedValues"; returnRows = [];
}
return {
values: vi.fn().mockImplementation((vals: unknown) => {
insertedRows[key] = vals;
return { returning: vi.fn().mockResolvedValue(returnRows) };
}),
};
}),
// Passes `db` as the transaction object so tracked inserts still work
transaction: vi.fn().mockImplementation(async (fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
_insertedRows: insertedRows,
};
return db;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("cloneSportsSeason", () => {
it("creates new season with reset fields (status=upcoming, majorsCompleted=0, simulationStatus=idle)", async () => {
const db = makeMockDb();
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
expect(db._insertedRows.sportsSeasons).toMatchObject({
name: "2026 F1 Season",
year: 2026,
status: "upcoming",
simulationStatus: "idle",
majorsCompleted: 0,
qualifyingPointsFinalized: false,
});
});
it("merges ELO calibration fields from source season", async () => {
const db = makeMockDb();
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
expect(db._insertedRows.sportsSeasons).toMatchObject({
eloCalibrationExponent: "0.33",
eloMinRating: 1250,
eloMaxRating: 1750,
});
});
it("copies participants with name, shortName, externalId — not expectedValue", async () => {
const participants = [
{ id: "p1", name: "Max Verstappen", shortName: "VER", externalId: "ext-1", expectedValue: "42.5" },
{ id: "p2", name: "Lewis Hamilton", shortName: "HAM", externalId: "ext-2", expectedValue: "38.0" },
];
const db = makeMockDb({ participants });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
const rows = db._insertedRows.participants as object[];
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
sportsSeasonId: NEW_ID,
name: "Max Verstappen",
shortName: "VER",
externalId: "ext-1",
});
expect(rows[0]).not.toHaveProperty("expectedValue");
expect(rows[0]).not.toHaveProperty("id");
});
it("skips participant insert when source has no participants", async () => {
const db = makeMockDb({ participants: [] });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
expect(db._insertedRows.participants).toBeUndefined();
});
it("copies scoring events with dates shifted by yearDelta", async () => {
const scoringEvents = [
{ id: "e1", name: "Bahrain GP", eventType: "final_standings", isQualifyingEvent: false,
bracketTemplateId: null, scoringStartsAtRound: null,
eventDate: "2025-03-16", eventStartsAt: null },
{ id: "e2", name: "Abu Dhabi GP", eventType: "final_standings", isQualifyingEvent: false,
bracketTemplateId: null, scoringStartsAtRound: null,
eventDate: "2025-12-07", eventStartsAt: null },
];
const db = makeMockDb({ scoringEvents });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
const rows = db._insertedRows.scoringEvents as object[];
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
sportsSeasonId: NEW_ID,
name: "Bahrain GP",
eventDate: "2026-03-16",
isComplete: false,
});
expect(rows[1]).toMatchObject({
name: "Abu Dhabi GP",
eventDate: "2026-12-07",
});
});
it("leaves eventDate null when source event has no date", async () => {
const scoringEvents = [
{ id: "e1", name: "TBD Event", eventType: "schedule_event", isQualifyingEvent: false,
bracketTemplateId: null, scoringStartsAtRound: null,
eventDate: null, eventStartsAt: null },
];
const db = makeMockDb({ scoringEvents });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
const rows = db._insertedRows.scoringEvents as Array<{ eventDate: unknown }>;
expect(rows[0].eventDate).toBeNull();
});
it("shifts eventStartsAt timestamp by yearDelta", async () => {
const originalDate = new Date("2025-06-15T14:00:00Z");
const scoringEvents = [
{ id: "e1", name: "Mid-year Event", eventType: "playoff_game", isQualifyingEvent: false,
bracketTemplateId: null, scoringStartsAtRound: null,
eventDate: null, eventStartsAt: originalDate },
];
const db = makeMockDb({ scoringEvents });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
const rows = db._insertedRows.scoringEvents as Array<{ eventStartsAt: Date }>;
const shiftedDate = rows[0].eventStartsAt;
expect(shiftedDate.getUTCFullYear()).toBe(2026);
expect(shiftedDate.getUTCMonth()).toBe(originalDate.getUTCMonth());
expect(shiftedDate.getUTCDate()).toBe(originalDate.getUTCDate());
});
it("copies futures odds as stub EV records matched by externalId", async () => {
const participants = [
{ id: "src-p1", name: "Max Verstappen", shortName: "VER", externalId: "ext-1" },
{ id: "src-p2", name: "Lewis Hamilton", shortName: "HAM", externalId: "ext-2" },
];
const sourceEvRows = [
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
source: "futures_odds", sourceOdds: -120, sourceElo: null, worldRanking: null },
{ participantId: "src-p2", sportsSeasonId: SOURCE_ID,
source: "futures_odds", sourceOdds: 300, sourceElo: null, worldRanking: null },
];
const newParticipantRows = [
{ id: "new-p1", name: "Max Verstappen", externalId: "ext-1", sportsSeasonId: NEW_ID },
{ id: "new-p2", name: "Lewis Hamilton", externalId: "ext-2", sportsSeasonId: NEW_ID },
];
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
expect(evRows).toHaveLength(2);
expect(evRows[0]).toMatchObject({
participantId: "new-p1",
sportsSeasonId: NEW_ID,
source: "futures_odds",
sourceOdds: -120,
sourceElo: null,
expectedValue: "0",
probFirst: "0",
});
expect(evRows[1]).toMatchObject({ participantId: "new-p2", sourceOdds: 300 });
});
it("copies Elo ratings and world rankings as stub EV records", async () => {
const participants = [
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: "ext-1" },
];
const sourceEvRows = [
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
source: "elo_simulation", sourceOdds: null, sourceElo: 1650, worldRanking: 3 },
];
const newParticipantRows = [
{ id: "new-p1", name: "Player A", externalId: "ext-1", sportsSeasonId: NEW_ID },
];
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
expect(evRows).toHaveLength(1);
expect(evRows[0]).toMatchObject({
participantId: "new-p1",
source: "elo_simulation",
sourceElo: 1650,
worldRanking: 3,
sourceOdds: null,
});
});
it("falls back to name matching when externalId is null", async () => {
const participants = [
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: null },
];
const sourceEvRows = [
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
source: "futures_odds", sourceOdds: 200, sourceElo: null, worldRanking: null },
];
const newParticipantRows = [
{ id: "new-p1", name: "Player A", externalId: null, sportsSeasonId: NEW_ID },
];
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
expect(evRows).toHaveLength(1);
expect(evRows[0]).toMatchObject({ participantId: "new-p1", sourceOdds: 200 });
});
it("skips EV copy when source has no odds or Elo data", async () => {
const participants = [
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: "ext-1" },
];
// EV row exists but has no input data — e.g. only calculated probabilities
const sourceEvRows = [
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
source: "manual", sourceOdds: null, sourceElo: null, worldRanking: null },
];
const db = makeMockDb({ participants, sourceEvRows });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
expect(db._insertedRows.participantExpectedValues).toBeUndefined();
});
it("skips EV copy when source season has no EV rows", async () => {
const participants = [
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: "ext-1" },
];
const db = makeMockDb({ participants, sourceEvRows: [] });
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
expect(db._insertedRows.participantExpectedValues).toBeUndefined();
});
it("copies QP config when scoringPattern is qualifying_points", async () => {
const qpSeason: NewSportsSeason = {
...newSeasonData,
scoringPattern: "qualifying_points",
totalMajors: 4,
};
const mockQPConfig = [
{ placement: 1, points: "20" },
{ placement: 2, points: "14" },
];
vi.mocked(getQPConfig).mockResolvedValue(mockQPConfig as never);
vi.mocked(updateQPConfig).mockResolvedValue([] as never);
const db = makeMockDb();
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, qpSeason);
expect(getQPConfig).toHaveBeenCalledWith(SOURCE_ID, db);
expect(updateQPConfig).toHaveBeenCalledWith(
NEW_ID,
[
{ placement: 1, points: 20 },
{ placement: 2, points: 14 },
],
db
);
});
it("does NOT copy QP config for non-QP seasons", async () => {
const db = makeMockDb();
vi.mocked(database).mockReturnValue(db as never);
await cloneSportsSeason(SOURCE_ID, newSeasonData);
expect(getQPConfig).not.toHaveBeenCalled();
expect(updateQPConfig).not.toHaveBeenCalled();
});
it("throws when source season is not found", async () => {
const db = makeMockDb();
db.query.sportsSeasons.findFirst = vi.fn().mockResolvedValue(undefined);
vi.mocked(database).mockReturnValue(db as never);
await expect(cloneSportsSeason("nonexistent-id", newSeasonData)).rejects.toThrow(
"Source season nonexistent-id not found"
);
});
});