562 lines
22 KiB
TypeScript
562 lines
22 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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<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 { 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<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,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── 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<number, { round: string; matchNumber: number }> = {
|
|||
|
|
// 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<string, number>(
|
|||
|
|
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<T>(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<number, [string | null, string | null]> = {
|
|||
|
|
// 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<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 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<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("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);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|