brackt/app/models/__tests__/qualifying-points.test.ts
Claude efec504c08
Fix qualifying points scoring, Discord rounding, and majors count
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
2026-07-03 20:40:47 +00:00

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);
});
});
});