brackt/app/models/__tests__/process-match-result.test.ts

525 lines
21 KiB
TypeScript
Raw Normal View History

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: {
participantResults: { findFirst },
seasonSports: {
findMany: vi.fn().mockResolvedValue([]), // empty → standings loop exits immediately
},
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,
};
}
Fix NBA Play-In Round 1 loser advancement logic (#296) * Fix NBA Play-In 7/8 loser incorrectly marked as eliminated processPlayoffEvent (called by autoCompleteRoundIfDone when all round matches finish) was marking every non-scoring round loser as eliminated without checking doesLoserAdvance. This caused the 7v8 loser, who should advance to Play-In Round 2, to get finalPosition=0 as soon as the full round completed. Fixes: - processPlayoffEvent now calls doesLoserAdvance per match before writing a 0-pt elimination result, matching the guard already in processMatchResult - recalculate-floors handler now passes loserAdvances to processMatchResult so a full reprocess also respects the loser-advances rule - recalculateAffectedLeagues gains a skipDiscord option; recalculate-floors uses it so clicking the admin "Recalculate Floors" button corrects the bad data without re-announcing results on Discord - Add two tests confirming 7v8 loser is not eliminated and 9v10 loser is https://claude.ai/code/session_01QmvezscLYY38gN4GbvXZbA * Address code review feedback on Play-In loserAdvances fix - Use outer `round` variable instead of match.round in processPlayoffEvent (they're identical, but consistent with surrounding code) - Add comment at recalculate-floors call site explaining skipDiscord intent - Combine two redundant test cases into one covering all four assertions - Add West conference matches (M3/M4) to test fixture — East-only was insufficient given doesLoserAdvance checks matchNumber 1 & 3 - Add guard test: when bracketTemplateId is null all losers are eliminated, catching any future refactor that drops the field from the DB query https://claude.ai/code/session_01QmvezscLYY38gN4GbvXZbA --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 09:40:53 -07:00
import { processMatchResult, isLoserNotifiable, processPlayoffEvent } from "../scoring-calculator";
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)", () => {
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 });
});
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 });
});
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 };
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
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();
});
});
});
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);
});
});
Fix NBA Play-In Round 1 loser advancement logic (#296) * Fix NBA Play-In 7/8 loser incorrectly marked as eliminated processPlayoffEvent (called by autoCompleteRoundIfDone when all round matches finish) was marking every non-scoring round loser as eliminated without checking doesLoserAdvance. This caused the 7v8 loser, who should advance to Play-In Round 2, to get finalPosition=0 as soon as the full round completed. Fixes: - processPlayoffEvent now calls doesLoserAdvance per match before writing a 0-pt elimination result, matching the guard already in processMatchResult - recalculate-floors handler now passes loserAdvances to processMatchResult so a full reprocess also respects the loser-advances rule - recalculateAffectedLeagues gains a skipDiscord option; recalculate-floors uses it so clicking the admin "Recalculate Floors" button corrects the bad data without re-announcing results on Discord - Add two tests confirming 7v8 loser is not eliminated and 9v10 loser is https://claude.ai/code/session_01QmvezscLYY38gN4GbvXZbA * Address code review feedback on Play-In loserAdvances fix - Use outer `round` variable instead of match.round in processPlayoffEvent (they're identical, but consistent with surrounding code) - Add comment at recalculate-floors call site explaining skipDiscord intent - Combine two redundant test cases into one covering all four assertions - Add West conference matches (M3/M4) to test fixture — East-only was insufficient given doesLoserAdvance checks matchNumber 1 & 3 - Add guard test: when bracketTemplateId is null all losers are eliminated, catching any future refactor that drops the field from the DB query https://claude.ai/code/session_01QmvezscLYY38gN4GbvXZbA --------- Co-authored-by: Claude <noreply@anthropic.com>
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),
},
participants: {
findMany: vi.fn().mockResolvedValue([]),
},
participantResults: {
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");
});
});