diff --git a/app/models/__tests__/scoring-calculator-qp-notify.test.ts b/app/models/__tests__/scoring-calculator-qp-notify.test.ts new file mode 100644 index 0000000..c7c9f0b --- /dev/null +++ b/app/models/__tests__/scoring-calculator-qp-notify.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Spy on the QP Discord notification, and no-op the per-participant total recalc +// (its own eventResults query would otherwise interfere with the before/after +// snapshot counter below). Everything else in the scorer runs for real. +vi.mock("~/services/qualifying-points-discord.server", () => ({ + notifyQualifyingPointsUpdate: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("~/models/qualifying-points", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + recalculateParticipantQP: vi.fn().mockResolvedValue({ totalQP: 0, eventsScored: 0 }), + }; +}); + +import { processQualifyingEvent } from "../scoring-calculator"; +import { DEFAULT_QP_VALUES } from "../qualifying-points"; +import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server"; + +const EVENT_ID = "event-1"; +const SPORTS_SEASON_ID = "sports-season-1"; + +type ResultRow = { + id: string; + seasonParticipantId: string; + placement: number | null; + qualifyingPointsAwarded: string | null; + scoringEvent: { id: string; sportsSeasonId: string }; +}; + +/** + * Mock db for the non-bracket `processQualifyingEvent` path. `eventResults.findMany` + * returns `before` on its first call (the initial getEventResults snapshot) and + * `after` on its second (the post-reprocess re-query that drives change detection). + * Awarding updates go to a no-op update chain — `after` stands in for the DB state + * once the scorer has written its QP. + */ +function makeDb(before: ResultRow[], after: ResultRow[]) { + const results = [before, after]; + let findManyCall = 0; + return { + query: { + scoringEvents: { + findFirst: async () => ({ + id: EVENT_ID, + sportsSeasonId: SPORTS_SEASON_ID, + isQualifyingEvent: true, + bracketTemplateId: null, + sportsSeason: { majorsCompleted: 1 }, + }), + }, + eventResults: { + findMany: async () => results[Math.min(findManyCall++, 1)], + }, + qualifyingPointConfig: { + findMany: async () => + DEFAULT_QP_VALUES.map((qp) => ({ placement: qp.placement, points: qp.points.toString() })), + }, + }, + update: () => ({ set: () => ({ where: async () => [] }) }), + } as never; +} + +const scoringEvent = { id: EVENT_ID, sportsSeasonId: SPORTS_SEASON_ID }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("processQualifyingEvent — QP change notification", () => { + it("notifies only participants whose QP changed (first-time score: null → value)", async () => { + const db = makeDb( + [ + { id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: null, scoringEvent }, + { id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent }, + ], + [ + { id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent }, + { id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent }, + ], + ); + + await processQualifyingEvent(EVENT_ID, db); + + expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1); + const [ssId, evId, , filter] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0]; + expect(ssId).toBe(SPORTS_SEASON_ID); + expect(evId).toBe(EVENT_ID); + // p1 went null → 100 (changed); p2 stayed at 0 (unchanged). + expect([...(filter as Set)]).toEqual(["p1"]); + }); + + it("does not notify when a re-sync produces identical QP", async () => { + const rows: ResultRow[] = [ + { id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent }, + { id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent }, + ]; + const db = makeDb(rows, rows.map((r) => ({ ...r }))); + + await processQualifyingEvent(EVENT_ID, db); + + expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index ef12e60..3b16eb7 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -879,6 +879,16 @@ export async function processQualifyingEvent( // Check if this was already processed (for majorsCompleted counter) const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results); + // Snapshot awarded QP before reprocessing so the Discord notification below can + // announce only the participants whose QP actually changed. Without this, a + // sibling window re-scored on every fan-out sync (syncTournamentResults) would + // re-ping the full QP standings each run even when nothing changed. + const beforeQP = new Map( + results + .filter((r) => r.qualifyingPointsAwarded !== null) + .map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)]) + ); + if (event.bracketTemplateId) { // Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the // bracket/stage writers, which assign each placement its STRUCTURAL tie span. @@ -960,10 +970,27 @@ export async function processQualifyingEvent( `[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants` ); - try { - await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db); - } catch (error) { - logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error); + // Announce only participants whose awarded QP changed vs. the pre-reprocess + // snapshot (a differing value, or a first-time score: null → value). This keeps + // repeated fan-out syncs of an unchanged window from re-pinging the standings. + const afterRows = await db.query.eventResults.findMany({ + where: eq(schema.eventResults.scoringEventId, eventId), + }); + const changedParticipantIds = new Set(); + for (const r of afterRows) { + if (r.qualifyingPointsAwarded === null) continue; + const newQP = parseFloat(r.qualifyingPointsAwarded); + if (beforeQP.get(r.seasonParticipantId) !== newQP) { + changedParticipantIds.add(r.seasonParticipantId); + } + } + + if (changedParticipantIds.size > 0) { + try { + await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds); + } catch (error) { + logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error); + } } }