- Provisional rows were being treated as finished by updateProbabilitiesAfterResult, whose finishedMap filtered on finalPosition alone. Entry floors made that fire for the whole seeded field: on the first match result, AFL seeds 1-6 would each be pinned to 100% at their floor position and dropped from the ICM recalc, zeroing the championship odds of six teams that had not played. Filter partial rows out of finishedMap so they stay in the unfinished set. Finalized 0-position eliminations still finalize as before. - generate-bracket recalculated standings only inside markEliminatedAndAnnounce, which no-ops when nothing was eliminated. A season whose participants exactly equal the bracket field would never surface the floors in teamStandings.totalPoints. Recalculate explicitly in that case (skipDiscord: seeding is not a result). - applyBracketEntryFloors upserted unconditionally, so regenerating a bracket mid-tournament could downgrade a team already sitting on a better placement. Read existing placements first and only write when the floor improves on what a participant already has; position 0 is eliminated, not a placement, so it never blocks a floor. - Relaxing the reprocess guard to matches.length made the season-wide deleteParticipantResultsBySportsSeasonId reachable with zero completed matches, wiping other events' placements with no replay able to rebuild them. Skip the wipe when there is nothing to replay; entry floors and elimination marking are additive and need no wipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkhdcbUCvramxJdVdoBsKd
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
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("~/database/context", () => ({
|
|
database: () => ({
|
|
query: {
|
|
participantExpectedValues: {
|
|
findMany: vi.fn(),
|
|
},
|
|
},
|
|
}),
|
|
}));
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|