A review of this branch found four problems, all downstream of one decision: calling runSportsSeasonSimulation from inside the result path. That function does three jobs — recompute probabilities, recalculate standings, write the daily EV snapshot — and the result path wants only the first. 1. The Discord standings post was silently suppressed on every scored match, for all 13 bracket-aware sports. recalculateAffectedLeagues detects change by snapshotting teamStandings, recalculating, then diffing; changedTeamIds gates the notification. But processMatchResult runs updateProbabilitiesAfterResult first, which now reached the runner's own recalculateStandings. The new totals were therefore already written when the "before" snapshot was taken, the diff came back empty, and the post never fired. previousRank went the same way: recalculateStandings rolls it forward on every call, so the extra one erased rank movement. runSportsSeasonSimulation now takes skipStandingsRecalc / skipSnapshots and the probability updater passes both. The snapshot is skipped because it is a per-day series keyed by snapshotDate — writing it per match result just overwrites the day's row with intra-day values. 2. finalizeQualifyingPoints marks the season completed immediately before calling the updater, and the runner rejects a completed season outright. With the ICM fallback gone that failed every time, stranding anyone still in the unfinished set on permanently stale probabilities — reachable for cs2_major_qualifying_points, the one bracket-aware qualifying-points sport. A completed season is not this branch's case rather than a failure: every placement is final and the floor it protects can no longer be contradicted. shouldRerunSimulator now excludes it and it falls through to ICM as before. The genuine failure modes still leave probabilities alone rather than falling back to the path being replaced. 3. match-sync calls processMatchResult in a per-match loop with no skipSideEffects, so each synced match ran a full Monte Carlo plus EV rewrite, snapshot and standings recalc. processMatchResult gains skipProbabilities — mirroring the option processPlayoffEvent already takes, and narrower than skipSideEffects — which match-sync passes in the loop before refreshing once at the end. Per-match standings and Discord posts are unchanged. 4. A partially-seeded afl_10 bracket now throws from inside the result path. The throw is correct and stays; the concern was that it was silent, which the error surfacing in 2 covers. Two claims from the review did not hold up and were left alone: the batch bracket route already passes skipSideEffects per match and refreshes once after the loop, and autoCompleteRoundIfDone already passes skipProbabilities, so there is no double run per round completion. Tests: the runner honors both skip flags and still writes EVs; the updater asks for probabilities only; a completed season takes the ICM path without erroring; processMatchResult skips the refresh but still announces. The two behavioral ones were confirmed to fail against the previous behavior. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
220 lines
8.6 KiB
TypeScript
220 lines
8.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type * as SimulationsRegistry from "~/services/simulations/registry";
|
|
|
|
vi.mock("~/models/sports-season", () => ({
|
|
findSportsSeasonById: vi.fn(),
|
|
updateSportsSeason: vi.fn(),
|
|
}));
|
|
vi.mock("~/models/simulator", () => ({
|
|
getSportsSeasonSimulatorConfig: vi.fn(),
|
|
validateSimulatorReadiness: vi.fn(),
|
|
prepareSimulatorInputsForRun: vi.fn(),
|
|
}));
|
|
vi.mock("~/models/season-participant", () => ({
|
|
findParticipantsBySportsSeasonId: vi.fn(),
|
|
}));
|
|
vi.mock("~/models/participant-expected-value", () => ({
|
|
batchUpsertParticipantEVs: vi.fn(),
|
|
}));
|
|
vi.mock("~/models/ev-snapshot", () => ({
|
|
batchUpsertParticipantEvSnapshots: vi.fn(),
|
|
}));
|
|
vi.mock("~/models/scoring-calculator", () => ({
|
|
recalculateStandings: vi.fn(),
|
|
}));
|
|
vi.mock("~/services/simulations/registry", async (importOriginal) => {
|
|
// Keep the real SIMULATOR_TYPES / getSimulatorInfo so the manifest (pulled in
|
|
// transitively via input-policy) can build; only stub getSimulator.
|
|
const actual = await importOriginal<typeof SimulationsRegistry>();
|
|
return { ...actual, getSimulator: vi.fn() };
|
|
});
|
|
vi.mock("~/services/simulations/simulation-probabilities", () => ({
|
|
normalizeSimulationResultColumns: vi.fn(),
|
|
}));
|
|
vi.mock("~/database/context", () => ({
|
|
database: vi.fn(() => ({
|
|
query: {
|
|
seasonSports: { findMany: vi.fn().mockResolvedValue([]) },
|
|
seasons: { findFirst: vi.fn() },
|
|
},
|
|
})),
|
|
}));
|
|
|
|
import { runSportsSeasonSimulation } from "../runner";
|
|
import { findSportsSeasonById, updateSportsSeason } from "~/models/sports-season";
|
|
import {
|
|
getSportsSeasonSimulatorConfig,
|
|
validateSimulatorReadiness,
|
|
prepareSimulatorInputsForRun,
|
|
} from "~/models/simulator";
|
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
|
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
|
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
|
import { recalculateStandings } from "~/models/scoring-calculator";
|
|
import { database } from "~/database/context";
|
|
import { getSimulator } from "~/services/simulations/registry";
|
|
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
|
|
|
const SEASON = {
|
|
id: "season-1",
|
|
status: "active",
|
|
simulationStatus: "idle",
|
|
fantasySeasonId: null,
|
|
};
|
|
|
|
const CONFIG = {
|
|
sportsSeasonId: "season-1",
|
|
simulatorType: "nba_bracket" as const,
|
|
config: {},
|
|
profile: { displayName: "NBA Bracket", requiredInputs: [], optionalInputs: [], setupSections: [] },
|
|
};
|
|
|
|
const READY = { canRun: true, missingInputs: [], status: "ready" as const };
|
|
|
|
const PARTICIPANTS = [{ id: "p1" }, { id: "p2" }];
|
|
|
|
const RESULTS = [
|
|
{
|
|
participantId: "p1",
|
|
probabilities: {
|
|
probFirst: 0.5, probSecond: 0.2, probThird: 0.1, probFourth: 0.1,
|
|
probFifth: 0.05, probSixth: 0.03, probSeventh: 0.01, probEighth: 0.01,
|
|
},
|
|
source: "elo_simulation" as const,
|
|
},
|
|
{
|
|
participantId: "p2",
|
|
probabilities: {
|
|
probFirst: 0.5, probSecond: 0.2, probThird: 0.1, probFourth: 0.1,
|
|
probFifth: 0.05, probSixth: 0.03, probSeventh: 0.01, probEighth: 0.01,
|
|
},
|
|
source: "elo_simulation" as const,
|
|
},
|
|
];
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(findSportsSeasonById).mockResolvedValue(SEASON as never);
|
|
vi.mocked(getSportsSeasonSimulatorConfig).mockResolvedValue(CONFIG as never);
|
|
vi.mocked(validateSimulatorReadiness).mockResolvedValue(READY as never);
|
|
vi.mocked(prepareSimulatorInputsForRun).mockResolvedValue(undefined);
|
|
vi.mocked(updateSportsSeason).mockResolvedValue(undefined as never);
|
|
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(PARTICIPANTS as never);
|
|
vi.mocked(batchUpsertParticipantEVs).mockResolvedValue([] as never);
|
|
vi.mocked(batchUpsertParticipantEvSnapshots).mockResolvedValue(undefined as never);
|
|
vi.mocked(getSimulator).mockReturnValue({ simulate: vi.fn().mockResolvedValue(RESULTS) } as never);
|
|
vi.mocked(normalizeSimulationResultColumns).mockImplementation(() => undefined);
|
|
});
|
|
|
|
describe("runSportsSeasonSimulation", () => {
|
|
it("runs and returns a summary with zeroed participants for those not in results", async () => {
|
|
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
|
...PARTICIPANTS,
|
|
{ id: "p3" },
|
|
] as never);
|
|
|
|
const result = await runSportsSeasonSimulation("season-1");
|
|
|
|
expect(result.simulatedParticipants).toBe(2);
|
|
expect(result.zeroedParticipants).toBe(1);
|
|
expect(result.simulatorType).toBe("nba_bracket");
|
|
expect(result.snapshotDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
});
|
|
|
|
it("sets simulationStatus to running before simulate and back to idle after", async () => {
|
|
await runSportsSeasonSimulation("season-1");
|
|
|
|
expect(vi.mocked(updateSportsSeason).mock.calls[0]).toEqual(["season-1", { simulationStatus: "running" }]);
|
|
expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]);
|
|
});
|
|
|
|
/** The default mock has no linked leagues, so nothing to recalculate. Give it one. */
|
|
function withLinkedLeague() {
|
|
vi.mocked(database).mockReturnValue({
|
|
query: {
|
|
seasonSports: { findMany: vi.fn().mockResolvedValue([{ seasonId: "fantasy-1" }]) },
|
|
seasons: { findFirst: vi.fn() },
|
|
},
|
|
} as never);
|
|
}
|
|
|
|
it("recalculates standings and writes the daily snapshot by default", async () => {
|
|
withLinkedLeague();
|
|
|
|
await runSportsSeasonSimulation("season-1");
|
|
|
|
expect(recalculateStandings).toHaveBeenCalledWith("fantasy-1");
|
|
expect(batchUpsertParticipantEvSnapshots).toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips standings and snapshots when the caller owns them", async () => {
|
|
withLinkedLeague();
|
|
|
|
// updateProbabilitiesAfterResult runs inside the result path, where the caller
|
|
// recalculates standings straight afterwards. A recalculation here lands before
|
|
// recalculateAffectedLeagues takes its "before" snapshot, emptying the diff that gates the
|
|
// Discord standings post and rolling previousRank forward twice. EVs are still written.
|
|
await runSportsSeasonSimulation("season-1", {
|
|
skipStandingsRecalc: true,
|
|
skipSnapshots: true,
|
|
});
|
|
|
|
expect(recalculateStandings).not.toHaveBeenCalled();
|
|
expect(batchUpsertParticipantEvSnapshots).not.toHaveBeenCalled();
|
|
expect(batchUpsertParticipantEVs).toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when the sports season is not found", async () => {
|
|
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
|
|
|
await expect(runSportsSeasonSimulation("missing")).rejects.toThrow("Sports season not found");
|
|
});
|
|
|
|
it("throws when no simulator config is set", async () => {
|
|
vi.mocked(getSportsSeasonSimulatorConfig).mockResolvedValue(null);
|
|
|
|
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("no simulator type configured");
|
|
});
|
|
|
|
it("throws when a simulation is already running", async () => {
|
|
vi.mocked(findSportsSeasonById).mockResolvedValue({ ...SEASON, simulationStatus: "running" } as never);
|
|
|
|
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("already running");
|
|
});
|
|
|
|
it("throws when the sports season is completed", async () => {
|
|
vi.mocked(findSportsSeasonById).mockResolvedValue({ ...SEASON, status: "completed" } as never);
|
|
|
|
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("cannot be simulated");
|
|
expect(prepareSimulatorInputsForRun).not.toHaveBeenCalled();
|
|
expect(updateSportsSeason).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when readiness check fails", async () => {
|
|
vi.mocked(validateSimulatorReadiness).mockResolvedValue({
|
|
canRun: false,
|
|
missingInputs: ["Elo rating for 3 participant(s)"],
|
|
status: "not_ready",
|
|
} as never);
|
|
|
|
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("not ready");
|
|
});
|
|
|
|
it("sets simulationStatus to failed and re-throws when the simulator errors", async () => {
|
|
vi.mocked(getSimulator).mockReturnValue({
|
|
simulate: vi.fn().mockRejectedValue(new Error("bracket data missing")),
|
|
} as never);
|
|
|
|
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("bracket data missing");
|
|
expect(vi.mocked(updateSportsSeason).mock.calls.at(-1)).toEqual(["season-1", { simulationStatus: "failed" }]);
|
|
});
|
|
|
|
it("sets simulationStatus to failed and re-throws when simulate returns no results", async () => {
|
|
vi.mocked(getSimulator).mockReturnValue({
|
|
simulate: vi.fn().mockResolvedValue([]),
|
|
} as never);
|
|
|
|
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("no results");
|
|
expect(vi.mocked(updateSportsSeason).mock.calls.at(-1)).toEqual(["season-1", { simulationStatus: "failed" }]);
|
|
});
|
|
});
|