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
|
|
|
|
|
},
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
import { processMatchResult } from "../scoring-calculator";
|
|
|
|
|
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 });
|
|
|
|
|
});
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|