brackt/app/models/__tests__/playoff-match-game.test.ts
Chris Parsons 2f14565d43
Add playoff match game scheduling and odds management (#135)
* 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>
2026-03-11 14:17:43 -07:00

130 lines
4.1 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
computeSeriesScore,
isSeriesComplete,
getSeriesLeader,
} from "../playoff-match-game";
const P1 = "participant-1";
const P2 = "participant-2";
const game = (winnerId: string | null, status: "complete" | "scheduled" | "postponed" = "complete") => ({
winnerId,
status,
});
describe("computeSeriesScore", () => {
it("returns zero counts when no games are present", () => {
const result = computeSeriesScore([], P1, P2);
expect(result).toEqual({ participant1Wins: 0, participant2Wins: 0, gamesPlayed: 0 });
});
it("counts only complete games", () => {
const games = [
game(P1, "complete"),
game(null, "scheduled"),
game(P2, "postponed"),
];
const result = computeSeriesScore(games, P1, P2);
expect(result.participant1Wins).toBe(1);
expect(result.participant2Wins).toBe(0);
expect(result.gamesPlayed).toBe(1);
});
it("correctly tallies a 4-3 series", () => {
const games = [
game(P1), game(P2), game(P1), game(P2),
game(P2), game(P1), game(P1),
];
const result = computeSeriesScore(games, P1, P2);
expect(result.participant1Wins).toBe(4);
expect(result.participant2Wins).toBe(3);
expect(result.gamesPlayed).toBe(7);
});
it("ignores games won by unknown participant", () => {
const games = [game("unknown-team"), game(P1)];
const result = computeSeriesScore(games, P1, P2);
expect(result.participant1Wins).toBe(1);
expect(result.participant2Wins).toBe(0);
expect(result.gamesPlayed).toBe(2);
});
it("handles a sweep correctly", () => {
const games = [game(P1), game(P1), game(P1), game(P1)];
const result = computeSeriesScore(games, P1, P2);
expect(result.participant1Wins).toBe(4);
expect(result.participant2Wins).toBe(0);
expect(result.gamesPlayed).toBe(4);
});
});
describe("isSeriesComplete", () => {
it("returns false when no games played", () => {
expect(isSeriesComplete([], P1, P2, 4)).toBe(false);
});
it("returns false when neither team has reached required wins", () => {
const games = [game(P1), game(P1), game(P2)];
expect(isSeriesComplete(games, P1, P2, 4)).toBe(false);
});
it("returns true when participant1 reaches required wins (best of 7)", () => {
const games = [game(P1), game(P1), game(P1), game(P1)];
expect(isSeriesComplete(games, P1, P2, 4)).toBe(true);
});
it("returns true when participant2 reaches required wins", () => {
const games = [game(P2), game(P2), game(P2)];
expect(isSeriesComplete(games, P1, P2, 3)).toBe(true);
});
it("works for best of 1", () => {
expect(isSeriesComplete([game(P1)], P1, P2, 1)).toBe(true);
expect(isSeriesComplete([], P1, P2, 1)).toBe(false);
});
it("works for best of 3", () => {
const twoToZero = [game(P1), game(P1)];
expect(isSeriesComplete(twoToZero, P1, P2, 2)).toBe(true);
const oneToOne = [game(P1), game(P2)];
expect(isSeriesComplete(oneToOne, P1, P2, 2)).toBe(false);
});
it("ignores scheduled/postponed games", () => {
const games = [game(P1, "complete"), game(P1, "scheduled")];
expect(isSeriesComplete(games, P1, P2, 2)).toBe(false);
});
});
describe("getSeriesLeader", () => {
it("returns null when no games played", () => {
expect(getSeriesLeader([], P1, P2)).toBeNull();
});
it("returns null when tied", () => {
const games = [game(P1), game(P2)];
expect(getSeriesLeader(games, P1, P2)).toBeNull();
});
it("returns participant1 when they lead", () => {
const games = [game(P1), game(P1), game(P2)];
expect(getSeriesLeader(games, P1, P2)).toBe(P1);
});
it("returns participant2 when they lead", () => {
const games = [game(P2), game(P2), game(P1)];
expect(getSeriesLeader(games, P1, P2)).toBe(P2);
});
it("returns correct leader after a sweep", () => {
const games = [game(P2), game(P2), game(P2), game(P2)];
expect(getSeriesLeader(games, P1, P2)).toBe(P2);
});
it("only counts complete games", () => {
const games = [game(P2, "complete"), game(P1, "scheduled"), game(P1, "postponed")];
expect(getSeriesLeader(games, P1, P2)).toBe(P2);
});
});