brackt/app/models/__tests__/ncaa-68-bracket.test.ts
Chris Parsons 0277c4d25e
Add per-event configurable region config for NCAA-style brackets (#149) (#149)
Replaces the hardcoded 2026 Men's region structure with a per-event
configurable system so the same ncaa_68 template works for Women's
March Madness (different region names and play-in placements each year).

- Add `bracket_region_config jsonb` column to `scoring_events`
- Export `ALL_16_SEEDS` and `BracketRegion`/`BracketPlayIn` interfaces
  from bracket-templates.ts so both server and client share the type
- Admin bracket entry form now shows a "Configure Regions" section:
  editable region names + add/remove play-in seed selectors (ShadCN
  Select) per region; slot map updates live as config changes
- Server action parses region config from form, validates total = 68,
  stores it on the event, and passes it to bracket generation
- `advanceFirstFourWinner` reads stored `bracketRegionConfig` from the
  event row first, falls back to template.regions for existing events
- Fix opponentSeed formula (was `seedNum <= 8 ? 17-n : n`, always `17-n`)
- Remove duplicate ALL_SEEDS inline definitions (now a shared constant)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 21:52:47 -07:00

387 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
import {
NCAA_68,
getScoringRoundType,
buildNCAA68SlotMap,
matchIndexForSeedSlot,
STANDARD_BRACKET_SEEDING,
} from "~/lib/bracket-templates";
/**
* NCAA 68 Bracket Unit Tests - Phase 2.8
*
* Tests the NCAA 68 tournament template structure and scoring logic:
* - 68 teams total (60 direct seeds + 8 in First Four)
* - First Four: 4 play-in games (8 teams)
* - Round of 64: 32 games
* - Only Elite Eight and beyond score fantasy points
*/
describe("NCAA 68 Bracket Template - Phase 2.8", () => {
describe("Template Structure", () => {
it("has correct total teams", () => {
expect(NCAA_68.totalTeams).toBe(68);
});
it("has all 7 rounds", () => {
expect(NCAA_68.rounds).toHaveLength(7);
expect(NCAA_68.rounds[0].name).toBe("First Four");
expect(NCAA_68.rounds[1].name).toBe("Round of 64");
expect(NCAA_68.rounds[2].name).toBe("Round of 32");
expect(NCAA_68.rounds[3].name).toBe("Sweet Sixteen");
expect(NCAA_68.rounds[4].name).toBe("Elite Eight");
expect(NCAA_68.rounds[5].name).toBe("Final Four");
expect(NCAA_68.rounds[6].name).toBe("Championship");
});
it("has correct match counts per round", () => {
expect(NCAA_68.rounds[0].matchCount).toBe(4); // First Four
expect(NCAA_68.rounds[1].matchCount).toBe(32); // Round of 64
expect(NCAA_68.rounds[2].matchCount).toBe(16); // Round of 32
expect(NCAA_68.rounds[3].matchCount).toBe(8); // Sweet Sixteen
expect(NCAA_68.rounds[4].matchCount).toBe(4); // Elite Eight (Quarterfinals)
expect(NCAA_68.rounds[5].matchCount).toBe(2); // Final Four (Semifinals)
expect(NCAA_68.rounds[6].matchCount).toBe(1); // Championship (Finals)
});
it("has correct round feeding structure", () => {
expect(NCAA_68.rounds[0].feedsInto).toBe("Round of 64");
expect(NCAA_68.rounds[1].feedsInto).toBe("Round of 32");
expect(NCAA_68.rounds[2].feedsInto).toBe("Sweet Sixteen");
expect(NCAA_68.rounds[3].feedsInto).toBe("Elite Eight");
expect(NCAA_68.rounds[4].feedsInto).toBe("Final Four");
expect(NCAA_68.rounds[5].feedsInto).toBe("Championship");
expect(NCAA_68.rounds[6].feedsInto).toBeNull(); // Championship has no next round
});
});
describe("Scoring Configuration", () => {
it("marks scoring to start at Elite Eight", () => {
expect(NCAA_68.scoringStartsAtRound).toBe("Elite Eight");
});
it("marks First Four as non-scoring", () => {
const firstFour = NCAA_68.rounds[0];
expect(firstFour.name).toBe("First Four");
expect(firstFour.isScoring).toBe(false);
});
it("marks Round of 64 as non-scoring", () => {
const roundOf64 = NCAA_68.rounds[1];
expect(roundOf64.name).toBe("Round of 64");
expect(roundOf64.isScoring).toBe(false);
});
it("marks Round of 32 as non-scoring", () => {
const roundOf32 = NCAA_68.rounds[2];
expect(roundOf32.name).toBe("Round of 32");
expect(roundOf32.isScoring).toBe(false);
});
it("marks Sweet Sixteen as non-scoring", () => {
const sweetSixteen = NCAA_68.rounds[3];
expect(sweetSixteen.name).toBe("Sweet Sixteen");
expect(sweetSixteen.isScoring).toBe(false);
});
it("marks Elite Eight as scoring (Quarterfinals)", () => {
const eliteEight = NCAA_68.rounds[4];
expect(eliteEight.name).toBe("Elite Eight");
expect(eliteEight.isScoring).toBe(true);
});
it("marks Final Four as scoring (Semifinals)", () => {
const finalFour = NCAA_68.rounds[5];
expect(finalFour.name).toBe("Final Four");
expect(finalFour.isScoring).toBe(true);
});
it("marks Championship as scoring (Finals)", () => {
const championship = NCAA_68.rounds[6];
expect(championship.name).toBe("Championship");
expect(championship.isScoring).toBe(true);
});
it("has exactly 3 scoring rounds (Elite Eight, Final Four, Championship)", () => {
const scoringRounds = NCAA_68.rounds.filter((r) => r.isScoring);
expect(scoringRounds).toHaveLength(3);
expect(scoringRounds[0].name).toBe("Elite Eight");
expect(scoringRounds[1].name).toBe("Final Four");
expect(scoringRounds[2].name).toBe("Championship");
});
it("has exactly 4 non-scoring rounds (FF, R64, R32, S16)", () => {
const nonScoringRounds = NCAA_68.rounds.filter((r) => !r.isScoring);
expect(nonScoringRounds).toHaveLength(4);
});
});
describe("Scoring Round Types", () => {
it("identifies Elite Eight as quarterfinals", () => {
const type = getScoringRoundType("Elite Eight", NCAA_68);
expect(type).toBe("quarterfinals");
});
it("identifies Final Four as semifinals", () => {
const type = getScoringRoundType("Final Four", NCAA_68);
expect(type).toBe("semifinals");
});
it("identifies Championship as finals", () => {
const type = getScoringRoundType("Championship", NCAA_68);
expect(type).toBe("finals");
});
it("returns null for non-scoring rounds", () => {
expect(getScoringRoundType("First Four", NCAA_68)).toBeNull();
expect(getScoringRoundType("Round of 64", NCAA_68)).toBeNull();
expect(getScoringRoundType("Round of 32", NCAA_68)).toBeNull();
expect(getScoringRoundType("Sweet Sixteen", NCAA_68)).toBeNull();
});
});
describe("Tournament Math", () => {
it("has correct number of total matches", () => {
// 4 + 32 + 16 + 8 + 4 + 2 + 1 = 67 total matches
const totalMatches = NCAA_68.rounds.reduce((sum, round) => sum + round.matchCount, 0);
expect(totalMatches).toBe(67);
});
it("eliminates 60 teams before Elite Eight (4+32+16+8)", () => {
const nonScoringRounds = NCAA_68.rounds.filter((r) => !r.isScoring);
const teamsEliminatedBeforeEliteEight = nonScoringRounds.reduce(
(sum, round) => sum + round.matchCount,
0
);
expect(teamsEliminatedBeforeEliteEight).toBe(60); // 4+32+16+8
});
it("has 8 teams in Elite Eight (top 8 scoring)", () => {
const eliteEightRound = NCAA_68.rounds.find((r) => r.name === "Elite Eight");
const teamsInEliteEight = eliteEightRound!.matchCount * 2;
expect(teamsInEliteEight).toBe(8);
});
it("verifies only 8 teams score fantasy points (Q18)", () => {
// Elite Eight has 4 matches = 8 teams
// These are the ONLY teams that can score fantasy points
const scoringRounds = NCAA_68.rounds.filter((r) => r.isScoring);
const eliteEightMatches = scoringRounds[0].matchCount;
const scoringTeamsCount = eliteEightMatches * 2;
expect(scoringTeamsCount).toBe(8);
});
});
describe("Phase 2.8 Requirements", () => {
it("meets requirement: 68 total teams", () => {
expect(NCAA_68.totalTeams).toBe(68);
});
it("meets requirement: 4 First Four play-in games", () => {
const firstFour = NCAA_68.rounds[0];
expect(firstFour.matchCount).toBe(4);
});
it("meets requirement: Only Elite Eight and beyond score points", () => {
const scoringRoundNames = NCAA_68.rounds
.filter((r) => r.isScoring)
.map((r) => r.name);
expect(scoringRoundNames).toEqual(["Elite Eight", "Final Four", "Championship"]);
});
it("meets requirement: Early elimination = 0 points (per Q20)", () => {
const firstFour = NCAA_68.rounds[0];
const roundOf64 = NCAA_68.rounds[1];
const roundOf32 = NCAA_68.rounds[2];
const sweetSixteen = NCAA_68.rounds[3];
expect(firstFour.isScoring).toBe(false); // 0 points
expect(roundOf64.isScoring).toBe(false); // 0 points
expect(roundOf32.isScoring).toBe(false); // 0 points
expect(sweetSixteen.isScoring).toBe(false); // 0 points
});
it("verifies total participants: 60 direct + 8 First Four", () => {
const firstFourTeams = 4 * 2; // 4 games × 2 teams = 8 teams
const directSeeds = 68 - firstFourTeams; // 60 teams directly seeded
expect(directSeeds).toBe(60);
expect(firstFourTeams).toBe(8);
expect(directSeeds + firstFourTeams).toBe(68);
});
});
describe("Region Config (2026)", () => {
it("has 4 named regions", () => {
expect(NCAA_68.regions).toBeDefined();
expect(NCAA_68.regions).toHaveLength(4);
});
it("has regions in order: East, South, West, Midwest", () => {
const names = NCAA_68.regions!.map((r) => r.name);
expect(names).toEqual(["East", "South", "West", "Midwest"]);
});
it("East has 16 direct seeds and no play-ins", () => {
const east = NCAA_68.regions![0];
expect(east.directSeeds).toHaveLength(16);
expect(east.playIns).toHaveLength(0);
});
it("South has 15 direct seeds and one 16-seed play-in", () => {
const south = NCAA_68.regions![1];
expect(south.directSeeds).toHaveLength(15);
expect(south.playIns).toHaveLength(1);
expect(south.playIns[0].seedSlot).toBe(16);
});
it("West has 15 direct seeds and one 11-seed play-in", () => {
const west = NCAA_68.regions![2];
expect(west.directSeeds).toHaveLength(15);
expect(west.playIns).toHaveLength(1);
expect(west.playIns[0].seedSlot).toBe(11);
});
it("Midwest has 14 direct seeds and two play-ins (11 and 16)", () => {
const midwest = NCAA_68.regions![3];
expect(midwest.directSeeds).toHaveLength(14);
expect(midwest.playIns).toHaveLength(2);
expect(midwest.playIns[0].seedSlot).toBe(11);
expect(midwest.playIns[1].seedSlot).toBe(16);
});
it("total direct seeds across all regions equals 60", () => {
const total = NCAA_68.regions!.reduce((sum, r) => sum + r.directSeeds.length, 0);
expect(total).toBe(60);
});
it("total play-in teams across all regions equals 8", () => {
const total = NCAA_68.regions!.reduce(
(sum, r) => sum + r.playIns.reduce((s, pi) => s + pi.teams, 0),
0
);
expect(total).toBe(8);
});
it("direct + play-in teams sum to 68", () => {
const direct = NCAA_68.regions!.reduce((sum, r) => sum + r.directSeeds.length, 0);
const playIn = NCAA_68.regions!.reduce(
(sum, r) => sum + r.playIns.reduce((s, pi) => s + pi.teams, 0),
0
);
expect(direct + playIn).toBe(68);
});
});
describe("buildNCAA68SlotMap", () => {
const slotMap = buildNCAA68SlotMap(NCAA_68.regions!);
it("East starts at index 0", () => {
expect(slotMap.directOffsets[0]).toBe(0);
});
it("South starts at index 16 (after East's 16 direct seeds)", () => {
expect(slotMap.directOffsets[1]).toBe(16);
});
it("West starts at index 31 (after East 16 + South 15)", () => {
expect(slotMap.directOffsets[2]).toBe(31);
});
it("Midwest starts at index 46 (after East 16 + South 15 + West 15)", () => {
expect(slotMap.directOffsets[3]).toBe(46);
});
it("totalDirect is 60", () => {
expect(slotMap.totalDirect).toBe(60);
});
it("totalPlayIn is 8", () => {
expect(slotMap.totalPlayIn).toBe(8);
});
it("has 4 play-in offsets", () => {
expect(slotMap.playInOffsets).toHaveLength(4);
});
it("FF1 (South 16-seed) starts at index 60", () => {
const ff1 = slotMap.playInOffsets[0];
expect(ff1.regionIndex).toBe(1); // South
expect(ff1.seedSlot).toBe(16);
expect(ff1.startIndex).toBe(60);
});
it("FF2 (West 11-seed) starts at index 62", () => {
const ff2 = slotMap.playInOffsets[1];
expect(ff2.regionIndex).toBe(2); // West
expect(ff2.seedSlot).toBe(11);
expect(ff2.startIndex).toBe(62);
});
it("FF3 (Midwest 11-seed) starts at index 64", () => {
const ff3 = slotMap.playInOffsets[2];
expect(ff3.regionIndex).toBe(3); // Midwest
expect(ff3.seedSlot).toBe(11);
expect(ff3.startIndex).toBe(64);
});
it("FF4 (Midwest 16-seed) starts at index 66", () => {
const ff4 = slotMap.playInOffsets[3];
expect(ff4.regionIndex).toBe(3); // Midwest
expect(ff4.seedSlot).toBe(16);
expect(ff4.startIndex).toBe(66);
});
});
describe("matchIndexForSeedSlot", () => {
it("seed 16 is at match index 0 (1v16)", () => {
expect(matchIndexForSeedSlot(16)).toBe(0);
});
it("seed 1 is at match index 0 (1v16)", () => {
expect(matchIndexForSeedSlot(1)).toBe(0);
});
it("seed 11 is at match index 4 (6v11)", () => {
expect(matchIndexForSeedSlot(11)).toBe(4);
});
it("seed 6 is at match index 4 (6v11)", () => {
expect(matchIndexForSeedSlot(6)).toBe(4);
});
it("all 8 standard seeding pairs are covered", () => {
for (const [hi, lo] of STANDARD_BRACKET_SEEDING) {
expect(() => matchIndexForSeedSlot(hi)).not.toThrow();
expect(() => matchIndexForSeedSlot(lo)).not.toThrow();
}
});
});
describe("First Four → Round of 64 match number derivation", () => {
const slotMap = buildNCAA68SlotMap(NCAA_68.regions!);
// r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1
it("FF1 (South region index 1, seed 16) → R64 match 9", () => {
const ff1 = slotMap.playInOffsets[0]; // South 16-seed
const r64 = ff1.regionIndex * 8 + matchIndexForSeedSlot(ff1.seedSlot) + 1;
expect(r64).toBe(9); // East occupies matches 1-8; South match index 0 → 9
});
it("FF2 (West region index 2, seed 11) → R64 match 21", () => {
const ff2 = slotMap.playInOffsets[1]; // West 11-seed
const r64 = ff2.regionIndex * 8 + matchIndexForSeedSlot(ff2.seedSlot) + 1;
expect(r64).toBe(21); // West starts at 17; match index 4 → 17+4=21
});
it("FF3 (Midwest region index 3, seed 11) → R64 match 29", () => {
const ff3 = slotMap.playInOffsets[2]; // Midwest 11-seed
const r64 = ff3.regionIndex * 8 + matchIndexForSeedSlot(ff3.seedSlot) + 1;
expect(r64).toBe(29); // Midwest starts at 25; match index 4 → 25+4=29
});
it("FF4 (Midwest region index 3, seed 16) → R64 match 25", () => {
const ff4 = slotMap.playInOffsets[3]; // Midwest 16-seed
const r64 = ff4.regionIndex * 8 + matchIndexForSeedSlot(ff4.seedSlot) + 1;
expect(r64).toBe(25); // Midwest starts at 25; match index 0 → 25
});
});
});