Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
immediately (loser → final placement, winner → provisional floor).
Called from `set-winner` and `set-round-winners` so points are awarded
as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
and `updateProbabilitiesAfterResult` once after the loop instead of per-match
(`skipSideEffects: true` per match).
## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
`getRoundConfig()` helper, eliminating three parallel `if/else` chains in
`processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
`isScoring ?? true` default, and AFL Semi-Finals template requirement.
## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
wins), so AFL QF losers who advance to Semi-Finals via double-chance are
correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
SF loss, and normal advancement not protecting a later loser.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
|
|
|
|
|
|
// Mock external DB-touching dependencies before importing the module under test
|
|
|
|
|
vi.mock("~/services/probability-updater", () => ({
|
|
|
|
|
updateProbabilitiesAfterResult: vi.fn().mockResolvedValue(undefined),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ── Minimal db mock ────────────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// processMatchResult calls, in order:
|
|
|
|
|
// 1. upsertParticipantResult (x2) → db.query.participantResults.findFirst, db.insert
|
|
|
|
|
// 2. recalculateAffectedLeagues → db.query.seasonSports.findMany (returns [] so loop exits)
|
|
|
|
|
// 3. updateProbabilitiesAfterResult (mocked above)
|
|
|
|
|
|
|
|
|
|
function makeDb(existingResult?: { id: string; isPartialScore: boolean }) {
|
|
|
|
|
// Track all inserted values in order
|
|
|
|
|
const insertedRows: object[] = [];
|
|
|
|
|
const updatedRows: object[] = [];
|
|
|
|
|
|
|
|
|
|
const insertValues = vi.fn().mockImplementation((values: object) => {
|
|
|
|
|
insertedRows.push(values);
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
|
|
|
|
const updateSet = vi.fn().mockImplementation((values: object) => {
|
|
|
|
|
updatedRows.push(values);
|
|
|
|
|
return { where: updateWhere };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// participantResults.findFirst: first call gets existingResult (if any),
|
|
|
|
|
// subsequent calls return undefined (winner/loser don't have existing rows)
|
|
|
|
|
let findFirstCallCount = 0;
|
|
|
|
|
const findFirst = vi.fn().mockImplementation(() => {
|
|
|
|
|
const result = findFirstCallCount === 0 ? existingResult : undefined;
|
|
|
|
|
findFirstCallCount++;
|
|
|
|
|
return Promise.resolve(result);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
db: {
|
|
|
|
|
insert: vi.fn().mockReturnValue({ values: insertValues }),
|
|
|
|
|
update: vi.fn().mockReturnValue({ set: updateSet }),
|
|
|
|
|
query: {
|
Canonical tournament layer: schema + backfill (1/2) (#365)
* refactor(schema): rename per-window tables to season_* prefix
Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor: rename participant.ts model file to season-participant.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(models): update model layer to use renamed schema exports
Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant
Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts
Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route layer to use renamed schema exports
- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file
Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts
Error count reduced from 499 to 453 (46 errors fixed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route files for schema rename
Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(services): update simulators and services for renamed schema
Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId
Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts
Typecheck errors reduced from 365 to 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: rename per-window tables to season_* prefix
* fix(tests): update mock query keys after participants table rename
Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.
Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(tests): update remaining mock paths and keys after schema rename
* fix(tests): final two mock stragglers after schema rename
- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: add post-phase1a baseline capture (temp, for diff verification)
* chore: capture pre-migration baselines
* chore: remove post-phase1a capture helper after verification
* schema: add canonical tournament & participant tables
Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(models): add canonical tournament, participant, result, surface-elo models
Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).
Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
createCanonicalParticipant, etc.) to avoid collision with existing
season-participant.ts exports
- All four models include comprehensive unit tests following the
audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints
Part of Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: create canonical tables, add nullable FKs
* scripts: add extractTournamentIdentity helper for backfill
Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.
* scripts: add backfill orchestrator for canonical layer
Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.
Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
(recorded in report.errors) rather than overwriting
* scripts: add backfill CLI with dry-run default
Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).
* fix(backfill-cli): wrap runBackfill in DatabaseContext.run
The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backfill-cli): exit 0 on success so pg pool doesn't block
The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:13:18 -07:00
|
|
|
seasonParticipantResults: { findFirst },
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
immediately (loser → final placement, winner → provisional floor).
Called from `set-winner` and `set-round-winners` so points are awarded
as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
and `updateProbabilitiesAfterResult` once after the loop instead of per-match
(`skipSideEffects: true` per match).
## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
`getRoundConfig()` helper, eliminating three parallel `if/else` chains in
`processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
`isScoring ?? true` default, and AFL Semi-Finals template requirement.
## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
wins), so AFL QF losers who advance to Semi-Finals via double-chance are
correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
SF loss, and normal advancement not protecting a later loser.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
|
|
|
seasonSports: {
|
|
|
|
|
findMany: vi.fn().mockResolvedValue([]), // empty → standings loop exits immediately
|
|
|
|
|
},
|
2026-03-17 11:16:36 -07:00
|
|
|
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(null) },
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
immediately (loser → final placement, winner → provisional floor).
Called from `set-winner` and `set-round-winners` so points are awarded
as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
and `updateProbabilitiesAfterResult` once after the loop instead of per-match
(`skipSideEffects: true` per match).
## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
`getRoundConfig()` helper, eliminating three parallel `if/else` chains in
`processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
`isScoring ?? true` default, and AFL Semi-Finals template requirement.
## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
wins), so AFL QF losers who advance to Semi-Finals via double-chance are
correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
SF loss, and normal advancement not protecting a later loser.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
|
|
|
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
|
|
|
},
|
|
|
|
|
} as any,
|
|
|
|
|
insertedRows,
|
|
|
|
|
updatedRows,
|
|
|
|
|
findFirst,
|
|
|
|
|
insertValues,
|
|
|
|
|
updateSet,
|
|
|
|
|
updateWhere,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 09:40:53 -07:00
|
|
|
import { processMatchResult, isLoserNotifiable, processPlayoffEvent } from "../scoring-calculator";
|
2026-04-14 22:16:57 -07:00
|
|
|
import { doesLoserAdvance } from "../playoff-match";
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
immediately (loser → final placement, winner → provisional floor).
Called from `set-winner` and `set-round-winners` so points are awarded
as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
and `updateProbabilitiesAfterResult` once after the loop instead of per-match
(`skipSideEffects: true` per match).
## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
`getRoundConfig()` helper, eliminating three parallel `if/else` chains in
`processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
`isScoring ?? true` default, and AFL Semi-Finals template requirement.
## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
wins), so AFL QF losers who advance to Semi-Finals via double-chance are
correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
SF loss, and normal advancement not protecting a later loser.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
|
|
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
|
|
|
|
|
|
|
|
|
const BASE = {
|
|
|
|
|
winnerId: "winner-1",
|
|
|
|
|
loserId: "loser-1",
|
|
|
|
|
sportsSeasonId: "ss-1",
|
|
|
|
|
bracketTemplateId: null as string | null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe("processMatchResult", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Quarterfinals (scoring)", () => {
|
|
|
|
|
it("loser gets position 5 (final), winner gets position 3 (provisional)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
|
|
|
|
|
await processMatchResult(
|
|
|
|
|
{ ...BASE, round: "Quarterfinals", isScoring: true },
|
|
|
|
|
db
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(insertedRows).toHaveLength(2);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 5, isPartialScore: false });
|
|
|
|
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 3, isPartialScore: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("also works for Elite Eight / Divisional / Conference Semifinals", async () => {
|
|
|
|
|
for (const round of ["Elite Eight", "Divisional", "Conference Semifinals"]) {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult({ ...BASE, round, isScoring: true }, db);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ finalPosition: 5, isPartialScore: false });
|
|
|
|
|
expect(insertedRows[1]).toMatchObject({ finalPosition: 3, isPartialScore: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("recalculates standings (calls db.query.seasonSports.findMany)", async () => {
|
|
|
|
|
const { db } = makeDb();
|
|
|
|
|
await processMatchResult({ ...BASE, round: "Quarterfinals", isScoring: true }, db);
|
|
|
|
|
expect((db.query.seasonSports.findMany as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Semifinals (scoring)", () => {
|
|
|
|
|
it("loser gets position 3 (final), winner gets position 2 (provisional)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
|
|
|
|
|
await processMatchResult({ ...BASE, round: "Semifinals", isScoring: true }, db);
|
|
|
|
|
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 3, isPartialScore: false });
|
|
|
|
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 2, isPartialScore: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("also works for Final Four / Conference Finals / Conference Championship", async () => {
|
|
|
|
|
for (const round of ["Final Four", "Conference Finals", "Conference Championship"]) {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult({ ...BASE, round, isScoring: true }, db);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ finalPosition: 3, isPartialScore: false });
|
|
|
|
|
expect(insertedRows[1]).toMatchObject({ finalPosition: 2, isPartialScore: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Finals / Championship (scoring)", () => {
|
|
|
|
|
it("loser=2nd (final), winner=1st (final), no third row", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
|
|
|
|
|
await processMatchResult({ ...BASE, round: "Finals", isScoring: true }, db);
|
|
|
|
|
|
|
|
|
|
expect(insertedRows).toHaveLength(2);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 2, isPartialScore: false });
|
|
|
|
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 1, isPartialScore: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles all Finals aliases the same way", async () => {
|
|
|
|
|
for (const round of ["Championship", "Super Bowl", "NBA Finals", "Grand Final"]) {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult({ ...BASE, round, isScoring: true }, db);
|
|
|
|
|
expect(insertedRows).toHaveLength(2);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ finalPosition: 2, isPartialScore: false });
|
|
|
|
|
expect(insertedRows[1]).toMatchObject({ finalPosition: 1, isPartialScore: false });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Non-scoring round (isScoring=false)", () => {
|
2026-03-17 12:34:10 -07:00
|
|
|
it("loser eliminated (pos 0 final), winner gets provisional T5 floor (no template = legacy)", async () => {
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
immediately (loser → final placement, winner → provisional floor).
Called from `set-winner` and `set-round-winners` so points are awarded
as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
and `updateProbabilitiesAfterResult` once after the loop instead of per-match
(`skipSideEffects: true` per match).
## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
`getRoundConfig()` helper, eliminating three parallel `if/else` chains in
`processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
`isScoring ?? true` default, and AFL Semi-Finals template requirement.
## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
wins), so AFL QF losers who advance to Semi-Finals via double-chance are
correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
SF loss, and normal advancement not protecting a later loser.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
|
|
|
|
|
await processMatchResult({ ...BASE, round: "Round of 16", isScoring: false }, db);
|
|
|
|
|
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
|
|
|
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
|
|
|
|
|
});
|
2026-03-17 12:34:10 -07:00
|
|
|
|
|
|
|
|
describe("NCAA 68 — only Sweet Sixteen winners earn a floor", () => {
|
|
|
|
|
const NCAA = { bracketTemplateId: "ncaa_68" };
|
|
|
|
|
|
|
|
|
|
it("Round of 64: loser=0, winner gets NO score (not yet in scoring bracket)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult({ ...BASE, ...NCAA, round: "Round of 64", isScoring: false }, db);
|
|
|
|
|
expect(insertedRows).toHaveLength(1);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("Round of 32: loser=0, winner gets NO score (not yet in scoring bracket)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult({ ...BASE, ...NCAA, round: "Round of 32", isScoring: false }, db);
|
|
|
|
|
expect(insertedRows).toHaveLength(1);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("Sweet Sixteen: loser=0, winner gets provisional T5 floor (entering Elite Eight)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult({ ...BASE, ...NCAA, round: "Sweet Sixteen", isScoring: false }, db);
|
|
|
|
|
expect(insertedRows).toHaveLength(2);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
|
|
|
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("First Four: loser=0, winner gets NO score (feeds into Round of 64, not scoring)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult({ ...BASE, ...NCAA, round: "First Four", isScoring: false }, db);
|
|
|
|
|
expect(insertedRows).toHaveLength(1);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
|
|
|
|
|
expect(insertedRows).toHaveLength(2);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
|
|
|
|
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
|
|
|
|
|
});
|
2026-04-14 22:16:57 -07:00
|
|
|
|
|
|
|
|
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
|
|
|
|
|
it("loser is NOT eliminated when loserAdvances=true (advances to Round 2)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult(
|
|
|
|
|
{
|
|
|
|
|
...BASE,
|
|
|
|
|
bracketTemplateId: "nba_20",
|
|
|
|
|
round: "Play-In Round 1",
|
|
|
|
|
isScoring: false,
|
|
|
|
|
loserAdvances: true,
|
|
|
|
|
},
|
|
|
|
|
db
|
|
|
|
|
);
|
|
|
|
|
// Neither participant gets a result: loser skipped (still playing), and
|
|
|
|
|
// winner gets nothing because PIR1 doesn't feed directly into a scoring round.
|
|
|
|
|
expect(insertedRows).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("loser IS eliminated when loserAdvances=false (9v10 game)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
await processMatchResult(
|
|
|
|
|
{
|
|
|
|
|
...BASE,
|
|
|
|
|
bracketTemplateId: "nba_20",
|
|
|
|
|
round: "Play-In Round 1",
|
|
|
|
|
isScoring: false,
|
|
|
|
|
loserAdvances: false,
|
|
|
|
|
},
|
|
|
|
|
db
|
|
|
|
|
);
|
|
|
|
|
expect(insertedRows).toHaveLength(1);
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
|
|
|
|
});
|
|
|
|
|
});
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
immediately (loser → final placement, winner → provisional floor).
Called from `set-winner` and `set-round-winners` so points are awarded
as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
and `updateProbabilitiesAfterResult` once after the loop instead of per-match
(`skipSideEffects: true` per match).
## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
`getRoundConfig()` helper, eliminating three parallel `if/else` chains in
`processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
`isScoring ?? true` default, and AFL Semi-Finals template requirement.
## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
wins), so AFL QF losers who advance to Semi-Finals via double-chance are
correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
SF loss, and normal advancement not protecting a later loser.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Unrecognized scoring round", () => {
|
|
|
|
|
it("gives loser 0 pts and still completes without throwing", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
|
|
|
|
|
await processMatchResult({ ...BASE, round: "Mystery Round", isScoring: true }, db);
|
|
|
|
|
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ finalPosition: 0, isPartialScore: false });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("AFL rounds", () => {
|
|
|
|
|
it("Elimination Finals: loser exits at position 7 (final)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
|
|
|
|
|
await processMatchResult(
|
|
|
|
|
{ ...BASE, round: "Elimination Finals", isScoring: true, bracketTemplateId: "afl_10" },
|
|
|
|
|
db
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 7, isPartialScore: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("Qualifying Finals: loser is still alive (provisional floor 5)", async () => {
|
|
|
|
|
const { db, insertedRows } = makeDb();
|
|
|
|
|
|
|
|
|
|
await processMatchResult(
|
|
|
|
|
{ ...BASE, round: "Qualifying Finals", isScoring: true, bracketTemplateId: "afl_10" },
|
|
|
|
|
db
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Loser row is provisional (isPartialScore=true)
|
|
|
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 5, isPartialScore: true });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Idempotency: already-final row is not un-finalized", () => {
|
|
|
|
|
it("does not overwrite a finalized row with a partial score", async () => {
|
|
|
|
|
// findFirst returns a finalized row for the first participant (loser)
|
|
|
|
|
const existing = { id: "result-1", isPartialScore: false };
|
2026-03-21 09:44:05 -07:00
|
|
|
const { db, updatedRows } = makeDb(existing);
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## Partial bracket scoring
- `processMatchResult`: new exported function that scores a single match
immediately (loser → final placement, winner → provisional floor).
Called from `set-winner` and `set-round-winners` so points are awarded
as soon as a winner is set, before the full round is complete.
- `set-winner`: passes `eventName` to `processMatchResult`.
- `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues`
and `updateProbabilitiesAfterResult` once after the loop instead of per-match
(`skipSideEffects: true` per match).
## Code review fixes
- **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table +
`getRoundConfig()` helper, eliminating three parallel `if/else` chains in
`processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`.
AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory
comment about why the `bracketTemplateId` guard is required.
- **skipSideEffects** (C2): new param on `processMatchResult`; bracket server
uses it to batch standings/probability recalc in `set-round-winners`.
- **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`.
- **Round validation** (W2): `set-round-winners` now guards `match.round === round`
before processing each assignment.
- **Comments** (C3/S3/W3): added notes on non-scoring loser assumption,
`isScoring ?? true` default, and AFL Semi-Finals template requirement.
## PlayoffBracket eliminated-teams fix + tests
- Fixed `computeEliminatedByRound` to track participant *appearances* (not just
wins), so AFL QF losers who advance to Semi-Finals via double-chance are
correctly excluded from the QF eliminated list.
- Extracted the logic as an exported pure function for testability.
- Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser →
SF loss, and normal advancement not protecting a later loser.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
|
|
|
|
|
|
|
|
await processMatchResult(
|
|
|
|
|
{ ...BASE, round: "Quarterfinals", isScoring: true },
|
|
|
|
|
db
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The guard in upsertParticipantResult should skip the update when
|
|
|
|
|
// existing.isPartialScore=false and we're trying to write isPartialScore=true.
|
|
|
|
|
// The loser row would try to write isPartialScore=false (final) — that IS
|
|
|
|
|
// allowed (both false). The winner row tries isPartialScore=true — since
|
|
|
|
|
// there's no existing row for the winner (findFirst returns undefined on
|
|
|
|
|
// second call), it inserts.
|
|
|
|
|
//
|
|
|
|
|
// Key assertion: no update/insert with isPartialScore=true was applied to
|
|
|
|
|
// the existing finalized row.
|
|
|
|
|
const finalizedOverwrite = updatedRows.find(
|
|
|
|
|
(r: any) => r.isPartialScore === true
|
|
|
|
|
);
|
|
|
|
|
expect(finalizedOverwrite).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Probability recalculation", () => {
|
|
|
|
|
it("always calls updateProbabilitiesAfterResult with the sports season id", async () => {
|
|
|
|
|
const { db } = makeDb();
|
|
|
|
|
|
|
|
|
|
await processMatchResult({ ...BASE, round: "Quarterfinals", isScoring: true }, db);
|
|
|
|
|
|
|
|
|
|
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not throw even if probability update fails", async () => {
|
|
|
|
|
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
|
|
|
|
new Error("network error")
|
|
|
|
|
);
|
|
|
|
|
const { db } = makeDb();
|
|
|
|
|
|
|
|
|
|
// Should not throw — the error is caught and logged
|
|
|
|
|
await expect(
|
|
|
|
|
processMatchResult({ ...BASE, round: "Quarterfinals", isScoring: true }, db)
|
|
|
|
|
).resolves.not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-14 22:16:57 -07:00
|
|
|
|
|
|
|
|
describe("doesLoserAdvance", () => {
|
|
|
|
|
it("returns true for NBA Play-In Round 1 match 1 (E7v8)", () => {
|
|
|
|
|
expect(doesLoserAdvance("Play-In Round 1", 1, "nba_20")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns true for NBA Play-In Round 1 match 3 (W7v8)", () => {
|
|
|
|
|
expect(doesLoserAdvance("Play-In Round 1", 3, "nba_20")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false for NBA Play-In Round 1 match 2 (E9v10 — loser eliminated)", () => {
|
|
|
|
|
expect(doesLoserAdvance("Play-In Round 1", 2, "nba_20")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false for NBA Play-In Round 1 match 4 (W9v10 — loser eliminated)", () => {
|
|
|
|
|
expect(doesLoserAdvance("Play-In Round 1", 4, "nba_20")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false for NBA Play-In Round 2 (all losers eliminated)", () => {
|
|
|
|
|
expect(doesLoserAdvance("Play-In Round 2", 1, "nba_20")).toBe(false);
|
|
|
|
|
expect(doesLoserAdvance("Play-In Round 2", 2, "nba_20")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false for other templates", () => {
|
|
|
|
|
expect(doesLoserAdvance("Play-In Round 1", 1, "nfl_14")).toBe(false);
|
|
|
|
|
expect(doesLoserAdvance("Semifinals", 1, "fifa_48")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false for an empty templateId", () => {
|
|
|
|
|
expect(doesLoserAdvance("Play-In Round 1", 1, "")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("isLoserNotifiable", () => {
|
|
|
|
|
const LOSER = "loser-1";
|
|
|
|
|
const TEAM = "team-1";
|
|
|
|
|
|
|
|
|
|
it("returns true when the loser's team score changed", () => {
|
|
|
|
|
expect(
|
|
|
|
|
isLoserNotifiable(LOSER, TEAM, new Set([TEAM]), new Set())
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns true when the loser is finalized even with no score change (0-pt elimination)", () => {
|
|
|
|
|
expect(
|
|
|
|
|
isLoserNotifiable(LOSER, TEAM, new Set(), new Set([LOSER]))
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false when score did not change and loser is not finalized (advances)", () => {
|
|
|
|
|
expect(
|
|
|
|
|
isLoserNotifiable(LOSER, TEAM, new Set(), new Set())
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false when loserId is null", () => {
|
|
|
|
|
expect(
|
|
|
|
|
isLoserNotifiable(null, TEAM, new Set([TEAM]), new Set())
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false when loserId is undefined", () => {
|
|
|
|
|
expect(
|
|
|
|
|
isLoserNotifiable(undefined, undefined, new Set(), new Set())
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false when loser has no team (undrafted) and is not finalized", () => {
|
|
|
|
|
expect(
|
|
|
|
|
isLoserNotifiable(LOSER, undefined, new Set(), new Set())
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns true when loser has no team but is finalized (edge case: drafted mid-season?)", () => {
|
|
|
|
|
// finalizedLoserIds wins regardless of team presence
|
|
|
|
|
expect(
|
|
|
|
|
isLoserNotifiable(LOSER, undefined, new Set(), new Set([LOSER]))
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-15 09:40:53 -07:00
|
|
|
|
|
|
|
|
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
|
|
|
|
|
function makePlayInDb(matches: object[]) {
|
|
|
|
|
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];
|
|
|
|
|
const insertValues = vi.fn().mockImplementation((values: object) => {
|
|
|
|
|
insertedRows.push(values as typeof insertedRows[0]);
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
db: {
|
|
|
|
|
insert: vi.fn().mockReturnValue({ values: insertValues }),
|
|
|
|
|
update: vi.fn().mockReturnValue({
|
|
|
|
|
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
|
|
|
|
}),
|
|
|
|
|
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
|
|
|
|
query: {
|
|
|
|
|
scoringEvents: {
|
|
|
|
|
findFirst: vi.fn().mockResolvedValue({
|
|
|
|
|
id: "event-1",
|
|
|
|
|
playoffRound: "Play-In Round 1",
|
|
|
|
|
bracketTemplateId: "nba_20",
|
|
|
|
|
sportsSeasonId: "ss-1",
|
|
|
|
|
name: "NBA Playoffs",
|
|
|
|
|
sportsSeason: {
|
|
|
|
|
seasonSports: [{ seasonId: "season-1", season: {} }],
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
playoffMatches: {
|
|
|
|
|
findMany: vi.fn().mockResolvedValue(matches),
|
|
|
|
|
},
|
Canonical tournament layer: schema + backfill (1/2) (#365)
* refactor(schema): rename per-window tables to season_* prefix
Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor: rename participant.ts model file to season-participant.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(models): update model layer to use renamed schema exports
Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant
Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts
Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route layer to use renamed schema exports
- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file
Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts
Error count reduced from 499 to 453 (46 errors fixed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route files for schema rename
Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(services): update simulators and services for renamed schema
Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId
Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts
Typecheck errors reduced from 365 to 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: rename per-window tables to season_* prefix
* fix(tests): update mock query keys after participants table rename
Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.
Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(tests): update remaining mock paths and keys after schema rename
* fix(tests): final two mock stragglers after schema rename
- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: add post-phase1a baseline capture (temp, for diff verification)
* chore: capture pre-migration baselines
* chore: remove post-phase1a capture helper after verification
* schema: add canonical tournament & participant tables
Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(models): add canonical tournament, participant, result, surface-elo models
Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).
Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
createCanonicalParticipant, etc.) to avoid collision with existing
season-participant.ts exports
- All four models include comprehensive unit tests following the
audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints
Part of Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: create canonical tables, add nullable FKs
* scripts: add extractTournamentIdentity helper for backfill
Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.
* scripts: add backfill orchestrator for canonical layer
Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.
Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
(recorded in report.errors) rather than overwriting
* scripts: add backfill CLI with dry-run default
Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).
* fix(backfill-cli): wrap runBackfill in DatabaseContext.run
The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backfill-cli): exit 0 on success so pg pool doesn't block
The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:13:18 -07:00
|
|
|
seasonParticipants: {
|
2026-04-15 09:40:53 -07:00
|
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
|
|
|
},
|
Canonical tournament layer: schema + backfill (1/2) (#365)
* refactor(schema): rename per-window tables to season_* prefix
Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor: rename participant.ts model file to season-participant.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(models): update model layer to use renamed schema exports
Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant
Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts
Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route layer to use renamed schema exports
- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file
Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts
Error count reduced from 499 to 453 (46 errors fixed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route files for schema rename
Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(services): update simulators and services for renamed schema
Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId
Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts
Typecheck errors reduced from 365 to 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: rename per-window tables to season_* prefix
* fix(tests): update mock query keys after participants table rename
Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.
Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(tests): update remaining mock paths and keys after schema rename
* fix(tests): final two mock stragglers after schema rename
- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: add post-phase1a baseline capture (temp, for diff verification)
* chore: capture pre-migration baselines
* chore: remove post-phase1a capture helper after verification
* schema: add canonical tournament & participant tables
Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(models): add canonical tournament, participant, result, surface-elo models
Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).
Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
createCanonicalParticipant, etc.) to avoid collision with existing
season-participant.ts exports
- All four models include comprehensive unit tests following the
audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints
Part of Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: create canonical tables, add nullable FKs
* scripts: add extractTournamentIdentity helper for backfill
Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.
* scripts: add backfill orchestrator for canonical layer
Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.
Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
(recorded in report.errors) rather than overwriting
* scripts: add backfill CLI with dry-run default
Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).
* fix(backfill-cli): wrap runBackfill in DatabaseContext.run
The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backfill-cli): exit 0 on success so pg pool doesn't block
The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:13:18 -07:00
|
|
|
seasonParticipantResults: {
|
2026-04-15 09:40:53 -07:00
|
|
|
findFirst: vi.fn().mockResolvedValue(undefined),
|
|
|
|
|
},
|
|
|
|
|
seasons: {
|
|
|
|
|
findFirst: vi.fn().mockResolvedValue({
|
|
|
|
|
id: "season-1",
|
|
|
|
|
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50,
|
|
|
|
|
pointsFor4th: 40, pointsFor5th: 25, pointsFor6th: 25,
|
|
|
|
|
pointsFor7th: 15, pointsFor8th: 15,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
seasonSports: { findMany: vi.fn().mockResolvedValue([]) },
|
|
|
|
|
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
|
|
|
},
|
|
|
|
|
} as any,
|
|
|
|
|
insertedRows,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// All four Play-In Round 1 matches: East 7v8 (M1), East 9v10 (M2), West 7v8 (M3), West 9v10 (M4)
|
|
|
|
|
const PLAY_IN_MATCHES = [
|
|
|
|
|
{
|
|
|
|
|
id: "m1", round: "Play-In Round 1", matchNumber: 1, isScoring: false,
|
|
|
|
|
isComplete: true, scoringEventId: "event-1",
|
|
|
|
|
participant1Id: "winner-78e", participant2Id: "loser-78e",
|
|
|
|
|
winnerId: "winner-78e", loserId: "loser-78e",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "m2", round: "Play-In Round 1", matchNumber: 2, isScoring: false,
|
|
|
|
|
isComplete: true, scoringEventId: "event-1",
|
|
|
|
|
participant1Id: "winner-910e", participant2Id: "loser-910e",
|
|
|
|
|
winnerId: "winner-910e", loserId: "loser-910e",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "m3", round: "Play-In Round 1", matchNumber: 3, isScoring: false,
|
|
|
|
|
isComplete: true, scoringEventId: "event-1",
|
|
|
|
|
participant1Id: "winner-78w", participant2Id: "loser-78w",
|
|
|
|
|
winnerId: "winner-78w", loserId: "loser-78w",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "m4", round: "Play-In Round 1", matchNumber: 4, isScoring: false,
|
|
|
|
|
isComplete: true, scoringEventId: "event-1",
|
|
|
|
|
participant1Id: "winner-910w", participant2Id: "loser-910w",
|
|
|
|
|
winnerId: "winner-910w", loserId: "loser-910w",
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
it("eliminates 9v10 losers (East & West) but NOT 7v8 losers", async () => {
|
|
|
|
|
const { db, insertedRows } = makePlayInDb(PLAY_IN_MATCHES);
|
|
|
|
|
|
|
|
|
|
await processPlayoffEvent("event-1", db, { skipRecalculate: true });
|
|
|
|
|
|
|
|
|
|
const eliminatedIds = insertedRows
|
|
|
|
|
.filter((r) => r.finalPosition === 0 && !r.isPartialScore)
|
|
|
|
|
.map((r) => r.participantId);
|
|
|
|
|
|
|
|
|
|
// 7v8 losers advance to Play-In Round 2 — must not be eliminated yet
|
|
|
|
|
expect(eliminatedIds).not.toContain("loser-78e");
|
|
|
|
|
expect(eliminatedIds).not.toContain("loser-78w");
|
|
|
|
|
// 9v10 losers are out immediately
|
|
|
|
|
expect(eliminatedIds).toContain("loser-910e");
|
|
|
|
|
expect(eliminatedIds).toContain("loser-910w");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("eliminates ALL losers when bracketTemplateId is missing (falls back to safe default)", async () => {
|
|
|
|
|
// Guards against a future refactor that drops bracketTemplateId from the DB query.
|
|
|
|
|
// doesLoserAdvance receives "" → returns false for all matches → all losers eliminated.
|
|
|
|
|
const { db, insertedRows } = makePlayInDb(PLAY_IN_MATCHES);
|
|
|
|
|
(db.query.scoringEvents.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
|
|
|
id: "event-1",
|
|
|
|
|
playoffRound: "Play-In Round 1",
|
|
|
|
|
bracketTemplateId: null, // simulates missing templateId
|
|
|
|
|
sportsSeasonId: "ss-1",
|
|
|
|
|
name: "NBA Playoffs",
|
|
|
|
|
sportsSeason: {
|
|
|
|
|
seasonSports: [{ seasonId: "season-1", season: {} }],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await processPlayoffEvent("event-1", db, { skipRecalculate: true });
|
|
|
|
|
|
|
|
|
|
const eliminatedIds = insertedRows
|
|
|
|
|
.filter((r) => r.finalPosition === 0 && !r.isPartialScore)
|
|
|
|
|
.map((r) => r.participantId);
|
|
|
|
|
|
|
|
|
|
expect(eliminatedIds).toContain("loser-78e");
|
|
|
|
|
expect(eliminatedIds).toContain("loser-78w");
|
|
|
|
|
});
|
|
|
|
|
});
|