import { describe, it, expect } from "vitest"; import { deriveBracketQualifyingStates, getRoundConfig, getGuaranteedMinimumPosition, type BracketMatchInput, } from "../scoring-calculator"; import { BRACKET_TEMPLATES } from "../../lib/bracket-templates"; import { calculateSplitQualifyingPoints, DEFAULT_QP_VALUES } from "../qualifying-points"; // ── Test fixtures ───────────────────────────────────────────────────────────── const SIMPLE_8 = BRACKET_TEMPLATES["simple_8"]; const getConfig = (round: string) => getRoundConfig(round, "simple_8"); const states = (matches: BracketMatchInput[]) => deriveBracketQualifyingStates(matches, SIMPLE_8.rounds, getConfig); const QP_MAP = new Map( DEFAULT_QP_VALUES.map((v) => [v.placement, v.points]) ); const qpFor = (placement: number, tieCount: number) => calculateSplitQualifyingPoints(placement, tieCount, QP_MAP); /** Helper to build a played match. */ const played = ( round: string, winnerId: string, loserId: string ): BracketMatchInput => ({ round, winnerId, loserId, participant1Id: winnerId, participant2Id: loserId, }); /** Helper to build an as-yet-unplayed match (participants seeded, no result). */ const pending = ( round: string, p1: string, p2: string ): BracketMatchInput => ({ round, winnerId: null, loserId: null, participant1Id: p1, participant2Id: p2, }); // Full simple_8 quarterfinal field: winners t1,t2,t3,t4 / losers t5,t6,t7,t8. const QF_ALL = [ played("Quarterfinals", "t1", "t8"), played("Quarterfinals", "t2", "t7"), played("Quarterfinals", "t3", "t6"), played("Quarterfinals", "t4", "t5"), ]; // Semifinals: t1 & t3 advance. const SF_ALL = [ played("Semifinals", "t1", "t2"), played("Semifinals", "t3", "t4"), ]; // Final: t1 champion. const FINAL = [played("Finals", "t1", "t3")]; // ── deriveBracketQualifyingStates ───────────────────────────────────────────── describe("deriveBracketQualifyingStates (simple_8)", () => { it("returns an empty map for no matches", () => { expect(states([]).size).toBe(0); }); it("QF entered: losers lock T5–8, winners floor at T3–4", () => { const s = states(QF_ALL); // Losers — final placement 5, tie span 4 (T5–8). for (const id of ["t5", "t6", "t7", "t8"]) { expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 }); } // Winners — guaranteed at least T3–4: floor placement 3, tie span 2. for (const id of ["t1", "t2", "t3", "t4"]) { expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 }); } }); it("QF+SF entered: SF losers lock T3–4, SF winners floor at 2nd", () => { const s = states([...QF_ALL, ...SF_ALL]); // SF losers — final placement 3, tie span 2. expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 }); expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); // SF winners (finalists-in-waiting) — floor placement 2, tie span 1. expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 }); expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); // QF losers unchanged. expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 }); }); it("full bracket: champion 1st, finalist 2nd, SF losers T3–4, QF losers T5–8", () => { const s = states([...QF_ALL, ...SF_ALL, ...FINAL]); expect(s.get("t1")).toEqual({ placement: 1, tieCount: 1 }); // champion expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); // finalist expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 }); // SF loser expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); // SF loser expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 }); // QF loser }); it("partial QF (2 of 4 played): unplayed teams sit at the T5–8 entry floor", () => { const matches = [ played("Quarterfinals", "t1", "t8"), played("Quarterfinals", "t4", "t5"), pending("Quarterfinals", "t3", "t6"), pending("Quarterfinals", "t2", "t7"), ]; const s = states(matches); // Played winners floor at T3–4. expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 }); expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); // Played losers lock T5–8. expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 }); expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 }); // Unplayed teams: entry floor T5–8 (no crash on incomplete round). for (const id of ["t2", "t3", "t6", "t7"]) { expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 }); } }); it("un-setting the Finals winner reverts both finalists to the 2nd-place floor", () => { const matches = [ ...QF_ALL, ...SF_ALL, pending("Finals", "t1", "t3"), // winner cleared ]; const s = states(matches); expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 }); expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); }); it("a team eliminated in the QF never gains a later-round floor", () => { const s = states([...QF_ALL, ...SF_ALL, ...FINAL]); // t8 lost in the QF and stays T5–8 through the rest of the bracket. expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 }); }); it("is idempotent — same matches yield the same states", () => { const a = states([...QF_ALL, ...SF_ALL]); const b = states([...QF_ALL, ...SF_ALL]); expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted()); }); }); // ── QP values (the worked example) ──────────────────────────────────────────── describe("guaranteed-minimum QP per outcome (default config)", () => { it("matches the worked example: QF win → 9, SF win → 14, Final win → 20", () => { expect(qpFor(3, 2)).toBeCloseTo(9); // QF winner, guaranteed T3–4 expect(qpFor(2, 1)).toBeCloseTo(14); // SF winner, guaranteed 2nd expect(qpFor(1, 1)).toBeCloseTo(20); // champion }); it("losers: QF loss → 4, SF loss → 9, Final loss → 14", () => { expect(qpFor(5, 4)).toBeCloseTo(4); // (5+5+3+3)/4 expect(qpFor(3, 2)).toBeCloseTo(9); // (10+8)/2 expect(qpFor(2, 1)).toBeCloseTo(14); }); it("end-to-end: a QF win immediately yields a 9 QP floor", () => { const s = states(QF_ALL); expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 }); expect(qpFor(3, 2)).toBeCloseTo(9); expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 }); expect(qpFor(5, 4)).toBeCloseTo(4); }); }); // ── Regression guard: structural tieCount, not live count (the #1 fix) ───────── describe("structural tie span vs. live-count regrouping", () => { it("4 QF winners share placement 3 but each keeps the 2-slot (9 QP) floor", () => { const s = states(QF_ALL); // All four QF winners sit at placement 3, but with the STRUCTURAL tie span of 2. for (const id of ["t1", "t2", "t3", "t4"]) { expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 }); } expect(qpFor(3, 2)).toBeCloseTo(9); // A naive regroup by LIVE count (4 rows at placement 3) would average over // slots 3–6 and wrongly yield 7 QP — the bug processQualifyingEvent must avoid // by delegating bracket events to the structural-tieCount path. expect(qpFor(3, 4)).toBeCloseTo(7); expect(qpFor(3, 4)).not.toBeCloseTo(9); }); }); // ── Regression guard: reused ROUND_CONFIG floors are intact ──────────────────── describe("ROUND_CONFIG floors (regression guard)", () => { it("simple_8 Quarterfinals still floors winners at 3rd", () => { expect(getGuaranteedMinimumPosition("Quarterfinals", "simple_8", true)).toBe(3); expect(getRoundConfig("Quarterfinals", "simple_8")?.loserPosition).toBe(5); expect(getRoundConfig("Semifinals", "simple_8")?.winnerFloor).toBe(2); expect(getRoundConfig("Finals", "simple_8")?.winnerFloor).toBeNull(); }); });