Futures odds entry was split across two unrelated places: a standalone /futures-odds page (which auto-ran the simulation on save and surfaced a "Simulator is not ready" run failure as if the save itself had failed) and the Bulk Simulator Inputs CSV importer on the simulator setup page. Consolidate everything onto the Bulk Simulator Inputs card: - batchUpsertParticipantSimulatorInputs now COALESCE-wraps every conflict update column, so a partial paste (e.g. odds-only) updates just the columns it provides instead of nulling out previously stored Elo/rating/etc. - The bulk importer accepts sportsbook-style paste (one team per line ending in American odds) when no CSV header is present, reusing the existing fuzzy matcher, and auto-runs the simulation on save. A not-ready run is reported as a successful save plus the readiness gap, never as a failed save. - Retire the standalone /futures-odds page (now redirects to the simulator setup page) and drop the redundant Futures links. - Remove the now-dead futures-only model paths (batchSaveSourceOdds, clearSourceOddsForParticipants, batchSaveFuturesOddsForSimulator, batchSaveParticipantSimulatorSourceOdds); the EV-table bridge is preserved by batchUpsertParticipantSimulatorInputs. - Repoint the test to the consolidated path and assert the non-destructive COALESCE behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHnoQ7myY3PzNdTnd7iGNE Co-authored-by: Claude <noreply@anthropic.com> Reviewed-on: #117
87 lines
3.4 KiB
TypeScript
87 lines
3.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Mock the database context before importing any model
|
|
vi.mock("~/database/context", () => ({
|
|
database: vi.fn(),
|
|
}));
|
|
|
|
import { database } from "~/database/context";
|
|
import { batchUpsertParticipantSimulatorInputs } from "../simulator";
|
|
|
|
type SetPayload = Record<string, unknown>;
|
|
|
|
const conflictSetCalls: SetPayload[] = [];
|
|
|
|
const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
|
|
conflictSetCalls.push(arg.set);
|
|
return Promise.resolve(undefined);
|
|
});
|
|
|
|
const tx = {
|
|
insert: vi.fn(() => ({
|
|
values: vi.fn(() => ({ onConflictDoUpdate })),
|
|
})),
|
|
};
|
|
|
|
const mockDb = {
|
|
transaction: vi.fn((cb: (t: typeof tx) => Promise<unknown>) => cb(tx)),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
conflictSetCalls.length = 0;
|
|
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
|
|
});
|
|
|
|
// Recursively flatten a Drizzle `sql` template into its static text so we can
|
|
// assert how a conflict-update column is built (e.g. wrapped in COALESCE).
|
|
function sqlToText(value: unknown): string {
|
|
if (value === null || value === undefined) return "";
|
|
const chunks = (value as { queryChunks?: unknown[] }).queryChunks;
|
|
if (Array.isArray(chunks)) return chunks.map(sqlToText).join("");
|
|
const stringValue = (value as { value?: unknown }).value;
|
|
if (Array.isArray(stringValue)) return stringValue.join("");
|
|
if (typeof stringValue === "string") return stringValue;
|
|
return "";
|
|
}
|
|
|
|
describe("batchUpsertParticipantSimulatorInputs", () => {
|
|
it("updates only the columns it is given, preserving the rest (non-destructive)", async () => {
|
|
await batchUpsertParticipantSimulatorInputs([
|
|
// Odds-only import (e.g. from the bulk futures paste): no Elo/rating supplied.
|
|
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
|
|
]);
|
|
|
|
// Runs in a transaction, writing the simulator-inputs row first then the
|
|
// legacy EV bridge row.
|
|
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
|
expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const simulatorSet = conflictSetCalls[0];
|
|
// Every input column participates in the conflict update so partial pastes
|
|
// can target any field...
|
|
for (const column of ["sourceOdds", "sourceElo", "worldRanking", "rating", "seed", "region"]) {
|
|
expect(simulatorSet).toHaveProperty(column);
|
|
}
|
|
// ...but each is COALESCE-wrapped, so a null incoming value keeps the stored
|
|
// one instead of clobbering it. A bare `excluded.*` here would be the bug.
|
|
const sourceEloSql = sqlToText(simulatorSet.sourceElo).toLowerCase();
|
|
expect(sourceEloSql).toContain("coalesce");
|
|
const ratingSql = sqlToText(simulatorSet.rating).toLowerCase();
|
|
expect(ratingSql).toContain("coalesce");
|
|
|
|
// Metadata must drop the per-column method flag whenever that column gets a
|
|
// fresh direct value, so a stale "generated" flag can't hide a newly entered
|
|
// Elo/rating. Blindly preserving metadata (e.g. COALESCE) would be the bug.
|
|
const metadataSql = sqlToText(simulatorSet.metadata).toLowerCase();
|
|
expect(metadataSql).toContain("sourceelomethod");
|
|
expect(metadataSql).toContain("ratingmethod");
|
|
expect(metadataSql).toContain("excluded.source_elo");
|
|
expect(metadataSql).toContain("excluded.rating");
|
|
});
|
|
|
|
it("is a no-op when given no inputs", async () => {
|
|
await batchUpsertParticipantSimulatorInputs([]);
|
|
expect(mockDb.transaction).not.toHaveBeenCalled();
|
|
});
|
|
});
|