201 lines
8 KiB
TypeScript
201 lines
8 KiB
TypeScript
|
|
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<number, number>(
|
|||
|
|
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();
|
|||
|
|
});
|
|||
|
|
});
|