378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, TestMatch[]> {
|
||
|
|
const byRound = new Map<string, TestMatch[]>();
|
||
|
|
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<string, TestMatch[]> {
|
||
|
|
const byRound = new Map<string, TestMatch[]>();
|
||
|
|
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<string, TestMatch[]>([
|
||
|
|
["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");
|
||
|
|
});
|
||
|
|
});
|