brackt/app/models/__tests__/bracket-integration.test.ts

396 lines
14 KiB
TypeScript
Raw Normal View History

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
import { describe, it, expect } from "vitest";
import { SIMPLE_16, SIMPLE_32, NCAA_68 } from "~/lib/bracket-templates";
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("Bracket Integration Tests - Phase 2.7", () => {
describe("16-Team Bracket", () => {
it("has correct template structure", () => {
expect(SIMPLE_16.totalTeams).toBe(16);
expect(SIMPLE_16.rounds).toHaveLength(4);
expect(SIMPLE_16.scoringStartsAtRound).toBe("Quarterfinals");
// Round of 16 doesn't score
const round1 = SIMPLE_16.rounds[0];
expect(round1.name).toBe("Round of 16");
expect(round1.isScoring).toBe(false);
expect(round1.matchCount).toBe(8);
// Quarterfinals and beyond score
const round2 = SIMPLE_16.rounds[1];
expect(round2.name).toBe("Quarterfinals");
expect(round2.isScoring).toBe(true);
expect(round2.matchCount).toBe(4);
const round3 = SIMPLE_16.rounds[2];
expect(round3.name).toBe("Semifinals");
expect(round3.isScoring).toBe(true);
expect(round3.matchCount).toBe(2);
const round4 = SIMPLE_16.rounds[3];
expect(round4.name).toBe("Finals");
expect(round4.isScoring).toBe(true);
expect(round4.matchCount).toBe(1);
});
it("calculates correct points for each placement", () => {
// Champion (1st)
const champion = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(champion).toBe(100);
// Runner-up (2nd)
const runnerUp = calculateFantasyPoints(2, DEFAULT_SCORING);
expect(runnerUp).toBe(70);
// Semifinals losers (3rd/4th share)
const sfLosers = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
expect(sfLosers).toBe(45); // (50 + 40) / 2
// Quarterfinals losers (5th-8th share)
const qfLosers = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING);
expect(qfLosers).toBe(20); // (25 + 25 + 15 + 15) / 4
// Round of 16 losers (early elimination = 0 points per Q20)
const earlyElimination = calculateFantasyPoints(0, DEFAULT_SCORING);
expect(earlyElimination).toBe(0);
});
it("simulates complete 16-team bracket scoring", () => {
// Simulate a complete bracket
const results = {
// Finals
champion: 1, // 100 points
runnerUp: 2, // 70 points
// Semifinals losers (2 teams share 3rd/4th)
sfLoser1: 3, // 45 points (avg of 3rd and 4th)
sfLoser2: 3, // 45 points
// Quarterfinals losers (4 teams share 5th-8th)
qfLoser1: 5, // 20 points (avg of 5-8)
qfLoser2: 5, // 20 points
qfLoser3: 5, // 20 points
qfLoser4: 5, // 20 points
// Round of 16 losers (8 teams get 0 points)
r16Loser1: 0, // 0 points
r16Loser2: 0, // 0 points
r16Loser3: 0, // 0 points
r16Loser4: 0, // 0 points
r16Loser5: 0, // 0 points
r16Loser6: 0, // 0 points
r16Loser7: 0, // 0 points
r16Loser8: 0, // 0 points
};
// Verify champion
expect(calculateFantasyPoints(results.champion, DEFAULT_SCORING)).toBe(100);
// Verify runner-up
expect(calculateFantasyPoints(results.runnerUp, DEFAULT_SCORING)).toBe(70);
// Verify SF losers
expect(calculateFantasyPoints(results.sfLoser1, DEFAULT_SCORING)).toBe(50); // Gets 3rd place points
expect(calculateFantasyPoints(results.sfLoser2, DEFAULT_SCORING)).toBe(50);
// When averaged: (50 + 40) / 2 = 45
expect(calculateAveragedPoints([3, 4], DEFAULT_SCORING)).toBe(45);
// Verify QF losers
expect(calculateFantasyPoints(results.qfLoser1, DEFAULT_SCORING)).toBe(25); // Gets 5th place points
// When averaged: (25 + 25 + 15 + 15) / 4 = 20
expect(calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING)).toBe(20);
// Verify Round of 16 losers get 0 points
expect(calculateFantasyPoints(results.r16Loser1, DEFAULT_SCORING)).toBe(0);
expect(calculateFantasyPoints(results.r16Loser8, DEFAULT_SCORING)).toBe(0);
// Total points distributed (if one team drafted all participants)
// 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20 + 0*8 = 340
const totalPoints =
100 + 70 + 45 + 45 + 20 + 20 + 20 + 20 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0;
expect(totalPoints).toBe(340);
});
it("verifies only top 8 finishers score fantasy points", () => {
// Winners and top 8 get points
expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBeGreaterThan(0);
expect(calculateFantasyPoints(8, DEFAULT_SCORING)).toBeGreaterThan(0);
// Round of 16 losers (positions 9-16) get 0 points
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
expect(calculateFantasyPoints(9, DEFAULT_SCORING)).toBe(0);
expect(calculateFantasyPoints(16, DEFAULT_SCORING)).toBe(0);
});
});
describe("32-Team Bracket", () => {
it("has correct template structure", () => {
expect(SIMPLE_32.totalTeams).toBe(32);
expect(SIMPLE_32.rounds).toHaveLength(5);
expect(SIMPLE_32.scoringStartsAtRound).toBe("Quarterfinals");
// Round of 32 and Round of 16 don't score
const round1 = SIMPLE_32.rounds[0];
expect(round1.name).toBe("Round of 32");
expect(round1.isScoring).toBe(false);
expect(round1.matchCount).toBe(16);
const round2 = SIMPLE_32.rounds[1];
expect(round2.name).toBe("Round of 16");
expect(round2.isScoring).toBe(false);
expect(round2.matchCount).toBe(8);
// Quarterfinals and beyond score
const round3 = SIMPLE_32.rounds[2];
expect(round3.name).toBe("Quarterfinals");
expect(round3.isScoring).toBe(true);
expect(round3.matchCount).toBe(4);
const round4 = SIMPLE_32.rounds[3];
expect(round4.name).toBe("Semifinals");
expect(round4.isScoring).toBe(true);
const round5 = SIMPLE_32.rounds[4];
expect(round5.name).toBe("Finals");
expect(round5.isScoring).toBe(true);
});
it("calculates correct points for each placement", () => {
// Same as 16-team for top 8
expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBe(100);
expect(calculateFantasyPoints(2, DEFAULT_SCORING)).toBe(70);
expect(calculateAveragedPoints([3, 4], DEFAULT_SCORING)).toBe(45);
expect(calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING)).toBe(20);
// Early elimination (9th-32nd) = 0 points
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
});
it("simulates complete 32-team bracket scoring", () => {
// Top 8 finishers
const topEight = {
champion: 100,
runnerUp: 70,
sfLoser1: 45,
sfLoser2: 45,
qfLoser1: 20,
qfLoser2: 20,
qfLoser3: 20,
qfLoser4: 20,
};
// 24 early eliminations (Round of 32 + Round of 16)
const earlyEliminations = Array(24).fill(0);
// Total points
const topEightTotal = Object.values(topEight).reduce((sum, pts) => sum + pts, 0);
expect(topEightTotal).toBe(340);
const earlyEliminationTotal = earlyEliminations.reduce((sum, pts) => sum + pts, 0);
expect(earlyEliminationTotal).toBe(0);
const grandTotal = topEightTotal + earlyEliminationTotal;
expect(grandTotal).toBe(340);
});
it("verifies 24 teams eliminated early get 0 points", () => {
// Round of 32: 16 losers
// Round of 16: 8 losers
// Total: 24 teams get 0 points
// Only top 8 score
const scoringTeams = 8;
const nonScoringTeams = 32 - scoringTeams;
expect(nonScoringTeams).toBe(24);
// All non-scoring teams get 0 points
for (let i = 0; i < nonScoringTeams; i++) {
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
}
});
});
describe("NCAA March Madness (68 Teams) - Elite Eight Scoring", () => {
it("has correct template structure", () => {
expect(NCAA_68.totalTeams).toBe(68);
expect(NCAA_68.rounds).toHaveLength(7);
expect(NCAA_68.scoringStartsAtRound).toBe("Elite Eight");
// First Four through Sweet Sixteen don't score
expect(NCAA_68.rounds[0].name).toBe("First Four");
expect(NCAA_68.rounds[0].isScoring).toBe(false);
expect(NCAA_68.rounds[1].name).toBe("Round of 64");
expect(NCAA_68.rounds[1].isScoring).toBe(false);
expect(NCAA_68.rounds[2].name).toBe("Round of 32");
expect(NCAA_68.rounds[2].isScoring).toBe(false);
expect(NCAA_68.rounds[3].name).toBe("Sweet Sixteen");
expect(NCAA_68.rounds[3].isScoring).toBe(false);
// Elite Eight and beyond score
expect(NCAA_68.rounds[4].name).toBe("Elite Eight");
expect(NCAA_68.rounds[4].isScoring).toBe(true);
expect(NCAA_68.rounds[5].name).toBe("Final Four");
expect(NCAA_68.rounds[5].isScoring).toBe(true);
expect(NCAA_68.rounds[6].name).toBe("Championship");
expect(NCAA_68.rounds[6].isScoring).toBe(true);
});
it("verifies only Elite Eight and beyond score points (per Q18)", () => {
// Teams eliminated before Elite Eight get 0 points
// First Four: 4 losers
// Round of 64: 32 losers
// Round of 32: 16 losers
// Sweet Sixteen: 8 losers
// Total: 60 teams get 0 points
const nonScoringTeams = 4 + 32 + 16 + 8;
expect(nonScoringTeams).toBe(60);
// Only Elite Eight and beyond (8 teams) score
const scoringTeams = 8;
expect(scoringTeams + nonScoringTeams).toBe(68);
});
it("simulates complete March Madness bracket scoring", () => {
// Top 8 finishers (Elite Eight and beyond)
const topEight = {
champion: 100,
runnerUp: 70,
finalFourLoser1: 45,
finalFourLoser2: 45,
eliteEightLoser1: 20,
eliteEightLoser2: 20,
eliteEightLoser3: 20,
eliteEightLoser4: 20,
};
// 60 early eliminations
const earlyEliminations = Array(60).fill(0);
// Total points
const topEightTotal = Object.values(topEight).reduce((sum, pts) => sum + pts, 0);
expect(topEightTotal).toBe(340);
const earlyEliminationTotal = earlyEliminations.reduce((sum, pts) => sum + pts, 0);
expect(earlyEliminationTotal).toBe(0);
const grandTotal = topEightTotal + earlyEliminationTotal;
expect(grandTotal).toBe(340);
});
it("verifies Elite Eight losers share 5th-8th placement", () => {
// Elite Eight has 4 matches (Quarterfinals round)
// Losers share 5th-8th = (25 + 25 + 15 + 15) / 4 = 20 points each
const eliteEightLosers = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING);
expect(eliteEightLosers).toBe(20);
});
it("verifies Final Four losers share 3rd-4th placement", () => {
// Final Four has 2 matches (Semifinals round)
// Losers share 3rd-4th = (50 + 40) / 2 = 45 points each
const finalFourLosers = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
expect(finalFourLosers).toBe(45);
});
});
describe("Cross-Template Consistency", () => {
it("verifies all templates award same total points for top 8", () => {
// Regardless of bracket size, top 8 always get the same points
const expectedTotal = 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20;
// 16-team bracket
const team16Total = 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20;
expect(team16Total).toBe(expectedTotal);
// 32-team bracket
const team32Total = 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20;
expect(team32Total).toBe(expectedTotal);
// NCAA 68-team bracket
const ncaaTotal = 100 + 70 + 45 + 45 + 20 + 20 + 20 + 20;
expect(ncaaTotal).toBe(expectedTotal);
// All should be 340
expect(expectedTotal).toBe(340);
});
it("verifies scoring rounds are consistent across templates", () => {
// All templates should have exactly 3 scoring rounds
const simple16ScoringRounds = SIMPLE_16.rounds.filter((r) => r.isScoring);
expect(simple16ScoringRounds).toHaveLength(3); // QF, SF, Finals
const simple32ScoringRounds = SIMPLE_32.rounds.filter((r) => r.isScoring);
expect(simple32ScoringRounds).toHaveLength(3); // QF, SF, Finals
const ncaaScoringRounds = NCAA_68.rounds.filter((r) => r.isScoring);
expect(ncaaScoringRounds).toHaveLength(3); // Elite Eight, Final Four, Championship
});
it("verifies non-scoring rounds correctly identified", () => {
// 16-team: 1 non-scoring round
const simple16NonScoring = SIMPLE_16.rounds.filter((r) => !r.isScoring);
expect(simple16NonScoring).toHaveLength(1);
// 32-team: 2 non-scoring rounds
const simple32NonScoring = SIMPLE_32.rounds.filter((r) => !r.isScoring);
expect(simple32NonScoring).toHaveLength(2);
// NCAA 68: 4 non-scoring rounds
const ncaaNonScoring = NCAA_68.rounds.filter((r) => !r.isScoring);
expect(ncaaNonScoring).toHaveLength(4);
});
});
describe("Placement 0 for Early Eliminations (Q20)", () => {
it("placement 0 awards 0 fantasy points", () => {
const points = calculateFantasyPoints(0, DEFAULT_SCORING);
expect(points).toBe(0);
});
it("placement 0 works with custom scoring rules", () => {
const customScoring: ScoringRules = {
pointsFor1st: 500,
pointsFor2nd: 400,
pointsFor3rd: 300,
pointsFor4th: 200,
pointsFor5th: 100,
pointsFor6th: 100,
pointsFor7th: 50,
pointsFor8th: 50,
};
const points = calculateFantasyPoints(0, customScoring);
expect(points).toBe(0);
});
it("early eliminations do not affect averaged points", () => {
// If we incorrectly averaged in 0-point placements, we'd get wrong results
// Correct: avg(5,6,7,8) = (25+25+15+15)/4 = 20
// Wrong: avg(0,5,6,7,8) = (0+25+25+15+15)/5 = 16
const correct = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING);
expect(correct).toBe(20);
// Should NOT include 0 in averaging
const wrong = calculateAveragedPoints([0, 5, 6, 7, 8], DEFAULT_SCORING);
expect(wrong).not.toBe(20);
expect(wrong).toBe(16); // This would be incorrect scoring
});
});
});