/** * LLWS 20-Team Double-Elimination Bracket Tests * * Verifies the llws_20 template against the official 2026 LLBWS bracket * (Williamsport, Aug 19–30). The PDF numbers its games 1–38; those numbers appear * throughout as `G` 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"; // 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[] = []; vi.mock("~/database/context", () => ({ database: () => ({ insert: () => ({ values: (rows: Record[]) => ({ 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, }; // ── PDF game number ↔ (round, match number) ────────────────────────────────── // // Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match // numbers in each round, International the high ones. const GAME_TO_MATCH: Record = { // Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8) 2: { round: "Opening Round", matchNumber: 1 }, 4: { round: "Opening Round", matchNumber: 2 }, 6: { round: "Opening Round", matchNumber: 3 }, 8: { round: "Opening Round", matchNumber: 4 }, 1: { round: "Opening Round", matchNumber: 5 }, 3: { round: "Opening Round", matchNumber: 6 }, 5: { round: "Opening Round", matchNumber: 7 }, 7: { round: "Opening Round", matchNumber: 8 }, // Winners Round 2 — U.S. G10,12; Intl G9,11 10: { round: "Winners Round 2", matchNumber: 1 }, 12: { round: "Winners Round 2", matchNumber: 2 }, 9: { round: "Winners Round 2", matchNumber: 3 }, 11: { round: "Winners Round 2", matchNumber: 4 }, // Elimination Round 1 — U.S. G14,16; Intl G13,15 14: { round: "Elimination Round 1", matchNumber: 1 }, 16: { round: "Elimination Round 1", matchNumber: 2 }, 13: { round: "Elimination Round 1", matchNumber: 3 }, 15: { round: "Elimination Round 1", matchNumber: 4 }, // Winners Semifinals — U.S. G17,19; Intl G18,20 17: { round: "Winners Semifinals", matchNumber: 1 }, 19: { round: "Winners Semifinals", matchNumber: 2 }, 18: { round: "Winners Semifinals", matchNumber: 3 }, 20: { round: "Winners Semifinals", matchNumber: 4 }, // Elimination Round 2 — U.S. G22,24; Intl G21,23 22: { round: "Elimination Round 2", matchNumber: 1 }, 24: { round: "Elimination Round 2", matchNumber: 2 }, 21: { round: "Elimination Round 2", matchNumber: 3 }, 23: { round: "Elimination Round 2", matchNumber: 4 }, // Elimination Round 3 — U.S. G26,28; Intl G25,27 26: { round: "Elimination Round 3", matchNumber: 1 }, 28: { round: "Elimination Round 3", matchNumber: 2 }, 25: { round: "Elimination Round 3", matchNumber: 3 }, 27: { round: "Elimination Round 3", matchNumber: 4 }, // Winners Final — U.S. G30; Intl G29 30: { round: "Winners Final", matchNumber: 1 }, 29: { round: "Winners Final", matchNumber: 2 }, // Elimination Round 4 — U.S. G32; Intl G31 32: { round: "Elimination Round 4", matchNumber: 1 }, 31: { round: "Elimination Round 4", matchNumber: 2 }, // Elimination Final — U.S. G34; Intl G33 34: { round: "Elimination Final", matchNumber: 1 }, 33: { round: "Elimination Final", matchNumber: 2 }, // Bracket Championship — U.S. G36; Intl G35 36: { round: "Bracket Championship", matchNumber: 1 }, 35: { round: "Bracket Championship", matchNumber: 2 }, // Finals 37: { round: "Consolation Third Place", matchNumber: 1 }, 38: { round: "World Championship", matchNumber: 1 }, }; const MATCH_TO_GAME = new Map( Object.entries(GAME_TO_MATCH).map(([game, m]) => [ `${m.round}#${m.matchNumber}`, Number(game), ]) ); function gameNumberFor(round: string, matchNumber: number): number { const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`); if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`); return game; } /** Narrows a destination that the test expects to exist. */ function required(destination: T | null): T { if (destination === null) throw new Error("Expected a destination, got null"); return destination; } /** PDF game number a destination points at. */ function destinationGame( destination: { round: string; matchNumber: number } | null ): number { const d = required(destination); return gameNumberFor(d.round, d.matchNumber); } /** * The official bracket printed as feed labels: for each game, which prior game's * winner (W) or loser (L) fills each slot. `null` = a team seeded in directly. * * Transcribed from the PDF. This is the source of truth the routing must reproduce. */ const EXPECTED_SLOTS: Record = { // Opening Round — all directly seeded 1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null], 5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null], // Winners Round 2 — bye team, then an Opening Round winner 9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"], // Elimination Round 1 13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"], // Winners Semifinals 17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"], // Elimination Round 2 21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"], // Elimination Round 3 — cross-over 25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"], // Winners Final 29: ["W18", "W20"], 30: ["W17", "W19"], // Elimination Round 4 31: ["W27", "W25"], 32: ["W28", "W26"], // Elimination Final 33: ["L29", "W31"], 34: ["L30", "W32"], // Bracket Championship 35: ["W29", "W33"], 36: ["W30", "W34"], // Finals 37: ["L36", "L35"], 38: ["W36", "W35"], }; 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 7–8 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(); 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 0–7 fill matches 1–4; International slots 10–17 fill matches 5–8. 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 = {}; 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 5–8 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 1–3 finish 9th–20th. 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 7th–8th 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); }); }); });