* Add playoff match games and odds storage Introduces two new tables for bracket matchup detail storage: - `playoff_match_games`: tracks individual game schedules within a series matchup (game number, scheduledAt, status, per-game scores, winner). Supports scheduled/complete/postponed status enum. - `playoff_match_odds`: stores moneyline odds per participant per matchup (single upsert record, no isLatest complexity). Includes: - Drizzle schema + relations with CASCADE deletes from playoff_matches - Migration 0040_fat_puma.sql - playoff-match-game.ts model with pure helpers: computeSeriesScore, isSeriesComplete, getSeriesLeader — plus full CRUD - playoff-match-odds.ts model with pure helpers: americanToImpliedProbability, impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete - findPlayoffMatchesByEventId and findPlayoffMatchById updated to include games and odds in their query results - Bracket server route: add-game, update-game, delete-game, upsert-odds, delete-odds actions - Bracket admin UI: expandable per-match panel for game schedule management and moneyline odds entry - 41 new unit tests (18 game + 23 odds), all 810 tests passing https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7 * Code review fixes: type safety, abstraction, and React correctness - Derive PlayoffMatchGameStatus from schema enum instead of hardcoding the string union, eliminating the duplicate source of truth - updateGame now returns PlayoffMatchGame | undefined to reflect reality when no row matches the ID - Remove TOCTOU check-then-act in update-game action: call updateGame directly and check the return value instead of a pre-flight findGameById - Add status enum validation before the cast in update-game action - Move impliedProbability computation inside upsertMatchOdds so callers only provide moneylineOdds; the model owns the derivation - Remove unnecessary dynamic import of americanToImpliedProbability in the upsert-odds action (was already imported from the same module) - Fix React list reconciliation bug: replace bare <> fragment with <Fragment key={match.id}> so React can correctly track rows https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7 --------- Co-authored-by: Claude <noreply@anthropic.com>
136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
americanToImpliedProbability,
|
|
impliedProbabilityToAmerican,
|
|
normalizeOdds,
|
|
} from "../playoff-match-odds";
|
|
|
|
describe("americanToImpliedProbability", () => {
|
|
it("converts even odds (+100) to 0.5", () => {
|
|
expect(americanToImpliedProbability(100)).toBeCloseTo(0.5);
|
|
});
|
|
|
|
it("converts favourite negative odds (-110) correctly", () => {
|
|
// 110 / (110 + 100) = 110/210 ≈ 0.5238
|
|
expect(americanToImpliedProbability(-110)).toBeCloseTo(110 / 210);
|
|
});
|
|
|
|
it("converts heavy favourite (-400) correctly", () => {
|
|
// 400 / 500 = 0.8
|
|
expect(americanToImpliedProbability(-400)).toBeCloseTo(0.8);
|
|
});
|
|
|
|
it("converts underdog (+200) correctly", () => {
|
|
// 100 / (200 + 100) = 100/300 ≈ 0.3333
|
|
expect(americanToImpliedProbability(200)).toBeCloseTo(1 / 3);
|
|
});
|
|
|
|
it("converts heavy underdog (+500) correctly", () => {
|
|
// 100 / 600 ≈ 0.1667
|
|
expect(americanToImpliedProbability(500)).toBeCloseTo(1 / 6);
|
|
});
|
|
|
|
it("returns 0 for zero odds", () => {
|
|
expect(americanToImpliedProbability(0)).toBe(0);
|
|
});
|
|
|
|
it("implied probabilities for two-way market sum > 1 (vig included)", () => {
|
|
// Standard -110/-110 line should sum to > 1 due to vig
|
|
const p1 = americanToImpliedProbability(-110);
|
|
const p2 = americanToImpliedProbability(-110);
|
|
expect(p1 + p2).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
describe("impliedProbabilityToAmerican", () => {
|
|
it("converts 0.5 to even money (+100)", () => {
|
|
// At exactly 0.5, should be -100 (technically even both ways)
|
|
const result = impliedProbabilityToAmerican(0.5);
|
|
expect(result).toBe(-100);
|
|
});
|
|
|
|
it("converts a favourite probability to negative odds", () => {
|
|
// 0.8 probability → -(0.8/0.2)*100 = -400
|
|
expect(impliedProbabilityToAmerican(0.8)).toBe(-400);
|
|
});
|
|
|
|
it("converts an underdog probability to positive odds", () => {
|
|
// 1/3 ≈ 0.3333 → ((1 - 1/3) / (1/3)) * 100 = 200
|
|
expect(impliedProbabilityToAmerican(1 / 3)).toBeCloseTo(200, 0);
|
|
});
|
|
|
|
it("converts a heavy underdog probability to large positive number", () => {
|
|
// 1/6 ≈ 0.1667 → (5/6 / 1/6) * 100 = 500
|
|
expect(impliedProbabilityToAmerican(1 / 6)).toBeCloseTo(500, 0);
|
|
});
|
|
|
|
it("throws for probability of 0", () => {
|
|
expect(() => impliedProbabilityToAmerican(0)).toThrow(RangeError);
|
|
});
|
|
|
|
it("throws for probability of 1", () => {
|
|
expect(() => impliedProbabilityToAmerican(1)).toThrow(RangeError);
|
|
});
|
|
|
|
it("throws for probability > 1", () => {
|
|
expect(() => impliedProbabilityToAmerican(1.5)).toThrow(RangeError);
|
|
});
|
|
|
|
it("throws for negative probability", () => {
|
|
expect(() => impliedProbabilityToAmerican(-0.1)).toThrow(RangeError);
|
|
});
|
|
|
|
it("roundtrips correctly for typical favourite", () => {
|
|
// -200 → prob → back to -200
|
|
const prob = americanToImpliedProbability(-200);
|
|
const back = impliedProbabilityToAmerican(prob);
|
|
expect(back).toBe(-200);
|
|
});
|
|
|
|
it("roundtrips correctly for typical underdog", () => {
|
|
// +150 → prob → back to +150
|
|
const prob = americanToImpliedProbability(150);
|
|
const back = impliedProbabilityToAmerican(prob);
|
|
expect(back).toBe(150);
|
|
});
|
|
});
|
|
|
|
describe("normalizeOdds", () => {
|
|
it("normalized probabilities sum to 1", () => {
|
|
const [p1, p2] = normalizeOdds(-110, -110);
|
|
expect(p1 + p2).toBeCloseTo(1);
|
|
});
|
|
|
|
it("equal moneylines give 50/50 after normalization", () => {
|
|
const [p1, p2] = normalizeOdds(-110, -110);
|
|
expect(p1).toBeCloseTo(0.5);
|
|
expect(p2).toBeCloseTo(0.5);
|
|
});
|
|
|
|
it("favourite has higher normalized probability", () => {
|
|
const [favProb, underdogProb] = normalizeOdds(-200, +170);
|
|
expect(favProb).toBeGreaterThan(underdogProb);
|
|
});
|
|
|
|
it("normalized probabilities are between 0 and 1", () => {
|
|
const [p1, p2] = normalizeOdds(-300, +250);
|
|
expect(p1).toBeGreaterThan(0);
|
|
expect(p1).toBeLessThan(1);
|
|
expect(p2).toBeGreaterThan(0);
|
|
expect(p2).toBeLessThan(1);
|
|
});
|
|
|
|
it("removes vig - heavy favourite still less than 1 after normalization", () => {
|
|
// Raw implied prob for -400 = 0.8. Normalized should be < 1 since opponent exists.
|
|
const [p1] = normalizeOdds(-400, +300);
|
|
expect(p1).toBeLessThan(1);
|
|
expect(p1).toBeGreaterThan(0.5);
|
|
});
|
|
|
|
it("ordering is consistent with input", () => {
|
|
const [p1First, p2First] = normalizeOdds(-200, +170);
|
|
const [p2Second, p1Second] = normalizeOdds(+170, -200);
|
|
expect(p1First).toBeCloseTo(p1Second);
|
|
expect(p2First).toBeCloseTo(p2Second);
|
|
});
|
|
});
|