/** * Bracket layout tests. * * The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS * bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level * with the game that feeds it, so these tests assert column membership, top-to-bottom * order, and vertical alignment — not just that a layout was produced. */ import { describe, it, expect } from "vitest"; import { LLWS_20, SIMPLE_16, NFL_14, getBracketTemplate, type BracketTemplate, type ConferenceGroup, } from "~/lib/bracket-templates"; import { buildFeederMap, computeGroupLayout, describeSlotSource, matchKey, type SlotSource, } from "~/lib/bracket-layout"; import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket"; interface TestMatch { round: string; matchNumber: number; } /** Every match a template defines, as the renderer would receive them. */ function allMatches(template: BracketTemplate): Map { const byRound = new Map(); for (const round of template.rounds) { byRound.set( round.name, Array.from({ length: round.matchCount }, (_, i) => ({ round: round.name, matchNumber: i + 1, })) ); } return byRound; } /** The matches of one phase group, filtered the way TabbedBracketLayout filters them. */ function groupMatches(group: ConferenceGroup): Map { const byRound = new Map(); for (const [round, nums] of Object.entries(group.roundMatchNumbers)) { byRound.set( round, nums.map((matchNumber) => ({ round, matchNumber })) ); } return byRound; } function findGroup(name: string): ConferenceGroup { for (const phase of LLWS_20.phases ?? []) { for (const group of phase.groups ?? []) { if (group.name === name) return group; } } throw new Error(`No LLWS group named ${name}`); } /** Lay out one LLWS group and describe it in PDF game numbers. */ function layOutLLWSGroup(name: string) { const group = findGroup(name); const byRound = groupMatches(group); const roundOrder = LLWS_20.rounds.map((r) => r.name); const rounds = roundOrder.filter((r) => byRound.has(r)); const layout = computeGroupLayout(rounds, byRound, buildFeederMap(LLWS_20), roundOrder); const game = (m: TestMatch) => { const n = MATCH_TO_GAME.get(`${m.round}#${m.matchNumber}`); if (n === undefined) throw new Error(`No PDF game for ${m.round} #${m.matchNumber}`); return n; }; return { layout, labels: layout.columns.map((c) => c.label), /** Column contents, top to bottom, as PDF game numbers. */ columns: layout.columns.map((c) => c.matches.map((m) => game(m.match))), /** Vertical centre of a game's card, in leaf-row units. */ centerOf(gameNumber: number): number { const target = GAME_TO_MATCH[gameNumber]; for (const column of layout.columns) { for (const { match, center } of column.matches) { if (match.round === target.round && match.matchNumber === target.matchNumber) { return center; } } } throw new Error(`G${gameNumber} is not in this group`); }, }; } describe("computeGroupLayout — LLWS winners brackets", () => { // The International side is the one in the reported screenshot. Under the old index // math, G5 and G7 were stranded in the first column: they skip Winners Round 2 and go // straight to the semifinals, so nothing in column two lined up with them. it("puts the International winners bracket in the printed bracket's columns", () => { const { columns } = layOutLLWSGroup("International Winner's Bracket"); expect(columns).toEqual([ [1, 3], [5, 9, 11, 7], [18, 20], [29], ]); }); it("mirrors that layout on the U.S. side", () => { const { columns } = layOutLLWSGroup("U.S. Winner's Bracket"); expect(columns).toEqual([ [2, 4], [6, 10, 12, 8], [17, 19], [30], ]); }); it("names a mixed column for the latest round it holds", () => { // Column two holds two Opening Round games (G5, G7) alongside Winners Round 2. const { labels } = layOutLLWSGroup("International Winner's Bracket"); expect(labels).toEqual([ "Opening Round", "Winners Round 2", "Winners Semifinals", "Winners Final", ]); }); it("levels each card with the game that feeds it", () => { const { centerOf } = layOutLLWSGroup("International Winner's Bracket"); // G1's winner fills a slot of G9, so the two sit at the same height. expect(centerOf(1)).toBe(centerOf(9)); expect(centerOf(3)).toBe(centerOf(11)); // G18 = W5 v W9, so it sits midway between them. expect(centerOf(18)).toBe((centerOf(5) + centerOf(9)) / 2); expect(centerOf(20)).toBe((centerOf(11) + centerOf(7)) / 2); expect(centerOf(29)).toBe((centerOf(18) + centerOf(20)) / 2); }); it("draws an edge for every in-group feed, played or not", () => { const { layout } = layOutLLWSGroup("International Winner's Bracket"); // G9←G1, G11←G3, G18←{G5,G9}, G20←{G11,G7}, G29←{G18,G20}: 8 in-group edges. expect(layout.edges).toHaveLength(8); // Every edge crosses exactly one gutter, which is what makes them drawable. for (const edge of layout.edges) { expect(edge.fromColumn).toBeGreaterThanOrEqual(0); expect(edge.fromColumn).toBeLessThan(layout.columns.length - 1); } }); }); describe("computeGroupLayout — LLWS elimination brackets", () => { it("orders Elimination Round 3 the way the printed bracket does", () => { // G31 = W27 v W25, so the later game is printed on top — the reverse of match // number order, which is how the old index-based sort got it wrong. const { columns } = layOutLLWSGroup("International Elimination Bracket"); expect(columns).toEqual([ [13, 15], [21, 23], [27, 25], [31], [33], ]); }); it("orders the U.S. elimination bracket the same way", () => { const { columns } = layOutLLWSGroup("U.S. Elimination Bracket"); expect(columns).toEqual([ [14, 16], [22, 24], [28, 26], [32], [34], ]); }); it("ignores feeds arriving from the winners bracket", () => { // G21 = L9 v W13. L9 is in the winners bracket group, so only W13 is an edge here. const { layout, centerOf } = layOutLLWSGroup("International Elimination Bracket"); expect(centerOf(21)).toBe(centerOf(13)); expect(layout.edges).toHaveLength(7); }); }); describe("buildFeederMap", () => { it("routes LLWS winners and losers to the slots the printed bracket shows", () => { const feeders = buildFeederMap(LLWS_20); // G18 = W5 v W9. const g18 = GAME_TO_MATCH[18]; expect(feeders.get(matchKey(g18.round, g18.matchNumber))).toEqual([ { kind: "match", ref: GAME_TO_MATCH[5], result: "winner" }, { kind: "match", ref: GAME_TO_MATCH[9], result: "winner" }, ]); // G13 = L3 v L5 — a winners-bracket loss drops into the elimination bracket. const g13 = GAME_TO_MATCH[13]; expect(feeders.get(matchKey(g13.round, g13.matchNumber))).toEqual([ { kind: "match", ref: GAME_TO_MATCH[3], result: "loser" }, { kind: "match", ref: GAME_TO_MATCH[5], result: "loser" }, ]); }); it("marks directly seeded slots as seeds", () => { const feeders = buildFeederMap(LLWS_20); // G9 = a bye team v W1: slot one is seeded, slot two is fed. const g9 = GAME_TO_MATCH[9]; const [p1, p2] = feeders.get(matchKey(g9.round, g9.matchNumber)) ?? []; expect(p1).toEqual({ kind: "seed" }); expect(p2).toEqual({ kind: "match", ref: GAME_TO_MATCH[1], result: "winner" }); }); it("applies the standard halving rule to other templates", () => { const feeders = buildFeederMap(SIMPLE_16); expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([ { kind: "match", ref: { round: "Round of 16", matchNumber: 1 }, result: "winner" }, { kind: "match", ref: { round: "Round of 16", matchNumber: 2 }, result: "winner" }, ]); expect(feeders.get(matchKey("Quarterfinals", 4))).toEqual([ { kind: "match", ref: { round: "Round of 16", matchNumber: 7 }, result: "winner" }, { kind: "match", ref: { round: "Round of 16", matchNumber: 8 }, result: "winner" }, ]); // The first round is seeded, not fed. expect(feeders.get(matchKey("Round of 16", 1))).toEqual([ { kind: "seed" }, { kind: "seed" }, ]); }); it("returns an empty map without a template", () => { expect(buildFeederMap(undefined).size).toBe(0); }); }); describe("computeGroupLayout — standard brackets are unchanged", () => { it("halves a 16-team bracket evenly, first round in seeded order", () => { const byRound = allMatches(SIMPLE_16); const roundOrder = SIMPLE_16.rounds.map((r) => r.name); const layout = computeGroupLayout( roundOrder, byRound, buildFeederMap(SIMPLE_16), roundOrder ); expect(layout.leafCount).toBe(8); expect(layout.columns.map((c) => c.label)).toEqual(roundOrder); expect(layout.columns.map((c) => c.matches.map((m) => m.match.matchNumber))).toEqual([ [1, 2, 3, 4, 5, 6, 7, 8], [1, 2, 3, 4], [1, 2], [1], ]); // Evenly spread, exactly as the previous index math placed them. expect(layout.columns[0].matches.map((m) => m.center)).toEqual([ 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, ]); expect(layout.columns[3].matches[0].center).toBe(4); }); it("handles byes, placing a seeded team level with the round it enters", () => { // The NFL bracket's top seeds skip the wild card round. const byRound = allMatches(NFL_14); const roundOrder = NFL_14.rounds.map((r) => r.name); const layout = computeGroupLayout(roundOrder, byRound, buildFeederMap(NFL_14), roundOrder); expect(layout.columns.length).toBeGreaterThan(0); for (const column of layout.columns) { expect(column.matches.length).toBeGreaterThan(0); } }); it("falls back to even spreading when a group has no single root", () => { // Two finals and no way to join them — the shape can't resolve to one tree. const byRound = new Map([ ["Semifinals", [{ round: "Semifinals", matchNumber: 1 }]], [ "Finals", [ { round: "Finals", matchNumber: 1 }, { round: "Finals", matchNumber: 2 }, ], ], ]); const layout = computeGroupLayout( ["Semifinals", "Finals"], byRound, new Map(), ["Semifinals", "Finals"] ); expect(layout.columns.map((c) => c.label)).toEqual(["Semifinals", "Finals"]); expect(layout.edges).toEqual([]); expect(layout.columns[0].matches[0].center).toBe(1); expect(layout.columns[1].matches.map((m) => m.center)).toEqual([0.5, 1.5]); }); it("returns nothing for an empty group", () => { const layout = computeGroupLayout([], new Map(), new Map(), []); expect(layout).toEqual({ columns: [], leafCount: 0, edges: [] }); }); }); describe("describeSlotSource", () => { const feeders = buildFeederMap(LLWS_20); const sourcesFor = (game: number): [SlotSource, SlotSource] => { const m = GAME_TO_MATCH[game]; const pair = feeders.get(matchKey(m.round, m.matchNumber)); if (!pair) throw new Error(`No feeders for G${game}`); return pair; }; it("names a winner feed", () => { // G18 = W5 v W9; G5 is Opening Round match 3 on the International side. expect(describeSlotSource(sourcesFor(18)[0], LLWS_20)).toBe("Winner of Opening 3"); }); it("names a loser feed, which is the one no line can show", () => { // G21 = L9 v W13; G9 is Winners Round 2 match 1 on the International side. expect(describeSlotSource(sourcesFor(21)[0], LLWS_20)).toBe("Loser of Winners R2 1"); // G25 = L18 v W23; G18 is International semifinal 1. expect(describeSlotSource(sourcesFor(25)[0], LLWS_20)).toBe("Loser of Winners SF 1"); }); it("uses side-local numbers, as the printed bracket does", () => { // G27 = L20 v W21. G20 is Winners Semifinals match 4 globally, but International // semifinal 2 — the number the printed bracket uses. expect(describeSlotSource(sourcesFor(27)[0], LLWS_20)).toBe("Loser of Winners SF 2"); }); it("names the side where each side plays only one such game", () => { // G37 = L36 v L35: both feeds are Bracket Championship losers, one per side, so a // number would say nothing and the side is the only thing that tells them apart. const [p1, p2] = sourcesFor(37); expect(describeSlotSource(p1, LLWS_20)).toBe("Loser of U.S. Bracket Final"); expect(describeSlotSource(p2, LLWS_20)).toBe("Loser of Intl Bracket Final"); // Same rule inside a side bracket: G34 = L30 v W32. const [elimP1, elimP2] = sourcesFor(34); expect(describeSlotSource(elimP1, LLWS_20)).toBe("Loser of U.S. Winners Final"); expect(describeSlotSource(elimP2, LLWS_20)).toBe("Winner of U.S. Elim R4"); }); it("drops both number and side for the shared final games", () => { // The two sides meet here, so there is only one of each game in the whole bracket. const wc = describeSlotSource( { kind: "match", ref: GAME_TO_MATCH[38], result: "winner" }, LLWS_20 ); expect(wc).toBe("Winner of World Championship"); }); it("returns null for a seeded slot so the caller can render TBD", () => { expect(describeSlotSource({ kind: "seed" }, LLWS_20)).toBeNull(); expect(describeSlotSource(undefined, LLWS_20)).toBeNull(); }); it("uses plain round names for non-LLWS templates", () => { const template = getBracketTemplate("simple_16"); const source: SlotSource = { kind: "match", ref: { round: "Quarterfinals", matchNumber: 3 }, result: "winner", }; expect(describeSlotSource(source, template)).toBe("Winner of Quarterfinals 3"); }); });