When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
265 lines
8.7 KiB
TypeScript
265 lines
8.7 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(),
|
|
},
|
|
]);
|
|
|
|
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(),
|
|
},
|
|
{
|
|
id: "result-2",
|
|
participantId: "participant-2",
|
|
sportsSeasonId: "season-1",
|
|
finalPosition: 2,
|
|
isPartialScore: false,
|
|
qualifyingPoints: null,
|
|
notes: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
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(),
|
|
},
|
|
]);
|
|
|
|
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(),
|
|
},
|
|
]);
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|