brackt/app/models/__tests__/season-standings.test.ts

177 lines
6.6 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from "vitest";
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
describe("Season Standings (F1 Pattern)", () => {
describe("Basic Placement Scoring", () => {
it("should calculate points for 1st place finisher", () => {
const points = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(points).toBe(100);
});
it("should calculate points for 8th place finisher", () => {
const points = calculateFantasyPoints(8, DEFAULT_SCORING);
expect(points).toBe(15);
});
it("should return 0 points for finishers beyond 8th place", () => {
const points = calculateFantasyPoints(0, DEFAULT_SCORING);
expect(points).toBe(0);
});
});
describe("Tie Scenarios", () => {
it("should handle 2-way tie for 3rd place", () => {
// Two drivers tied for 3rd get average of 3rd and 4th place points
const points = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
// (50 + 40) / 2 = 45
expect(points).toBe(45);
});
it("should handle 3-way tie for 5th place", () => {
// Three drivers tied for 5th share 5th, 6th, and 7th place points
const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING);
Unify tie-split point math across every screen A participant tied for a scoring placement splits the combined points of the tied positions. Four code paths computed a pick's points, each re-implementing the same bracket/qualifying_points/default cascade, and two of them omitted the qualifying_points arm entirely. A golfer tied for 8th was therefore worth the full 15 points on the team page and draft board but the split 7.5 in the standings, so a team read 225 on one screen and 218 on another. Collapse the cascade into a single calculatePickPoints helper and route all six call sites through it, backed by one shared getSharedPlacementCounts loader replacing the two separate tie-count queries. Tie counts span every participant in the sports season, not just drafted ones, since an undrafted tie partner still halves the award. Also round split awards to the nearest whole point in calculateAveragedPoints. The /rules page states ties are "combined and split equally among them, rounded to the nearest whole point", and its own worked example rounds 18.33 down to 18, so this is nearest rather than ceiling. Season point values are integer columns, making this averaging the only source of fractional points; rounding here means the standings' 218 is now correct by construction rather than a display artifact, and per-pick values visibly sum to the team total. Existing assertions encoding the unrounded results are updated, and the rules page's two published examples are asserted directly so the code and the published rule cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
// (25 + 25 + 15) / 3 = 21.67 → 22 (nearest whole point)
expect(points).toBe(22);
});
it("should handle 4-way tie for 1st place", () => {
// Four drivers tied for 1st (unlikely but possible)
const points = calculateAveragedPoints([1, 2, 3, 4], DEFAULT_SCORING);
// (100 + 70 + 50 + 40) / 4 = 65
expect(points).toBe(65);
});
});
describe("Season Standings Simulation", () => {
it("should correctly score a complete F1-style season (no ties)", () => {
// Simulate final standings positions 1-10
const standings = [
{ driver: "Max Verstappen", position: 1, fantasyPoints: 100 },
{ driver: "Lewis Hamilton", position: 2, fantasyPoints: 70 },
{ driver: "Charles Leclerc", position: 3, fantasyPoints: 50 },
{ driver: "Lando Norris", position: 4, fantasyPoints: 40 },
{ driver: "Carlos Sainz", position: 5, fantasyPoints: 25 },
{ driver: "George Russell", position: 6, fantasyPoints: 25 },
{ driver: "Sergio Perez", position: 7, fantasyPoints: 15 },
{ driver: "Fernando Alonso", position: 8, fantasyPoints: 15 },
{ driver: "Oscar Piastri", position: 9, fantasyPoints: 0 }, // Beyond top 8
{ driver: "Pierre Gasly", position: 10, fantasyPoints: 0 }, // Beyond top 8
];
standings.forEach((entry) => {
const points = calculateFantasyPoints(entry.position, DEFAULT_SCORING);
expect(points).toBe(entry.fantasyPoints);
});
// Total points if one team drafted all top 8
const totalTop8 = 100 + 70 + 50 + 40 + 25 + 25 + 15 + 15;
expect(totalTop8).toBe(340);
});
it("should correctly score season with tied positions", () => {
// Simulate standings where 3rd, 4th, and 5th are tied
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 _standings = [
{ position: 1, expected: 100 },
{ position: 2, expected: 70 },
// Three tied for 3rd place - each gets placement 3
// When scoring, these will share 3rd, 4th, 5th
{ position: 3, base: 50 }, // Will be averaged: (50+40+25)/3
{ position: 3, base: 50 },
{ position: 3, base: 50 },
{ position: 6, expected: 25 },
{ position: 7, expected: 15 },
{ position: 8, expected: 15 },
];
// Verify first two positions
expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBe(100);
expect(calculateFantasyPoints(2, DEFAULT_SCORING)).toBe(70);
// Verify tied positions get averaged
const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING);
Unify tie-split point math across every screen A participant tied for a scoring placement splits the combined points of the tied positions. Four code paths computed a pick's points, each re-implementing the same bracket/qualifying_points/default cascade, and two of them omitted the qualifying_points arm entirely. A golfer tied for 8th was therefore worth the full 15 points on the team page and draft board but the split 7.5 in the standings, so a team read 225 on one screen and 218 on another. Collapse the cascade into a single calculatePickPoints helper and route all six call sites through it, backed by one shared getSharedPlacementCounts loader replacing the two separate tie-count queries. Tie counts span every participant in the sports season, not just drafted ones, since an undrafted tie partner still halves the award. Also round split awards to the nearest whole point in calculateAveragedPoints. The /rules page states ties are "combined and split equally among them, rounded to the nearest whole point", and its own worked example rounds 18.33 down to 18, so this is nearest rather than ceiling. Season point values are integer columns, making this averaging the only source of fractional points; rounding here means the standings' 218 is now correct by construction rather than a display artifact, and per-pick values visibly sum to the team total. Existing assertions encoding the unrounded results are updated, and the rules page's two published examples are asserted directly so the code and the published rule cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
expect(tiedPoints).toBe(38); // (50 + 40 + 25) / 3 = 38.33 → 38
// Verify remaining positions
expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25);
expect(calculateFantasyPoints(7, DEFAULT_SCORING)).toBe(15);
expect(calculateFantasyPoints(8, DEFAULT_SCORING)).toBe(15);
});
it("should handle ties that cross the 8th place boundary", () => {
// Simulate where 7th, 8th, and 9th are tied
// In practice, 7th and 8th would score, 9th would get 0
// First 7 and 8 get points
expect(calculateFantasyPoints(7, DEFAULT_SCORING)).toBe(15);
expect(calculateFantasyPoints(8, DEFAULT_SCORING)).toBe(15);
// If they share 7th and 8th place
const shared7and8 = calculateAveragedPoints([7, 8], DEFAULT_SCORING);
expect(shared7and8).toBe(15); // (15 + 15) / 2 = 15
// 9th gets 0
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
});
});
describe("Custom Scoring Rules", () => {
it("should work with custom F1 league scoring rules", () => {
const customScoring: ScoringRules = {
pointsFor1st: 150,
pointsFor2nd: 100,
pointsFor3rd: 75,
pointsFor4th: 60,
pointsFor5th: 50,
pointsFor6th: 40,
pointsFor7th: 30,
pointsFor8th: 20,
};
expect(calculateFantasyPoints(1, customScoring)).toBe(150);
expect(calculateFantasyPoints(8, customScoring)).toBe(20);
// Tied positions
const tied3rd = calculateAveragedPoints([3, 4], customScoring);
Unify tie-split point math across every screen A participant tied for a scoring placement splits the combined points of the tied positions. Four code paths computed a pick's points, each re-implementing the same bracket/qualifying_points/default cascade, and two of them omitted the qualifying_points arm entirely. A golfer tied for 8th was therefore worth the full 15 points on the team page and draft board but the split 7.5 in the standings, so a team read 225 on one screen and 218 on another. Collapse the cascade into a single calculatePickPoints helper and route all six call sites through it, backed by one shared getSharedPlacementCounts loader replacing the two separate tie-count queries. Tie counts span every participant in the sports season, not just drafted ones, since an undrafted tie partner still halves the award. Also round split awards to the nearest whole point in calculateAveragedPoints. The /rules page states ties are "combined and split equally among them, rounded to the nearest whole point", and its own worked example rounds 18.33 down to 18, so this is nearest rather than ceiling. Season point values are integer columns, making this averaging the only source of fractional points; rounding here means the standings' 218 is now correct by construction rather than a display artifact, and per-pick values visibly sum to the team total. Existing assertions encoding the unrounded results are updated, and the rules page's two published examples are asserted directly so the code and the published rule cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
expect(tied3rd).toBe(68); // (75 + 60) / 2 = 67.5 → 68 (halves round up)
});
});
describe("Edge Cases", () => {
it("should handle all 8 positions tied", () => {
// Extremely unlikely but mathematically possible
const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
Unify tie-split point math across every screen A participant tied for a scoring placement splits the combined points of the tied positions. Four code paths computed a pick's points, each re-implementing the same bracket/qualifying_points/default cascade, and two of them omitted the qualifying_points arm entirely. A golfer tied for 8th was therefore worth the full 15 points on the team page and draft board but the split 7.5 in the standings, so a team read 225 on one screen and 218 on another. Collapse the cascade into a single calculatePickPoints helper and route all six call sites through it, backed by one shared getSharedPlacementCounts loader replacing the two separate tie-count queries. Tie counts span every participant in the sports season, not just drafted ones, since an undrafted tie partner still halves the award. Also round split awards to the nearest whole point in calculateAveragedPoints. The /rules page states ties are "combined and split equally among them, rounded to the nearest whole point", and its own worked example rounds 18.33 down to 18, so this is nearest rather than ceiling. Season point values are integer columns, making this averaging the only source of fractional points; rounding here means the standings' 218 is now correct by construction rather than a display artifact, and per-pick values visibly sum to the team total. Existing assertions encoding the unrounded results are updated, and the rules page's two published examples are asserted directly so the code and the published rule cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5 → 43
expect(allTied).toBe(43);
});
it("should handle only top 4 finishing (others DNF/DQ)", () => {
// If only 4 participants finish the season
expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBe(100);
expect(calculateFantasyPoints(2, DEFAULT_SCORING)).toBe(70);
expect(calculateFantasyPoints(3, DEFAULT_SCORING)).toBe(50);
expect(calculateFantasyPoints(4, DEFAULT_SCORING)).toBe(40);
// Positions 5-8 would have no participants
// Any non-finishers get 0
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
});
it("should handle single participant season", () => {
// Only 1 participant completes the season
const points = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(points).toBe(100);
});
});
});