brackt/app/services/__tests__/probability-updater.test.ts

271 lines
8.8 KiB
TypeScript
Raw Normal View History

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,
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
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,
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
participant: null,
},
{
id: "result-2",
participantId: "participant-2",
sportsSeasonId: "season-1",
finalPosition: 2,
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
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
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: "50.00",
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
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
feat: progressive floor scoring for playoff brackets (#100) 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>
2026-03-10 10:27:58 -07:00
isPartialScore: false,
qualifyingPoints: null,
notes: null,
createdAt: new Date(),
updatedAt: new Date(),
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
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);
});
});
});