brackt/app/models/__tests__/llws-20-bracket.test.ts
Claude 89ceee432a
Lay out brackets from the feeder graph
The LLWS bracket didn't read as a bracket: cards sat above games that
don't feed them, connectors joined the wrong pairs, and several games had
no line at all.

The stored data was correct — LLWS_ADVANCEMENT already matches the
official 2026 LLBWS bracket game for game. The renderer was the problem.
TreeColumns placed cards at `index * (height / roundSize)` and
ConnectorColumn assumed matches 2k and 2k+1 feed match k, which holds
only for an exact halving. The LLWS winners bracket is not one: two of
the four Opening Round games skip Winners Round 2 and go straight to the
semifinals, so those two got stranded in column one with nothing beside
them, and the halving branch drew confident, wrong connectors for the
rest.

Lay out from the graph instead. app/lib/bracket-layout.ts inverts a
template's advancement into "what fills each slot", then assigns columns
by depth from the group's final, orders each column by the parent's slot
order, and centres each card on its feeders. Counting back from the final
is what makes a printed bracket line up: a team entering late is drawn in
the column where it actually plays. This reproduces the official
International bracket exactly, and fixes Elimination Round 3, where the
official bracket prints the later game on top but match-number sort put
it below.

Because column is depth, every in-group edge spans exactly one gutter, so
connectors now draw for unplayed games too. Cards also take a fixed
height rather than stretching to fill their column, which is what made a
lone final tower over the rest.

Empty slots name their source — "Loser of Winners SF 1" rather than
"TBD". That is the only way to show the feeds crossing between the
winners and elimination brackets, which render as separate trees.

Also:
- Move the LLWS routing table to app/lib/llws-bracket.ts so the renderer
  can import it without pulling the database context into the browser
  bundle; models/playoff-match re-exports it.
- Page the mobile view one group at a time, matching desktop. A whole
  double-elimination phase is a DAG, not a tree, so its columns would be
  arbitrary.
- Add a clear-bracket admin action. Nothing else could rewrite a match's
  participants, so a mis-seeded bracket had no repair path at all.
- Lift the PDF transcription into app/test/fixtures/llws-bracket.ts so the
  routing and layout tests check against one copy of the official bracket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
2026-08-21 17:50:59 +00:00

481 lines
19 KiB
TypeScript
Raw Permalink 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.

/**
* LLWS 20-Team Double-Elimination Bracket Tests
*
* Verifies the llws_20 template against the official 2026 LLBWS bracket
* (Williamsport, Aug 1930). The PDF numbers its games 138; those numbers appear
* throughout as `G<n>` so the routing can be checked against the printed bracket.
*
* The critical property under test is the double-elimination loser routing: a loss in
* the winners bracket drops a team into the elimination bracket at a specific slot,
* while a loss in the elimination bracket is final.
*/
import { describe, it, expect, vi } from "vitest";
import {
LLWS_20,
getScoringRoundType,
llwsMatchNumber,
llwsSideAndLocal,
} from "~/lib/bracket-templates";
import {
doesLoserAdvance,
generateBracketFromTemplate,
resolveLLWSAdvancement,
} from "../playoff-match";
import {
calculateBracketPoints,
calculateAveragedPoints,
type ScoringRules,
} from "../scoring-rules";
import {
GAME_TO_MATCH,
EXPECTED_SLOTS,
gameNumberFor,
required,
destinationGame,
} from "~/test/fixtures/llws-bracket";
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
// minimal stub is enough to capture the generated rows.
const insertedRows: Record<string, unknown>[] = [];
vi.mock("~/database/context", () => ({
database: () => ({
insert: () => ({
values: (rows: Record<string, unknown>[]) => ({
returning: async () => {
insertedRows.push(...rows);
return rows;
},
}),
}),
}),
}));
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 20,
pointsFor7th: 15,
pointsFor8th: 10,
};
describe("LLWS 20 Bracket Template", () => {
describe("Template structure", () => {
it("has correct identity and size", () => {
expect(LLWS_20.id).toBe("llws_20");
expect(LLWS_20.totalTeams).toBe(20);
expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final");
});
it("has 12 rounds totalling 38 matches", () => {
expect(LLWS_20.rounds).toHaveLength(12);
const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0);
expect(total).toBe(38);
});
it("has the expected match count per round", () => {
const counts = Object.fromEntries(
LLWS_20.rounds.map((r) => [r.name, r.matchCount])
);
expect(counts).toEqual({
"Opening Round": 8,
"Winners Round 2": 4,
"Elimination Round 1": 4,
"Winners Semifinals": 4,
"Elimination Round 2": 4,
"Elimination Round 3": 4,
"Winners Final": 2,
"Elimination Round 4": 2,
"Elimination Final": 2,
"Bracket Championship": 2,
"Consolation Third Place": 1,
"World Championship": 1,
});
});
it("marks exactly the point-awarding rounds as scoring", () => {
const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name);
expect(scoring).toEqual([
"Winners Final",
"Elimination Round 4",
"Elimination Final",
"Bracket Championship",
"Consolation Third Place",
"World Championship",
]);
});
it("lists rounds in chronological order", () => {
// Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23).
const names = LLWS_20.rounds.map((r) => r.name);
expect(names.indexOf("Elimination Round 1")).toBeLessThan(
names.indexOf("Winners Semifinals")
);
expect(names.indexOf("Winners Final")).toBeLessThan(
names.indexOf("Elimination Final")
);
});
it("gives elimination-bracket winners a floor matching their real worst case", () => {
const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n);
// Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 78 tier),
// so the engine's default floor of 5 would overstate it.
expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7);
// Reaching the Winners Final guarantees 5th at worst.
expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5);
// Nothing is guaranteed earlier than that.
expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull();
});
it("has 20 participant labels", () => {
expect(LLWS_20.participantLabels).toHaveLength(20);
});
it("splits display into U.S., International and Championship phases", () => {
expect(LLWS_20.phases?.map((p) => p.name)).toEqual([
"United States",
"International",
"Championship",
]);
});
it("assigns every match to exactly one phase group", () => {
const claimed = new Map<string, number>();
for (const phase of LLWS_20.phases ?? []) {
for (const group of phase.groups ?? []) {
for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) {
for (const n of numbers) {
const key = `${round}#${n}`;
claimed.set(key, (claimed.get(key) ?? 0) + 1);
}
}
}
}
// Every per-side match claimed exactly once (36 games; the 2 finals live in
// the Championship phase's plain round list, not in a group).
expect(claimed.size).toBe(36);
expect([...claimed.values()].every((c) => c === 1)).toBe(true);
});
});
describe("Bracket generation", () => {
const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`);
async function generate() {
insertedRows.length = 0;
await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS);
return insertedRows.map((r) => ({
round: r.round as string,
matchNumber: r.matchNumber as number,
participant1Id: (r.participant1Id ?? null) as string | null,
participant2Id: (r.participant2Id ?? null) as string | null,
isScoring: r.isScoring as boolean,
}));
}
it("creates all 38 matches", async () => {
const rows = await generate();
expect(rows).toHaveLength(38);
});
it("creates the right number of matches per round", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
expect(
rows.filter((r) => r.round === round.name),
`${round.name} match count`
).toHaveLength(round.matchCount);
}
});
it("numbers matches 1..n within each round", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
const numbers = rows
.filter((r) => r.round === round.name)
.map((r) => r.matchNumber)
.toSorted((a, b) => a - b);
expect(numbers).toEqual(
Array.from({ length: round.matchCount }, (_, i) => i + 1)
);
}
});
it("seeds the Opening Round two teams at a time, U.S. then International", async () => {
const rows = await generate();
const opening = rows
.filter((r) => r.round === "Opening Round")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
// U.S. slots 07 fill matches 14; International slots 1017 fill matches 58.
expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
["team-0", "team-1"],
["team-2", "team-3"],
["team-4", "team-5"],
["team-6", "team-7"],
["team-10", "team-11"],
["team-12", "team-13"],
["team-14", "team-15"],
["team-16", "team-17"],
]);
});
it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => {
const rows = await generate();
const wr2 = rows
.filter((r) => r.round === "Winners Round 2")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
["team-8", null],
["team-9", null],
["team-18", null],
["team-19", null],
]);
});
it("uses each participant exactly once and leaves every other slot empty", async () => {
const rows = await generate();
const seeded = rows
.flatMap((r) => [r.participant1Id, r.participant2Id])
.filter((id): id is string => id !== null);
expect(seeded).toHaveLength(20);
expect(new Set(seeded).size).toBe(20);
expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS));
});
it("stamps isScoring from the template", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
for (const row of rows.filter((r) => r.round === round.name)) {
expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring);
}
}
});
it("rejects a participant count other than 20", async () => {
await expect(
generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19))
).rejects.toThrow(/requires 20 participants/);
});
});
describe("Side / match-number mapping", () => {
it("round-trips every match number through side-local form", () => {
for (const round of LLWS_20.rounds) {
if (round.matchCount === 1) continue; // shared finals have no side
for (let n = 1; n <= round.matchCount; n++) {
const { side, localMatch } = llwsSideAndLocal(round.name, n);
expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n);
}
}
});
it("puts U.S. matches in the low half and International in the high half", () => {
expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0);
expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1);
expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0);
expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1);
expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0);
expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1);
});
});
describe("Advancement matches the official bracket", () => {
/**
* Replay the whole tournament through resolveLLWSAdvancement and record which
* feed label ends up in each slot, then compare against the printed bracket.
*/
const actualSlots: Record<number, [string | null, string | null]> = {};
for (const game of Object.keys(EXPECTED_SLOTS)) {
actualSlots[Number(game)] = [null, null];
}
for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) {
const game = Number(gameStr);
const { winner, loser } = resolveLLWSAdvancement(round, matchNumber);
for (const [dest, label] of [
[winner, `W${game}`],
[loser, `L${game}`],
] as const) {
if (!dest) continue;
const targetGame = gameNumberFor(dest.round, dest.matchNumber);
const slotIndex = dest.slot === "participant1Id" ? 0 : 1;
actualSlots[targetGame][slotIndex] = label;
}
}
it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))(
"Game %i has the printed participants",
(game) => {
expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]);
}
);
it("fills every slot in the bracket exactly once", () => {
// 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes),
// leaving 56 to be filled by advancement.
const filled = Object.values(actualSlots)
.flat()
.filter((s) => s !== null).length;
expect(filled).toBe(56);
});
});
describe("Double-elimination loser routing", () => {
it("routes every winners-bracket loser into the elimination bracket", () => {
const winnersRounds = [
"Opening Round",
"Winners Round 2",
"Winners Semifinals",
"Winners Final",
];
for (const roundName of winnersRounds) {
const round = LLWS_20.rounds.find((r) => r.name === roundName);
if (!round) throw new Error(`missing round ${roundName}`);
for (let n = 1; n <= round.matchCount; n++) {
const { loser } = resolveLLWSAdvancement(roundName, n);
expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull();
expect(loser?.round.startsWith("Elimination")).toBe(true);
}
}
});
it("eliminates every elimination-bracket loser", () => {
const elimRounds = [
"Elimination Round 1",
"Elimination Round 2",
"Elimination Round 3",
"Elimination Round 4",
"Elimination Final",
];
for (const roundName of elimRounds) {
const round = LLWS_20.rounds.find((r) => r.name === roundName);
if (!round) throw new Error(`missing round ${roundName}`);
for (let n = 1; n <= round.matchCount; n++) {
const { loser } = resolveLLWSAdvancement(roundName, n);
expect(loser, `${roundName} #${n} loser should be out`).toBeNull();
}
}
});
it("keeps the winners-bracket final loser alive via the Elimination Final", () => {
// G30 (U.S. Winners Final) loser → G34, not out. This is the defining
// double-elimination behavior: a first loss never eliminates.
const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1);
expect(destinationGame(loser)).toBe(34);
expect(destinationGame(winner)).toBe(36);
});
it("sends the side-championship loser to the consolation game, not out", () => {
// No "if necessary" rematch: the winners-bracket champion that loses G36 is
// done in the bracket, but still plays G37 for 3rd/4th.
const us = resolveLLWSAdvancement("Bracket Championship", 1);
expect(destinationGame(us.winner)).toBe(38);
expect(destinationGame(us.loser)).toBe(37);
expect(required(us.winner).slot).toBe("participant1Id");
expect(required(us.loser).slot).toBe("participant1Id");
const intl = resolveLLWSAdvancement("Bracket Championship", 2);
expect(required(intl.winner).slot).toBe("participant2Id");
expect(required(intl.loser).slot).toBe("participant2Id");
});
it("flags winners-bracket losers as advancing so they are not marked eliminated", () => {
// doesLoserAdvance is what stops the scoring engine writing a 0-point
// elimination (and announcing a knockout) for a team that is still alive.
// Winners Final and Bracket Championship are scoring rounds and are covered
// by loserIsPartial instead, so they are deliberately not listed here.
for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) {
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true);
}
for (const round of [
"Elimination Round 1",
"Elimination Round 2",
"Elimination Round 3",
"Elimination Round 4",
"Elimination Final",
]) {
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false);
}
});
it("does not apply LLWS loser routing to other templates", () => {
expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false);
expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false);
});
it("advances nobody out of the two final games", () => {
for (const round of ["Consolation Third Place", "World Championship"]) {
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });
}
});
it("never crosses a team between the U.S. and International sides", () => {
for (const round of LLWS_20.rounds) {
if (round.name === "Bracket Championship") continue; // the crossover point
if (round.matchCount === 1) continue;
for (let n = 1; n <= round.matchCount; n++) {
const { side } = llwsSideAndLocal(round.name, n);
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
for (const dest of [winner, loser]) {
if (!dest) continue;
const destRound = LLWS_20.rounds.find((r) => r.name === dest.round);
if (!destRound || destRound.matchCount === 1) continue;
expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side);
}
}
}
});
});
describe("Placement tiers", () => {
it("classifies scoring rounds correctly", () => {
expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals");
expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals");
expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals");
expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals");
// Nobody is eliminated in the Winners Final — the loser drops to the
// elimination bracket — so it has no placement tier.
expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull();
});
it("pays 3rd and 4th distinctly (there is a real consolation game)", () => {
expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50);
expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40);
});
it("splits 58 into two two-team tiers", () => {
const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2
const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2
expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper);
expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper);
expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower);
expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower);
// Surviving Elimination Round 4 is worth more than losing it.
expect(upper).toBeGreaterThan(lower);
});
it("awards nothing below 8th", () => {
// The 12 teams knocked out in Elimination Rounds 13 finish 9th20th.
expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0);
expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0);
});
it("has exactly 8 teams alive when the first scoring elimination game is played", () => {
// Elimination Round 4 is the 7th8th tier, so the field must be 8 at that point:
// per side the Winners Final winner, the Winners Final loser, and the two
// Elimination Round 3 winners.
const eliminatedBeforeElimR4 =
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) +
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) +
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0);
expect(eliminatedBeforeElimR4).toBe(12);
expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8);
});
});
});