- Introduced `generateStandardSeeding()` function to create proper tournament matchups (e.g., 1v16, 8v9). - Added comprehensive tests for 4, 8, 16, and 32-team brackets to ensure correct seeding and balance. - Updated testing instructions to reflect new features and improvements in bracket generation. - Fixed issues with sequential seeding and ensured all matchups sum to n+1 for balance. - Implemented batch winner selection and duplicate participant prevention for improved user experience. - Enhanced fantasy points display on event pages for better visibility of participant results.
395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach } 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
|
|
});
|
|
});
|
|
});
|