Review of the llws_20 bracket turned up two display bugs in the shared ranking code, both triggered by the template setting loserFeedsInto on every winners-bracket round. findConsolationRound took the FIRST round with loserFeedsInto and treated its target as a third-place game. That is sound for fifa_48, whose only such round is the Semifinals, but llws_20 uses loserFeedsInto to route losers into the elimination bracket — so "Elimination Round 1" was being read as the consolation game. Its four matches were then placed as exact positions inside the Opening Round tier, corrupting the final rankings list. A consolation round now has to be terminal: its winner plays no further game, which is precisely what lets its result split two exact positions. The rank walk also advanced by a round's match count on the assumption that every match places its loser. That holds in single elimination and must be kept for undecided rounds — four semifinalists occupy positions 1-4 whether or not the games have been played — but a winners-bracket loss places nobody, since the loser drops into the elimination bracket and is ranked by whatever knocks them out later. Those rounds consumed positions they never filled, so a 20-team bracket ranked its last teams T23. Rounds whose losers are placed later now consume nothing, leaving LLWS at 1, T2, 3, 4, T5, T7, T9, T13, T17 for the full 20-team field. Single-elimination templates are unaffected; afl_10's sub-8th ranks were inflated the same way and are now correct too. Also from review: the llws_20 participant labels were 21-24 characters in an 80px fixed-width admin column and would have wrapped to three lines across all 20 rows. Shortened them and widened the column. Tests play a full 20-team tournament through the real advancement map and assert the resulting rank labels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
589 lines
24 KiB
TypeScript
589 lines
24 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 {
|
||
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<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("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);
|
||
});
|
||
});
|
||
});
|