claude/discord-qp-notifications-75wa7k #122
2 changed files with 137 additions and 4 deletions
106
app/models/__tests__/scoring-calculator-qp-notify.test.ts
Normal file
106
app/models/__tests__/scoring-calculator-qp-notify.test.ts
Normal file
|
|
@ -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<typeof import("~/models/qualifying-points")>();
|
||||||
|
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<string>)]).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -879,6 +879,16 @@ export async function processQualifyingEvent(
|
||||||
// Check if this was already processed (for majorsCompleted counter)
|
// Check if this was already processed (for majorsCompleted counter)
|
||||||
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
|
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<string, number>(
|
||||||
|
results
|
||||||
|
.filter((r) => r.qualifyingPointsAwarded !== null)
|
||||||
|
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
|
||||||
|
);
|
||||||
|
|
||||||
if (event.bracketTemplateId) {
|
if (event.bracketTemplateId) {
|
||||||
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
|
// 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.
|
// 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`
|
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
// Announce only participants whose awarded QP changed vs. the pre-reprocess
|
||||||
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db);
|
// snapshot (a differing value, or a first-time score: null → value). This keeps
|
||||||
} catch (error) {
|
// repeated fan-out syncs of an unchanged window from re-pinging the standings.
|
||||||
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
|
const afterRows = await db.query.eventResults.findMany({
|
||||||
|
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||||
|
});
|
||||||
|
const changedParticipantIds = new Set<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue