brackt/app/models/__tests__/futures-odds-simulator.test.ts
Claude 88248e349c
All checks were successful
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
Fix futures odds being ignored when stale Elo exists
When an admin entered futures (preseason) odds for a season that already
had Elo ratings stored, the simulator kept using the old Elo and silently
ignored the new odds. This affected any Elo-based simulator (e.g. NHL).

Root cause: resolveSourceElos() ranks a direct sourceElo above the
sourceOdds -> convertFuturesToElo branch, but batchSaveFuturesOddsForSimulator()
only cleared the bracket-seeding `rating`/`ratingMethod` — never the stale
`sourceElo`/`sourceEloMethod`. A manually entered Elo (method "direct") is not
treated as generated, so it survived and short-circuited the resolver.

Fix:
- batchSaveFuturesOddsForSimulator now also nulls sourceElo and strips
  sourceEloMethod (both the pre-update and upsert-conflict paths), so the
  existing futures -> Elo conversion drives the run.
- resolveSourceElos' sourceOdds branch now guards for >= 2 participants
  (mirroring resolveRatings), so a lone-odds season falls through to the
  configured missing-Elo strategy instead of getting a flat ~1500.
- batchSaveSourceOdds clears the legacy EV sourceElo and marks source as
  futures_odds so the elo-ratings page won't resurrect a stale rating.

Adds unit coverage for odds-derived Elo, the single-participant guard, the
post-clear regression, generated-vs-direct sourceElo suppression, and the
new clearing behavior in batchSaveFuturesOddsForSimulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNfUEd9RzD3zm84oLHBHUH
2026-06-25 17:43:31 +00:00

66 lines
2.1 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 { batchSaveFuturesOddsForSimulator } from "../simulator";
type SetPayload = Record<string, unknown>;
const setCalls: SetPayload[] = [];
const conflictSetCalls: SetPayload[] = [];
const tx = {
update: vi.fn(() => ({
set: vi.fn((arg: SetPayload) => {
setCalls.push(arg);
return { where: vi.fn().mockResolvedValue(undefined) };
}),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
onConflictDoUpdate: vi.fn((arg: { set: SetPayload }) => {
conflictSetCalls.push(arg.set);
return Promise.resolve(undefined);
}),
})),
})),
};
const mockDb = {
transaction: vi.fn(async (cb: (t: typeof tx) => Promise<void>) => cb(tx)),
};
beforeEach(() => {
vi.clearAllMocks();
setCalls.length = 0;
conflictSetCalls.length = 0;
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
});
describe("batchSaveFuturesOddsForSimulator", () => {
it("clears rating and sourceElo so odds drive the next run", async () => {
await batchSaveFuturesOddsForSimulator([
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
]);
// Pre-update clears the stale rating and sourceElo for these participants.
expect(setCalls).toHaveLength(1);
expect(setCalls[0]).toMatchObject({ rating: null, sourceElo: null });
// Metadata is rewritten (an SQL fragment that strips ratingMethod/sourceEloMethod).
expect(setCalls[0].metadata).toBeDefined();
// The upsert conflict path also nulls both so re-entering odds stays clean.
expect(conflictSetCalls).toHaveLength(1);
expect(conflictSetCalls[0]).toMatchObject({ rating: null, sourceElo: null });
expect(conflictSetCalls[0].metadata).toBeDefined();
});
it("is a no-op when given no inputs", async () => {
await batchSaveFuturesOddsForSimulator([]);
expect(mockDb.transaction).not.toHaveBeenCalled();
});
});