diff --git a/app/models/__tests__/process-match-result.test.ts b/app/models/__tests__/process-match-result.test.ts index 3afc8fc..1112225 100644 --- a/app/models/__tests__/process-match-result.test.ts +++ b/app/models/__tests__/process-match-result.test.ts @@ -59,7 +59,7 @@ function makeDb(existingResult?: { id: string; isPartialScore: boolean }) { }; } -import { processMatchResult, isLoserNotifiable } from "../scoring-calculator"; +import { processMatchResult, isLoserNotifiable, processPlayoffEvent } from "../scoring-calculator"; import { doesLoserAdvance } from "../playoff-match"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; @@ -398,3 +398,127 @@ describe("isLoserNotifiable", () => { ).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), + }, + 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).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"); + }); +}); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 11d39d0..70c5b7d 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -6,6 +6,7 @@ import { getSeasonResults } from "./participant-season-result"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord"; import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; +import { doesLoserAdvance } from "~/models/playoff-match"; import { getUserDisplayName } from "~/models/user"; import { createDailySnapshot } from "~/models/standings"; import { logger } from "~/lib/logger"; @@ -251,7 +252,8 @@ export async function processPlayoffEvent( // receive floor points — R64 and R32 winners are not yet guaranteed top-8. const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId); for (const match of matches) { - if (match.loserId) { + const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? ""); + if (match.loserId && !loserAdvances) { await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db); } if (match.winnerId && awardFloor) { @@ -1271,7 +1273,7 @@ export async function recalculateStandings( export async function recalculateAffectedLeagues( sportsSeasonId: string, providedDb?: ReturnType, - options?: { eventName?: string; eventId?: string; matchIds?: string[] } + options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean } ): Promise { const db = providedDb || database(); @@ -1386,7 +1388,7 @@ export async function recalculateAffectedLeagues( }); const webhookUrl = season?.league?.discordWebhookUrl; - if (!webhookUrl) continue; + if (!webhookUrl || options?.skipDiscord) continue; // Build clerkId → username map for all team owners in this season const ownerClerkIds = afterStandings diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index ab20924..763d614 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -614,11 +614,13 @@ export async function action({ request, params }: Route.ActionArgs) { sportsSeasonId: event.sportsSeasonId, bracketTemplateId: event.bracketTemplateId, skipSideEffects: true, + loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), } ); } - await recalculateAffectedLeagues(event.sportsSeasonId); + // skipDiscord: recalculate-floors is a data-correction tool, not a result announcement. + await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true }); return { success: `Recalculated floors from ${sortedMatches.length} completed match(es)` }; } catch (error) {