import { describe, it, expect, beforeEach, vi } from "vitest"; import { updateProbabilitiesAfterResult, } from "../probability-updater"; import * as participantResultModel from "~/models/participant-result"; import * as participantEVModel from "~/models/participant-expected-value"; // Mock the dependencies vi.mock("~/models/participant-result"); vi.mock("~/models/participant-expected-value"); vi.mock("~/models/simulator"); vi.mock("~/models/sports-season"); vi.mock("~/services/simulations/runner"); vi.mock("~/database/context", () => ({ database: () => ({ query: { participantExpectedValues: { findMany: vi.fn(), }, }, }), })); // vi.mock above is hoisted over the imports, so this is already the mocked function. const upsertEV = vi.mocked(participantEVModel.upsertParticipantEV); describe("probability-updater", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("updateProbabilitiesAfterResult", () => { it("should set 100% probability for finished participants at their placement", async () => { // Mock data: One participant finished 1st vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([ { id: "result-1", participantId: "participant-1", sportsSeasonId: "season-1", finalPosition: 1, isPartialScore: false, qualifyingPoints: null, notes: null, createdAt: new Date(), updatedAt: new Date(), participant: null, }, ]); vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([ { id: "ev-1", participantId: "participant-1", sportsSeasonId: "season-1", probFirst: "0.1500", probSecond: "0.1300", probThird: "0.1200", probFourth: "0.1100", probFifth: "0.1000", probSixth: "0.0900", probSeventh: "0.0800", probEighth: "0.0700", expectedValue: "50.00", source: "futures_odds", sourceOdds: null, calculatedAt: new Date(), updatedAt: new Date(), }, ]); const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({ id: "ev-1", participantId: "participant-1", sportsSeasonId: "season-1", probFirst: "1.0000", probSecond: "0.0000", probThird: "0.0000", probFourth: "0.0000", probFifth: "0.0000", probSixth: "0.0000", probSeventh: "0.0000", probEighth: "0.0000", expectedValue: "100.00", source: "manual", sourceOdds: null, calculatedAt: new Date(), updatedAt: new Date(), }); const result = await updateProbabilitiesAfterResult("season-1", false); expect(result.finishedParticipants).toBe(1); expect(result.updated).toBe(1); expect(result.errors).toHaveLength(0); // Verify upsert was called with correct probabilities expect(upsertSpy).toHaveBeenCalled(); const callArgs = upsertSpy.mock.calls[0][0]; expect(callArgs.participantId).toBe("participant-1"); expect(callArgs.sportsSeasonId).toBe("season-1"); expect(callArgs.probabilities.probFirst).toBe(1); expect(callArgs.probabilities.probSecond).toBe(0); expect(callArgs.source).toBe("manual"); }); it("should handle multiple finished participants", async () => { vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([ { id: "result-1", participantId: "participant-1", sportsSeasonId: "season-1", finalPosition: 1, isPartialScore: false, qualifyingPoints: null, notes: null, createdAt: new Date(), updatedAt: new Date(), participant: null, }, { id: "result-2", participantId: "participant-2", sportsSeasonId: "season-1", finalPosition: 2, isPartialScore: false, qualifyingPoints: null, notes: null, createdAt: new Date(), updatedAt: new Date(), participant: null, }, ]); vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([ { id: "ev-1", participantId: "participant-1", sportsSeasonId: "season-1", probFirst: "0.2000", probSecond: "0.1500", probThird: "0.1000", probFourth: "0.0800", probFifth: "0.0700", probSixth: "0.0600", probSeventh: "0.0500", probEighth: "0.0400", expectedValue: "60.00", source: "futures_odds", sourceOdds: null, calculatedAt: new Date(), updatedAt: new Date(), }, { id: "ev-2", participantId: "participant-2", sportsSeasonId: "season-1", probFirst: "0.1500", probSecond: "0.1500", probThird: "0.1200", probFourth: "0.1000", probFifth: "0.0900", probSixth: "0.0800", probSeventh: "0.0700", probEighth: "0.0600", expectedValue: "55.00", source: "futures_odds", sourceOdds: null, calculatedAt: new Date(), updatedAt: new Date(), }, ]); const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as any); const result = await updateProbabilitiesAfterResult("season-1", false); expect(result.finishedParticipants).toBe(2); expect(result.updated).toBe(2); expect(result.errors).toHaveLength(0); expect(upsertSpy).toHaveBeenCalledTimes(2); }); it("should handle empty results", async () => { vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([]); vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]); const result = await updateProbabilitiesAfterResult("season-1"); expect(result.finishedParticipants).toBe(0); expect(result.unfishedParticipants).toBe(0); expect(result.updated).toBe(0); expect(result.errors).toHaveLength(0); }); it("should handle results without final position", async () => { vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([ { id: "result-1", participantId: "participant-1", sportsSeasonId: "season-1", finalPosition: null, // No position set yet isPartialScore: false, qualifyingPoints: "50.00", notes: null, createdAt: new Date(), updatedAt: new Date(), participant: null, }, ]); vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]); const result = await updateProbabilitiesAfterResult("season-1"); expect(result.finishedParticipants).toBe(0); expect(result.updated).toBe(0); }); it("should set all probabilities to 0% for eliminated participants (finalPosition = 0)", async () => { // Mock: Participant eliminated (didn't make playoffs) vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([ { id: "result-1", participantId: "participant-1", sportsSeasonId: "season-1", finalPosition: 0, // Eliminated isPartialScore: false, qualifyingPoints: null, notes: null, createdAt: new Date(), updatedAt: new Date(), participant: null, }, ]); vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([ { id: "ev-1", participantId: "participant-1", sportsSeasonId: "season-1", probFirst: "0.0500", probSecond: "0.0800", probThird: "0.1200", probFourth: "0.1500", probFifth: "0.1600", probSixth: "0.1500", probSeventh: "0.1400", probEighth: "0.1500", expectedValue: "20.00", source: "futures_odds", sourceOdds: null, calculatedAt: new Date(), updatedAt: new Date(), }, ]); const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as any); const result = await updateProbabilitiesAfterResult("season-1", false); expect(result.finishedParticipants).toBe(1); expect(result.updated).toBe(1); // Verify all probabilities set to 0 const callArgs = upsertSpy.mock.calls[0][0]; expect(callArgs.probabilities.probFirst).toBe(0); expect(callArgs.probabilities.probSecond).toBe(0); expect(callArgs.probabilities.probThird).toBe(0); expect(callArgs.probabilities.probFourth).toBe(0); expect(callArgs.probabilities.probFifth).toBe(0); expect(callArgs.probabilities.probSixth).toBe(0); expect(callArgs.probabilities.probSeventh).toBe(0); expect(callArgs.probabilities.probEighth).toBe(0); }); it("does NOT treat a provisional floor as finished — the team is still playing", async () => { // An AFL top-4 seed banks a provisional 5th-6th floor at seeding. Pinning them // to 100% at 5th would erase their championship odds before they have played. vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([ { id: "result-1", participantId: "participant-1", sportsSeasonId: "season-1", finalPosition: 5, isPartialScore: true, qualifyingPoints: null, notes: null, createdAt: new Date(), updatedAt: new Date(), participant: null, }, ] as never); vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]); const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never); const result = await updateProbabilitiesAfterResult("season-1", false); expect(result.finishedParticipants).toBe(0); expect(upsertSpy).not.toHaveBeenCalled(); }); it("still finalizes a 0-position elimination — those rows are not partial", async () => { vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue([ { id: "result-1", participantId: "participant-1", sportsSeasonId: "season-1", finalPosition: 0, isPartialScore: false, qualifyingPoints: null, notes: null, createdAt: new Date(), updatedAt: new Date(), participant: null, }, ] as never); vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([]); const upsertSpy = vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never); const result = await updateProbabilitiesAfterResult("season-1", false); expect(result.finishedParticipants).toBe(1); expect(upsertSpy.mock.calls[0][0].probabilities.probFirst).toBe(0); }); }); }); // ─── Bracket-aware simulator seasons ────────────────────────────────────────── // // The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about // who is playing whom or what has already been decided, so it cannot see the placement floors // an afl_10 seeding or a non-scoring-round win has already banked — it will happily value a // team below points the league has paid out. Whenever the season has a simulator that reads // its bracket, that simulator is the better answer and is re-run instead. Only a season whose // simulator is bracket-blind (or has none) still goes through ICM. const evRow = (participantId: string, source: string) => ({ id: `ev-${participantId}`, participantId, sportsSeasonId: "season-1", probFirst: "0.1000", probSecond: "0.1000", probThird: "0.1000", probFourth: "0.1000", probFifth: "0.1000", probSixth: "0.1000", probSeventh: "0.1000", probEighth: "0.1000", expectedValue: "34.00", source, sourceOdds: null, calculatedAt: new Date(), updatedAt: new Date(), }); const finishedResult = (participantId: string, finalPosition: number) => ({ id: `result-${participantId}`, participantId, sportsSeasonId: "season-1", finalPosition, isPartialScore: false, qualifyingPoints: null, notes: null, createdAt: new Date(), updatedAt: new Date(), participant: null, }); describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => { /** Wire up a season: which teams are done, what wrote the EVs, which simulator it has. */ async function setup(opts: { evSource: string; simulatorType: string | null; results?: ReturnType[]; seasonStatus?: string; }) { const simulatorModel = await import("~/models/simulator"); const sportsSeasonModel = await import("~/models/sports-season"); const runner = await import("~/services/simulations/runner"); vi.mocked(sportsSeasonModel.findSportsSeasonById).mockResolvedValue({ id: "season-1", status: opts.seasonStatus ?? "active", } as never); vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue( opts.results ?? [] ); vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([ evRow("alive-1", opts.evSource), evRow("alive-2", opts.evSource), ] as never); vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never); vi.mocked(simulatorModel.getSportsSeasonSimulatorConfig).mockResolvedValue( opts.simulatorType ? ({ simulatorType: opts.simulatorType, config: {} } as never) : null ); const runSim = vi.mocked(runner.runSportsSeasonSimulation); runSim.mockResolvedValue({} as never); return { runner, runSim }; } /** The ICM branch is the only thing that writes unfinished rows with this source. */ const icmWrites = () => upsertEV.mock.calls.filter(([arg]) => arg.source === "futures_odds"); beforeEach(() => { vi.clearAllMocks(); }); it("re-runs a bracket-aware simulator instead of recalculating ICM", async () => { const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" }); const result = await updateProbabilitiesAfterResult("season-1", true); expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything()); expect(icmWrites()).toHaveLength(0); expect(result.errors).toEqual([]); }); it("asks the run for probabilities only, leaving standings and snapshots to the caller", async () => { // recalculateAffectedLeagues detects change by diffing teamStandings across its own // recalculation, and that diff gates the Discord standings post. A recalculation in here // runs before it takes its "before" snapshot, so the diff comes back empty and the post is // silently dropped — and previousRank gets rolled forward twice, erasing rank movement. const { runSim } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" }); await updateProbabilitiesAfterResult("season-1", true); expect(runSim).toHaveBeenCalledWith("season-1", { skipStandingsRecalc: true, skipSnapshots: true, }); }); it("falls through to ICM on a completed season rather than failing every time", async () => { // finalizeQualifyingPoints marks the season completed immediately before calling here, and // runSportsSeasonSimulation rejects a completed season outright. Treating that as a failure // would strand anyone still unfinished on stale probabilities forever. const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "cs2_major_qualifying_points", seasonStatus: "completed", }); const result = await updateProbabilitiesAfterResult("season-1", true); expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled(); expect(icmWrites().length).toBeGreaterThan(0); expect(result.errors).toEqual([]); }); it("still pins finished participants before re-running the simulator", async () => { const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket", results: [finishedResult("done-1", 2)], }); await updateProbabilitiesAfterResult("season-1", true); const pinned = upsertEV.mock.calls.find(([arg]) => arg.participantId === "done-1"); expect(pinned?.[0].probabilities.probSecond).toBe(1.0); expect(runner.runSportsSeasonSimulation).toHaveBeenCalledTimes(1); }); it("writes a finalized pin after the re-run, so the pin wins over the simulation", async () => { // runSportsSeasonSimulation rewrites every participant in the season, finalized ones // included. A finalized placement is a fact, not a projection, so it has to land last. const { runSim } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket", results: [finishedResult("done-1", 0)], }); await updateProbabilitiesAfterResult("season-1", true); const pinIndex = upsertEV.mock.calls.findIndex(([arg]) => arg.participantId === "done-1"); expect(pinIndex).toBeGreaterThanOrEqual(0); expect(upsertEV.mock.invocationCallOrder[pinIndex]).toBeGreaterThan( runSim.mock.invocationCallOrder[0] ); }); it("leaves probabilities alone, and does not fall back to ICM, when the re-run fails", async () => { const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" }); vi.mocked(runner.runSportsSeasonSimulation).mockRejectedValue( new Error("A simulation is already running for this sports season.") ); const result = await updateProbabilitiesAfterResult("season-1", true); expect(icmWrites()).toHaveLength(0); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toMatch(/Failed to re-run simulator/); }); it("re-runs the simulator whatever wrote the EVs originally", async () => { // The alternative is not leaving them alone — ICM would overwrite them either way — so // futures-odds EVs are no reason to prefer the bracket-blind overwrite. const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_bracket" }); await updateProbabilitiesAfterResult("season-1", true); expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything()); expect(icmWrites()).toHaveLength(0); }); it("keeps the ICM path for a bracket-blind simulator", async () => { // ncaa_football_bracket declares a "bracket" setup section but never reads playoff_matches, // so re-running it would re-draw the field and hand equity back to eliminated teams. const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "ncaa_football_bracket", }); await updateProbabilitiesAfterResult("season-1", true); expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled(); expect(icmWrites().length).toBeGreaterThan(0); }); it("keeps the ICM path when the season has no simulator configured", async () => { const { runner } = await setup({ evSource: "elo_simulation", simulatorType: null }); await updateProbabilitiesAfterResult("season-1", true); expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled(); expect(icmWrites().length).toBeGreaterThan(0); }); });