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
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
calculateSplitQualifyingPoints,
|
|
DEFAULT_QP_VALUES,
|
|
diffChangedQualifyingPoints,
|
|
} from "../qualifying-points";
|
|
|
|
describe("diffChangedQualifyingPoints", () => {
|
|
it("flags a changed value, a first-time score (null → value), and ignores unchanged", () => {
|
|
const before = [
|
|
{ id: "A", qp: "10.00" },
|
|
{ id: "B", qp: "5.00" },
|
|
{ id: "C", qp: null },
|
|
];
|
|
const after = [
|
|
{ id: "A", qp: "10.00" }, // unchanged
|
|
{ id: "B", qp: "8.00" }, // changed
|
|
{ id: "C", qp: "3.00" }, // first-time score
|
|
];
|
|
|
|
expect([...diffChangedQualifyingPoints(before, after)].toSorted()).toEqual(["B", "C"]);
|
|
});
|
|
|
|
it("normalizes decimal formatting so 10 and 10.00 are equal", () => {
|
|
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10" }], [{ id: "A", qp: "10.00" }]);
|
|
expect(changed.size).toBe(0);
|
|
});
|
|
|
|
it("does not report participants absent from the after set", () => {
|
|
const changed = diffChangedQualifyingPoints(
|
|
[
|
|
{ id: "A", qp: "10.00" },
|
|
{ id: "D", qp: "2.00" },
|
|
],
|
|
[{ id: "A", qp: "10.00" }],
|
|
);
|
|
expect(changed.size).toBe(0);
|
|
});
|
|
|
|
it("treats a value → zero drop as a change", () => {
|
|
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10.00" }], [{ id: "A", qp: "0.00" }]);
|
|
expect([...changed]).toEqual(["A"]);
|
|
});
|
|
});
|
|
|
|
describe("Qualifying Points Configuration", () => {
|
|
describe("DEFAULT_QP_VALUES", () => {
|
|
it("should have 16 placement values", () => {
|
|
expect(DEFAULT_QP_VALUES).toHaveLength(16);
|
|
});
|
|
|
|
it("should have correct QP values for top placements", () => {
|
|
expect(DEFAULT_QP_VALUES[0]).toEqual({ placement: 1, points: 20 });
|
|
expect(DEFAULT_QP_VALUES[1]).toEqual({ placement: 2, points: 14 });
|
|
expect(DEFAULT_QP_VALUES[2]).toEqual({ placement: 3, points: 10 });
|
|
expect(DEFAULT_QP_VALUES[3]).toEqual({ placement: 4, points: 8 });
|
|
});
|
|
|
|
it("should have 5 QP for 5th and 6th place", () => {
|
|
expect(DEFAULT_QP_VALUES[4]).toEqual({ placement: 5, points: 5 });
|
|
expect(DEFAULT_QP_VALUES[5]).toEqual({ placement: 6, points: 5 });
|
|
});
|
|
|
|
it("should have 3 QP for 7th and 8th place", () => {
|
|
expect(DEFAULT_QP_VALUES[6]).toEqual({ placement: 7, points: 3 });
|
|
expect(DEFAULT_QP_VALUES[7]).toEqual({ placement: 8, points: 3 });
|
|
});
|
|
|
|
it("should have 2 QP for 9th-12th place", () => {
|
|
expect(DEFAULT_QP_VALUES[8]).toEqual({ placement: 9, points: 2 });
|
|
expect(DEFAULT_QP_VALUES[9]).toEqual({ placement: 10, points: 2 });
|
|
expect(DEFAULT_QP_VALUES[10]).toEqual({ placement: 11, points: 2 });
|
|
expect(DEFAULT_QP_VALUES[11]).toEqual({ placement: 12, points: 2 });
|
|
});
|
|
|
|
it("should have 1 QP for 13th-16th place", () => {
|
|
expect(DEFAULT_QP_VALUES[12]).toEqual({ placement: 13, points: 1 });
|
|
expect(DEFAULT_QP_VALUES[13]).toEqual({ placement: 14, points: 1 });
|
|
expect(DEFAULT_QP_VALUES[14]).toEqual({ placement: 15, points: 1 });
|
|
expect(DEFAULT_QP_VALUES[15]).toEqual({ placement: 16, points: 1 });
|
|
});
|
|
|
|
it("should be sorted by placement (ascending)", () => {
|
|
for (let i = 0; i < DEFAULT_QP_VALUES.length - 1; i++) {
|
|
expect(DEFAULT_QP_VALUES[i].placement).toBeLessThan(
|
|
DEFAULT_QP_VALUES[i + 1].placement
|
|
);
|
|
}
|
|
});
|
|
|
|
it("should have descending point values for higher placements", () => {
|
|
expect(DEFAULT_QP_VALUES[0].points).toBeGreaterThan(DEFAULT_QP_VALUES[1].points);
|
|
expect(DEFAULT_QP_VALUES[1].points).toBeGreaterThan(DEFAULT_QP_VALUES[2].points);
|
|
expect(DEFAULT_QP_VALUES[2].points).toBeGreaterThan(DEFAULT_QP_VALUES[3].points);
|
|
});
|
|
});
|
|
|
|
describe("QP Accumulation Logic", () => {
|
|
it("should accumulate QP across multiple events", () => {
|
|
// Simulating participant earning QP from 4 majors
|
|
const event1QP = 20; // 1st place
|
|
const event2QP = 14; // 2nd place
|
|
const event3QP = 10; // 3rd place
|
|
const event4QP = 8; // 4th place
|
|
|
|
const totalQP = event1QP + event2QP + event3QP + event4QP;
|
|
|
|
expect(totalQP).toBe(52);
|
|
});
|
|
|
|
it("should handle decimal QP values", () => {
|
|
const qp1 = 20.5;
|
|
const qp2 = 14.25;
|
|
|
|
const total = qp1 + qp2;
|
|
|
|
expect(total).toBe(34.75);
|
|
});
|
|
|
|
it("should handle zero QP awards", () => {
|
|
const event1QP = 20;
|
|
const event2QP = 0; // Finished beyond 16th
|
|
|
|
const total = event1QP + event2QP;
|
|
|
|
expect(total).toBe(20);
|
|
});
|
|
});
|
|
|
|
describe("QP Ranking Logic", () => {
|
|
it("should rank participants by total QP (highest first)", () => {
|
|
const standings = [
|
|
{ name: "Player A", qp: 44 },
|
|
{ name: "Player B", qp: 50 },
|
|
{ name: "Player C", qp: 38 },
|
|
];
|
|
|
|
const sorted = standings.toSorted((a, b) => b.qp - a.qp);
|
|
|
|
expect(sorted[0].name).toBe("Player B"); // 50 QP
|
|
expect(sorted[1].name).toBe("Player A"); // 44 QP
|
|
expect(sorted[2].name).toBe("Player C"); // 38 QP
|
|
});
|
|
|
|
it("should handle ties in QP totals", () => {
|
|
const standings = [
|
|
{ name: "Player A", qp: 30 },
|
|
{ name: "Player B", qp: 30 },
|
|
{ name: "Player C", qp: 20 },
|
|
];
|
|
|
|
const sorted = standings.toSorted((a, b) => b.qp - a.qp);
|
|
|
|
// Both players with 30 QP should be before Player C
|
|
expect(sorted[2].name).toBe("Player C");
|
|
expect(sorted[0].qp).toBe(30);
|
|
expect(sorted[1].qp).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe("QP to Fantasy Placement Conversion", () => {
|
|
it("should assign top 8 QP leaders to fantasy placements 1-8", () => {
|
|
const qpRankings = [50, 44, 38, 30, 25, 20, 15, 10, 5, 2];
|
|
|
|
const top8 = qpRankings.slice(0, 8);
|
|
|
|
expect(top8).toHaveLength(8);
|
|
expect(top8[0]).toBe(50); // 1st place
|
|
expect(top8[7]).toBe(10); // 8th place
|
|
});
|
|
|
|
it("should assign 0 fantasy points to participants beyond top 8", () => {
|
|
const qpRankings = [50, 44, 38, 30, 25, 20, 15, 10, 5, 2];
|
|
|
|
const beyond8 = qpRankings.slice(8);
|
|
|
|
expect(beyond8).toHaveLength(2);
|
|
// These would get 0 fantasy points
|
|
const fantasyPoints = beyond8.map(() => 0);
|
|
expect(fantasyPoints).toEqual([0, 0]);
|
|
});
|
|
|
|
it("should handle ties at the cutoff (8th place)", () => {
|
|
// Scenario: Two players tied for 8th place QP
|
|
const qpRankings = [
|
|
{ name: "P1", qp: 50 }, // 1st
|
|
{ name: "P2", qp: 44 }, // 2nd
|
|
{ name: "P3", qp: 38 }, // 3rd
|
|
{ name: "P4", qp: 30 }, // 4th
|
|
{ name: "P5", qp: 25 }, // 5th
|
|
{ name: "P6", qp: 20 }, // 6th
|
|
{ name: "P7", qp: 15 }, // 7th
|
|
{ name: "P8", qp: 10 }, // Tied for 8th
|
|
{ name: "P9", qp: 10 }, // Tied for 8th
|
|
];
|
|
|
|
// Both should get placement 8
|
|
const tied8th = qpRankings.filter(p => p.qp === 10);
|
|
expect(tied8th).toHaveLength(2);
|
|
|
|
// In implementation, both would share 8th place fantasy placement
|
|
const placement = 8;
|
|
expect(placement).toBe(8);
|
|
});
|
|
});
|
|
|
|
describe("Tie Handling During QP Awards", () => {
|
|
const pointsByPlacement = new Map(
|
|
DEFAULT_QP_VALUES.map(({ placement, points }) => [placement, points])
|
|
);
|
|
|
|
it("rounds a 4-way golf tie for 15th to the nearest hundredth", () => {
|
|
expect(calculateSplitQualifyingPoints(15, 4, pointsByPlacement)).toBe(0.5);
|
|
});
|
|
|
|
it("should correctly split QP among tied participants", () => {
|
|
// Scenario: 7 players tied for 14th place
|
|
// They occupy positions 14-20
|
|
// Positions 14, 15, 16 get 1 QP each = 3 total QP
|
|
// Positions 17-20 get 0 QP
|
|
// Total: 3 QP divided by 7 players = 3/7 = 0.428571... per player
|
|
|
|
const tieCount = 7;
|
|
const startPosition = 14;
|
|
|
|
// Calculate total QP for positions 14-20
|
|
const qpMap: Record<number, number> = {
|
|
14: 1,
|
|
15: 1,
|
|
16: 1,
|
|
17: 0,
|
|
18: 0,
|
|
19: 0,
|
|
20: 0,
|
|
};
|
|
|
|
let totalQP = 0;
|
|
for (let pos = startPosition; pos < startPosition + tieCount; pos++) {
|
|
totalQP += qpMap[pos] || 0;
|
|
}
|
|
|
|
const qpPerPlayer = totalQP / tieCount;
|
|
|
|
expect(totalQP).toBe(3);
|
|
expect(calculateSplitQualifyingPoints(14, 7, pointsByPlacement)).toBe(0.43);
|
|
expect(qpPerPlayer).toBeCloseTo(0.428571, 5);
|
|
});
|
|
|
|
it("should handle 2-way tie for 5th place", () => {
|
|
// 2 players tied for 5th occupy positions 5 and 6
|
|
// Position 5: 5 QP, Position 6: 5 QP
|
|
// Total: 10 QP / 2 players = 5 QP each
|
|
|
|
const tieCount = 2;
|
|
const startPosition = 5;
|
|
const qpMap: Record<number, number> = { 5: 5, 6: 5 };
|
|
|
|
let totalQP = 0;
|
|
for (let pos = startPosition; pos < startPosition + tieCount; pos++) {
|
|
totalQP += qpMap[pos];
|
|
}
|
|
|
|
const qpPerPlayer = totalQP / tieCount;
|
|
|
|
expect(totalQP).toBe(10);
|
|
expect(qpPerPlayer).toBe(5);
|
|
});
|
|
|
|
it("should handle 3-way tie for 1st place", () => {
|
|
// 3 players tied for 1st occupy positions 1, 2, 3
|
|
// Position 1: 20 QP, Position 2: 14 QP, Position 3: 10 QP
|
|
// Total: 44 QP / 3 players = 14.666... QP each
|
|
|
|
const tieCount = 3;
|
|
const startPosition = 1;
|
|
const qpMap: Record<number, number> = { 1: 20, 2: 14, 3: 10 };
|
|
|
|
let totalQP = 0;
|
|
for (let pos = startPosition; pos < startPosition + tieCount; pos++) {
|
|
totalQP += qpMap[pos];
|
|
}
|
|
|
|
const qpPerPlayer = totalQP / tieCount;
|
|
|
|
expect(totalQP).toBe(44);
|
|
expect(calculateSplitQualifyingPoints(1, 3, pointsByPlacement)).toBe(14.67);
|
|
expect(qpPerPlayer).toBeCloseTo(14.666667, 5);
|
|
});
|
|
});
|
|
|
|
describe("Reprocessing Events", () => {
|
|
it("should handle reprocessing by removing old QP before adding new QP", () => {
|
|
// Scenario: Admin enters results, processes QP, then realizes there's an error
|
|
// They fix the placements and reprocess
|
|
|
|
// Initial processing
|
|
const participant1InitialQP = 20; // 1st place
|
|
const participant2InitialQP = 14; // 2nd place
|
|
|
|
let totalQP1 = participant1InitialQP;
|
|
let totalQP2 = participant2InitialQP;
|
|
|
|
// Admin realizes participant 1 should have been 3rd, not 1st
|
|
// Reprocessing: subtract old QP, add new QP
|
|
totalQP1 = totalQP1 - participant1InitialQP + 10; // Remove 20, add 10 (3rd place)
|
|
totalQP2 = totalQP2 - participant2InitialQP + 20; // Remove 14, add 20 (moved to 1st)
|
|
|
|
expect(totalQP1).toBe(10); // Should have 10 QP (3rd place)
|
|
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
|
|
});
|
|
|
|
});
|
|
|
|
describe("Scoring Workflow", () => {
|
|
it("should process 4 majors and calculate final standings", () => {
|
|
// Simulating a full season with 4 majors
|
|
const participants = [
|
|
{ name: "Tiger Woods", qp: 0, events: 0 },
|
|
{ name: "Rory McIlroy", qp: 0, events: 0 },
|
|
{ name: "Scottie Scheffler", qp: 0, events: 0 },
|
|
];
|
|
|
|
// Event 1: The Masters
|
|
participants[0].qp += 20; // Tiger wins
|
|
participants[0].events++;
|
|
participants[1].qp += 14; // Rory 2nd
|
|
participants[1].events++;
|
|
participants[2].qp += 10; // Scottie 3rd
|
|
participants[2].events++;
|
|
|
|
// Event 2: PGA Championship
|
|
participants[1].qp += 20; // Rory wins
|
|
participants[1].events++;
|
|
participants[0].qp += 14; // Tiger 2nd
|
|
participants[0].events++;
|
|
participants[2].qp += 10; // Scottie 3rd
|
|
participants[2].events++;
|
|
|
|
// Event 3: U.S. Open
|
|
participants[2].qp += 20; // Scottie wins
|
|
participants[2].events++;
|
|
participants[0].qp += 14; // Tiger 2nd
|
|
participants[0].events++;
|
|
participants[1].qp += 10; // Rory 3rd
|
|
participants[1].events++;
|
|
|
|
// Event 4: The Open
|
|
participants[0].qp += 20; // Tiger wins
|
|
participants[0].events++;
|
|
participants[1].qp += 14; // Rory 2nd
|
|
participants[1].events++;
|
|
participants[2].qp += 10; // Scottie 3rd
|
|
participants[2].events++;
|
|
|
|
// Final standings
|
|
participants.sort((a, b) => b.qp - a.qp);
|
|
|
|
expect(participants[0].name).toBe("Tiger Woods");
|
|
expect(participants[0].qp).toBe(68); // 20+14+14+20
|
|
expect(participants[0].events).toBe(4);
|
|
|
|
expect(participants[1].name).toBe("Rory McIlroy");
|
|
expect(participants[1].qp).toBe(58); // 14+20+10+14
|
|
expect(participants[1].events).toBe(4);
|
|
|
|
expect(participants[2].name).toBe("Scottie Scheffler");
|
|
expect(participants[2].qp).toBe(50); // 10+10+20+10
|
|
expect(participants[2].events).toBe(4);
|
|
});
|
|
});
|
|
});
|