brackt/app/models/__tests__/qualifying-points.test.ts

419 lines
14 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from "vitest";
import {
calculateSplitQualifyingPoints,
DEFAULT_QP_VALUES,
diffChangedQualifyingPoints,
hasProcessedQualifyingPlacement,
} 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 },
];
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
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 },
];
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
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)
});
it("should not increment majorsCompleted when reprocessing", () => {
// First processing
let majorsCompleted = 0;
const wasAlreadyProcessed = false;
if (!wasAlreadyProcessed) {
majorsCompleted += 1;
}
expect(majorsCompleted).toBe(1);
// Reprocessing (wasAlreadyProcessed = true)
const reprocessing = true;
if (!reprocessing) {
majorsCompleted += 1;
}
expect(majorsCompleted).toBe(1); // Should still be 1, not 2
});
});
describe("Processed event detection", () => {
it("treats a placed zero-QP result as already processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: 20, qualifyingPointsAwarded: "0.00" },
])
).toBe(true);
});
it("does not treat filler zero-QP rows without placements as processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: null, qualifyingPointsAwarded: "0" },
])
).toBe(false);
});
it("does not treat unprocessed placed rows as processed", () => {
expect(
hasProcessedQualifyingPlacement([
{ placement: 15, qualifyingPointsAwarded: null },
])
).toBe(false);
});
});
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);
});
});
});