A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of 64) got no Discord announcement when the bracket was scored by sync. The first three Grand Slam rounds are non-scoring, so an early-round loser earns 0 QP, gets no event_results row, and is dropped from the "Qualifying Points Update" notification — the only announcement the tennis sync emits mid-tournament. Detect players knocked out on each sync and surface them: - populateBracketFromDraw now returns newlyDecidedLoserIds: losers of matches that transition to complete on this run. Idempotent across re-syncs since playoff_matches persist, so a knockout is announced once. - syncTennisDraw threads that set into notifyQualifyingPointsUpdate and fires the notification even when no QP changed. - notifyQualifyingPointsUpdate builds an eliminated list scoped to players drafted in the league, deduped against QP earners (so a Round-of-16 loser who scores isn't listed twice), tagging the drafting manager. - sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and pings those managers; the QP Standings block is skipped when a sync only reports knockouts. Tests cover the new detection, dedup, manager tagging, knockout-only notifications, and rendering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
113 lines
3.6 KiB
TypeScript
113 lines
3.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// populateBracketFromDraw upserts a full draw into playoff_matches and reports
|
|
// which losers' matches *transitioned to complete on this run* — the signal used
|
|
// to announce knockouts once, idempotently across re-syncs.
|
|
|
|
const existingRows: Array<Record<string, unknown>> = [];
|
|
|
|
const mockDb = {
|
|
query: {
|
|
playoffMatches: {
|
|
findMany: vi.fn(async () => existingRows),
|
|
},
|
|
},
|
|
update: vi.fn(() => ({
|
|
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
|
})),
|
|
insert: vi.fn(() => ({
|
|
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([]) })),
|
|
})),
|
|
};
|
|
|
|
vi.mock("~/database/context", () => ({ database: () => mockDb }));
|
|
|
|
import { populateBracketFromDraw, type ResolvedDrawMatch } from "../playoff-match";
|
|
|
|
const EVENT_ID = "ev-1";
|
|
|
|
function match(overrides: Partial<ResolvedDrawMatch> = {}): ResolvedDrawMatch {
|
|
return {
|
|
externalMatchId: "m-1",
|
|
round: "Round of 64",
|
|
matchNumber: 1,
|
|
participant1Id: "winner",
|
|
participant2Id: "loser",
|
|
winnerId: "winner",
|
|
loserId: "loser",
|
|
isScoring: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
existingRows.length = 0;
|
|
});
|
|
|
|
describe("populateBracketFromDraw newlyDecidedLoserIds", () => {
|
|
it("reports the loser of a brand-new completed match", async () => {
|
|
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
|
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
|
]);
|
|
|
|
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
|
});
|
|
|
|
it("reports the loser of an existing match that just reached completion", async () => {
|
|
existingRows.push({
|
|
id: "row-1",
|
|
externalMatchId: "m-1",
|
|
isComplete: false,
|
|
loserId: null,
|
|
winnerId: null,
|
|
});
|
|
|
|
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
|
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
|
]);
|
|
|
|
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
|
});
|
|
|
|
it("reports nothing on an idempotent re-sync of an already-complete match", async () => {
|
|
existingRows.push({
|
|
id: "row-1",
|
|
externalMatchId: "m-1",
|
|
isComplete: true,
|
|
loserId: "mensik",
|
|
winnerId: "winner",
|
|
});
|
|
|
|
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
|
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
|
]);
|
|
|
|
expect(newlyDecidedLoserIds).toEqual([]);
|
|
});
|
|
|
|
it("ignores incomplete matches (no winner/loser yet)", async () => {
|
|
const { newlyDecidedLoserIds, completed } = await populateBracketFromDraw(EVENT_ID, [
|
|
match({ externalMatchId: "m-2", winnerId: null, loserId: null }),
|
|
]);
|
|
|
|
expect(newlyDecidedLoserIds).toEqual([]);
|
|
expect(completed).toBe(0);
|
|
});
|
|
|
|
it("collects only the newly-decided losers in a mixed batch", async () => {
|
|
existingRows.push(
|
|
{ id: "row-1", externalMatchId: "m-1", isComplete: true, loserId: "old", winnerId: "w1" },
|
|
{ id: "row-2", externalMatchId: "m-2", isComplete: false, loserId: null, winnerId: null },
|
|
);
|
|
|
|
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
|
match({ externalMatchId: "m-1", loserId: "old" }), // already complete → skip
|
|
match({ externalMatchId: "m-2", loserId: "freshly-out" }), // transitioned → include
|
|
match({ externalMatchId: "m-3", loserId: "brand-new-out" }), // new complete → include
|
|
match({ externalMatchId: "m-4", winnerId: null, loserId: null }), // incomplete → skip
|
|
]);
|
|
|
|
expect(newlyDecidedLoserIds.toSorted()).toEqual(["brand-new-out", "freshly-out"]);
|
|
});
|
|
});
|