Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon: 1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/ mirror windows scored via the placement-group path in processQualifyingEvent, which took the tie span from the players present on that window's roster (a draftable subset) instead of the full field. A window holding fewer than the 8 tied R16 losers split the 9-16 points too few ways and over-awarded. The tie span is now derived from the canonical tournament_results (the whole field) for tournament-linked events, falling back to the live count only for standalone events. Every league now scores identically. 2. Discord notifications rounded QP with Math.round, turning 1.5 into "2". Both the "Points Awarded" and "QP Standings" values now use a 2-decimal formatter mirroring the web UI's formatQP, so Discord and the site agree. 3. The Discord "QP Standings" block ranked players only among that event's scorers, showing two R16 losers as T1 instead of T9. Rank is now computed over the full season field and passed through to the notifier. 4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored counter incremented on every fan-out sync with a guard that misfired. It is now derived on read (getMajorsCompleted = count of completed qualifying events), which is self-correcting; the increment/decrement writes are removed along with the now-unused hasProcessedQualifyingPlacement helper. Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so already-corrupted seasons are corrected (2 -> 1.5), and threads a skipNotifications option through processQualifyingEvent / syncTournamentResults so the backfill does not re-ping every league. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
178 lines
6.1 KiB
TypeScript
178 lines
6.1 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("../scoring-calculator", () => ({
|
|
recalculateAffectedLeagues: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../qualifying-points", async (importOriginal) => {
|
|
const actual = await importOriginal();
|
|
return {
|
|
...(actual as Record<string, unknown>),
|
|
recalculateParticipantQP: vi.fn(),
|
|
};
|
|
});
|
|
|
|
import { deleteScoringEvent } from "../scoring-event";
|
|
|
|
describe("deleteScoringEvent", () => {
|
|
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
|
|
// majorsCompleted is no longer a stored counter — it is computed via
|
|
// getMajorsCompleted from completed qualifying events. Deleting a qualifying
|
|
// event must therefore never issue a sportsSeasons.majorsCompleted update.
|
|
const setCalls: unknown[] = [];
|
|
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
|
const updateChain = {
|
|
set: vi.fn((values: unknown) => {
|
|
setCalls.push(values);
|
|
return { where: vi.fn().mockResolvedValue(undefined) };
|
|
}),
|
|
};
|
|
const db = {
|
|
query: {
|
|
scoringEvents: {
|
|
findFirst: vi.fn().mockResolvedValue({
|
|
id: "event-1",
|
|
sportsSeasonId: "sports-season-1",
|
|
eventType: "major_tournament",
|
|
isQualifyingEvent: true,
|
|
}),
|
|
},
|
|
eventResults: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
seasonParticipantId: "participant-1",
|
|
placement: 20,
|
|
qualifyingPointsAwarded: "0.00",
|
|
},
|
|
]),
|
|
},
|
|
},
|
|
transaction: vi.fn(async (callback) => callback(db)),
|
|
delete: vi.fn(() => deleteChain),
|
|
update: vi.fn(() => updateChain),
|
|
} as any;
|
|
|
|
await deleteScoringEvent("event-1", db);
|
|
|
|
expect(setCalls).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ majorsCompleted: expect.anything() }),
|
|
])
|
|
);
|
|
});
|
|
|
|
describe("shared tournament handling", () => {
|
|
// Builds a mock db for a non-qualifying schedule_event linked to a tournament,
|
|
// so the delete skips the QP/league branches and exercises only the
|
|
// shared-tournament bookkeeping.
|
|
function makeSharedDb({
|
|
deletedEvent,
|
|
remaining,
|
|
siblingTournamentId = "tournament-1",
|
|
}: {
|
|
deletedEvent: { tournamentId: string | null; isPrimary: boolean };
|
|
remaining: Array<{ id: string; isPrimary: boolean }>;
|
|
siblingTournamentId?: string;
|
|
}) {
|
|
const findFirst = vi
|
|
.fn()
|
|
// 1st call: the event being deleted
|
|
.mockResolvedValueOnce({
|
|
id: "event-1",
|
|
sportsSeasonId: "sports-season-1",
|
|
eventType: "schedule_event",
|
|
isQualifyingEvent: false,
|
|
...deletedEvent,
|
|
})
|
|
// subsequent calls: setPrimaryEvent looking up the promoted event
|
|
.mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
|
|
|
|
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
|
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
|
const db = {
|
|
query: {
|
|
scoringEvents: {
|
|
findFirst,
|
|
findMany: vi.fn().mockResolvedValue(remaining),
|
|
},
|
|
},
|
|
transaction: vi.fn(async (callback) => callback(db)),
|
|
delete: vi.fn(() => ({ where: deleteWhere })),
|
|
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
|
} as any;
|
|
return { db, deleteWhere };
|
|
}
|
|
|
|
it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
|
|
const { db } = makeSharedDb({
|
|
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
|
remaining: [{ id: "event-2", isPrimary: true }],
|
|
});
|
|
|
|
const result = await deleteScoringEvent("event-1", db);
|
|
|
|
expect(result.remainingWindows).toBe(1);
|
|
expect(result.promotedPrimaryId).toBeNull();
|
|
expect(result.deletedTournament).toBe(false);
|
|
});
|
|
|
|
it("does not promote a primary when a golf-style tournament (no primary) loses a window", async () => {
|
|
// Golf-style shared majors intentionally have no primary window — every
|
|
// linked event is isPrimary=false. Deleting one must not flip a sibling
|
|
// into a primary, which would change its scoring/guard behavior.
|
|
const { db } = makeSharedDb({
|
|
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
|
remaining: [
|
|
{ id: "event-2", isPrimary: false },
|
|
{ id: "event-3", isPrimary: false },
|
|
],
|
|
});
|
|
|
|
const result = await deleteScoringEvent("event-1", db);
|
|
|
|
expect(result.promotedPrimaryId).toBeNull();
|
|
expect(result.remainingWindows).toBe(2);
|
|
});
|
|
|
|
it("promotes the earliest remaining window when the primary window is deleted", async () => {
|
|
const { db } = makeSharedDb({
|
|
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
|
// findMany is ordered asc(createdAt); first is the earliest.
|
|
remaining: [
|
|
{ id: "event-2", isPrimary: false },
|
|
{ id: "event-3", isPrimary: false },
|
|
],
|
|
});
|
|
|
|
const result = await deleteScoringEvent("event-1", db);
|
|
|
|
expect(result.promotedPrimaryId).toBe("event-2");
|
|
expect(result.remainingWindows).toBe(2);
|
|
});
|
|
|
|
it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
|
|
const { db } = makeSharedDb({
|
|
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
|
remaining: [],
|
|
});
|
|
|
|
const result = await deleteScoringEvent("event-1", db);
|
|
|
|
expect(result.remainingWindows).toBe(0);
|
|
expect(result.deletedTournament).toBe(false);
|
|
});
|
|
|
|
it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
|
|
const { db } = makeSharedDb({
|
|
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
|
remaining: [],
|
|
});
|
|
|
|
const result = await deleteScoringEvent("event-1", db, {
|
|
deleteOrphanTournament: true,
|
|
});
|
|
|
|
expect(result.deletedTournament).toBe(true);
|
|
});
|
|
});
|
|
});
|