A review of this branch found four problems, all downstream of one decision: calling runSportsSeasonSimulation from inside the result path. That function does three jobs — recompute probabilities, recalculate standings, write the daily EV snapshot — and the result path wants only the first. 1. The Discord standings post was silently suppressed on every scored match, for all 13 bracket-aware sports. recalculateAffectedLeagues detects change by snapshotting teamStandings, recalculating, then diffing; changedTeamIds gates the notification. But processMatchResult runs updateProbabilitiesAfterResult first, which now reached the runner's own recalculateStandings. The new totals were therefore already written when the "before" snapshot was taken, the diff came back empty, and the post never fired. previousRank went the same way: recalculateStandings rolls it forward on every call, so the extra one erased rank movement. runSportsSeasonSimulation now takes skipStandingsRecalc / skipSnapshots and the probability updater passes both. The snapshot is skipped because it is a per-day series keyed by snapshotDate — writing it per match result just overwrites the day's row with intra-day values. 2. finalizeQualifyingPoints marks the season completed immediately before calling the updater, and the runner rejects a completed season outright. With the ICM fallback gone that failed every time, stranding anyone still in the unfinished set on permanently stale probabilities — reachable for cs2_major_qualifying_points, the one bracket-aware qualifying-points sport. A completed season is not this branch's case rather than a failure: every placement is final and the floor it protects can no longer be contradicted. shouldRerunSimulator now excludes it and it falls through to ICM as before. The genuine failure modes still leave probabilities alone rather than falling back to the path being replaced. 3. match-sync calls processMatchResult in a per-match loop with no skipSideEffects, so each synced match ran a full Monte Carlo plus EV rewrite, snapshot and standings recalc. processMatchResult gains skipProbabilities — mirroring the option processPlayoffEvent already takes, and narrower than skipSideEffects — which match-sync passes in the loop before refreshing once at the end. Per-match standings and Discord posts are unchanged. 4. A partially-seeded afl_10 bracket now throws from inside the result path. The throw is correct and stays; the concern was that it was silent, which the error surfacing in 2 covers. Two claims from the review did not hold up and were left alone: the batch bracket route already passes skipSideEffects per match and refreshes once after the loop, and autoCompleteRoundIfDone already passes skipProbabilities, so there is no double run per round completion. Tests: the runner honors both skip flags and still writes EVs; the updater asks for probabilities only; a completed season takes the ICM path without erroring; processMatchResult skips the refresh but still announces. The two behavioral ones were confirmed to fail against the previous behavior. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
555 lines
22 KiB
TypeScript
555 lines
22 KiB
TypeScript
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: {
|
|
seasonParticipantResults: { findFirst },
|
|
seasonSports: {
|
|
findMany: vi.fn().mockResolvedValue([]), // empty → standings loop exits immediately
|
|
},
|
|
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
},
|
|
} as any,
|
|
insertedRows,
|
|
updatedRows,
|
|
findFirst,
|
|
insertValues,
|
|
updateSet,
|
|
updateWhere,
|
|
};
|
|
}
|
|
|
|
import { processMatchResult, isLoserNotifiable, processPlayoffEvent } from "../scoring-calculator";
|
|
import { doesLoserAdvance } from "../playoff-match";
|
|
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 () => {
|
|
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 T7 floor (a Wildcard win only buys an Elimination Final)", async () => {
|
|
// The generic "entering a scoring round ⇒ top-8" default would bank 5 here,
|
|
// over-awarding the 5th-6th tier to a team whose next loss is the 7th-8th tier.
|
|
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: 7, isPartialScore: true });
|
|
});
|
|
|
|
it("AFL Qualifying Finals: winner gets T3 floor (bye to a Preliminary Final), loser holds their entry floor", async () => {
|
|
const { db, insertedRows } = makeDb();
|
|
await processMatchResult(
|
|
{ ...BASE, bracketTemplateId: "afl_10", round: "Qualifying Finals", isScoring: false, loserAdvances: true },
|
|
db
|
|
);
|
|
// Only the winner is written: the loser still has a Semi-Final, so their
|
|
// seeding-derived floor of 5 stands untouched.
|
|
expect(insertedRows).toHaveLength(1);
|
|
expect(insertedRows[0]).toMatchObject({ participantId: "winner-1", finalPosition: 3, 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 });
|
|
});
|
|
});
|
|
});
|
|
|
|
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 };
|
|
const { db, updatedRows } = makeDb(existing);
|
|
|
|
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("skips only the probability refresh when asked, still announcing", async () => {
|
|
// For a caller scoring several matches in a loop: the refresh is season-wide and, for a
|
|
// bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather
|
|
// than once per match. Standings and the announcement still happen per match.
|
|
const { db } = makeDb();
|
|
|
|
await processMatchResult(
|
|
{ ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true },
|
|
db
|
|
);
|
|
|
|
expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled();
|
|
// recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports.
|
|
expect(db.query.seasonSports.findMany).toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
|
|
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),
|
|
},
|
|
seasonParticipants: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
seasonParticipantResults: {
|
|
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");
|
|
});
|
|
});
|