brackt/app/models/__tests__/qualifying-bracket-scoring.test.ts

201 lines
8 KiB
TypeScript
Raw Normal View History

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 T58, winners floor at T34", () => {
const s = states(QF_ALL);
// Losers — final placement 5, tie span 4 (T58).
for (const id of ["t5", "t6", "t7", "t8"]) {
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
}
// Winners — guaranteed at least T34: 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 T34, 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 T34, QF losers T58", () => {
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 T58 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 T34.
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
// Played losers lock T58.
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 });
// Unplayed teams: entry floor T58 (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 T58 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 T34
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 36 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();
});
});