/** * 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, BRACKET_TEMPLATES, 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; /** Only the fallback reads these, to trace edges through an unrecognised shape. */ winnerId?: string | null; participant1Id?: string | null; participant2Id?: string | null; } /** 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("buildFeederMap — templates with routing of their own", () => { // The halving rule describes advanceWinnerTemplate, not every bracket. Inventing it // where it doesn't hold draws confident, wrong connectors and mislabels slots, which // is worse than drawing nothing. it("follows feedsInto rather than the order rounds are listed in", () => { // AFL's Wildcard Round feeds the Elimination Finals, skipping the round printed // next to it, so array order would fabricate the whole chain. const afl = BRACKET_TEMPLATES.afl_10; const feeders = buildFeederMap(afl); const fed = [...feeders.entries()].filter(([, pair]) => pair.some((s) => s.kind === "match") ); // Only Preliminary Finals → Grand Final actually halves. expect(fed.map(([key]) => key)).toEqual(["Grand Final#1"]); }); it("leaves a bye round's slots seeded rather than inventing feeds", () => { // CFP's First Round (4) feeds the Quarterfinals (4) — the top seeds have byes. const feeders = buildFeederMap(BRACKET_TEMPLATES.cfp_12); expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([ { kind: "seed" }, { kind: "seed" }, ]); }); it("leaves the First Four out of the Round of 64", () => { // advanceFirstFourWinner puts each winner in a specific seed slot, not games 1-2. const feeders = buildFeederMap(BRACKET_TEMPLATES.ncaa_68); expect(feeders.get(matchKey("Round of 64", 1))).toEqual([ { kind: "seed" }, { kind: "seed" }, ]); }); it("leaves the NBA play-in alone, where a loser feeds forward", () => { // Play-In Round 2 pairs the 7v8 loser with the 9v10 winner, so the round sizes // halve but the winners-only rule still doesn't describe it. const feeders = buildFeederMap(BRACKET_TEMPLATES.nba_20); expect(feeders.get(matchKey("Play-In Round 2", 1))).toEqual([ { kind: "seed" }, { kind: "seed" }, ]); }); it("does not route the FIFA final through the third place game", () => { // Third Place Game sits between Semifinals and Finals in round order, so array // order made it the Finals' feeder and left the Finals' second slot empty. const feeders = buildFeederMap(BRACKET_TEMPLATES.fifa_48); expect(feeders.get(matchKey("Finals", 1))).toEqual([ { kind: "match", ref: { round: "Semifinals", matchNumber: 1 }, result: "winner" }, { kind: "match", ref: { round: "Semifinals", matchNumber: 2 }, result: "winner" }, ]); }); }); describe("computeGroupLayout — every template still draws connectors", () => { /** Lay a whole template out the way BracketTreeView would. */ function layOut(template: BracketTemplate) { const byRound = allMatches(template); const order = template.rounds.map((r) => r.name); // BracketTreeView renders a third place game outside the tree. const rounds = order.filter((r) => r !== "Third Place Game"); return computeGroupLayout(rounds, byRound, buildFeederMap(template), order); } // A gutter joining a column to one exactly half its size is a plain bracket join and // must always be drawn. Where the sizes don't halve — a bye round, a play-in, the // First Four — the routing is bespoke and nothing is drawn until the games decide it, // which is what these brackets did before. it.each(Object.keys(BRACKET_TEMPLATES).filter((id) => id !== "llws_20"))( "%s draws every gutter that halves", (id) => { const layout = layOut(BRACKET_TEMPLATES[id]); const gutters = new Set(layout.edges.map((e) => e.fromColumn)); let halvingGutters = 0; for (let ci = 0; ci < layout.columns.length - 1; ci++) { const from = layout.columns[ci].matches.length; const to = layout.columns[ci + 1].matches.length; if (from !== to * 2) continue; halvingGutters += 1; expect(gutters).toContain(ci); } // Every template has at least one, so a template that lost all its lines fails. expect(halvingGutters).toBeGreaterThan(0); } ); it("keeps the FIFA bracket a single tree once the third place game is set aside", () => { const layout = layOut(BRACKET_TEMPLATES.fifa_48); expect(layout.columns.map((c) => c.label)).toEqual([ "Round of 32", "Round of 16", "Quarterfinals", "Semifinals", "Finals", ]); expect(layout.edges).toHaveLength(30); }); // llws_20 is excluded above because both sides in one group is genuinely not a tree; // it renders per side, which the tests further up cover. }); describe("computeGroupLayout — fallback keeps the old connectors", () => { const rounds = ["Quarterfinals", "Semifinals", "Finals"]; const byRound = new Map([ ["Quarterfinals", [1, 2, 3, 4].map((n) => ({ round: "Quarterfinals", matchNumber: n }))], ["Semifinals", [1, 2].map((n) => ({ round: "Semifinals", matchNumber: n }))], ["Finals", [{ round: "Finals", matchNumber: 1 }]], ]); it("infers halving edges when there is no feeder map at all", () => { // A bracket with no template id, which SportSeasonDisplay renders. const layout = computeGroupLayout(rounds, byRound, new Map(), rounds); expect(layout.edges).toHaveLength(6); // Quarterfinals 1 and 2 both join Semifinal 1. const intoFirstSemi = layout.edges.filter((e) => e.toCenter === 1); expect(intoFirstSemi.map((e) => e.fromCenter)).toEqual([0.5, 1.5]); }); it("traces played winners when the shape is not a halving", () => { const irregular = new Map([ [ "Wildcard", [ { round: "Wildcard", matchNumber: 1, winnerId: "a" }, { round: "Wildcard", matchNumber: 2, winnerId: "b" }, ], ], [ "Semifinals", [ { round: "Semifinals", matchNumber: 1, participant1Id: "seeded", participant2Id: "b" }, { round: "Semifinals", matchNumber: 2, participant1Id: "seeded2", participant2Id: "a" }, ], ], ]); const layout = computeGroupLayout( ["Wildcard", "Semifinals"], irregular, new Map(), ["Wildcard", "Semifinals"] ); // b won Wildcard 2 (centre 1.5) and plays Semifinal 1 (centre 0.5) — a crossing // edge that only the actual result can reveal. expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 1.5, toCenter: 0.5 }); expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 0.5, toCenter: 1.5 }); }); }); 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"); }); });