2026-06-19 01:25:17 +00:00
|
|
|
|
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());
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.
Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass
Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
(gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
transaction.
Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
template-stripping so {{nowrap}}-wrapped players parse
Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
slots) with inline "rename existing" / "create as new" resolution via
fetcher (no full reload)
Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
|
|
|
|
// ── tennis_128: scoring starts deep (Round of 16), not at round 1 ─────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("deriveBracketQualifyingStates (tennis_128 — deep scoring start)", () => {
|
|
|
|
|
|
const TENNIS = BRACKET_TEMPLATES["tennis_128"];
|
|
|
|
|
|
const tStates = (matches: BracketMatchInput[]) =>
|
|
|
|
|
|
deriveBracketQualifyingStates(matches, TENNIS.rounds, (round) =>
|
|
|
|
|
|
getRoundConfig(round, "tennis_128"),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
it("awards NO QP to a player who lost before the Round of 16", () => {
|
|
|
|
|
|
const s = tStates([
|
|
|
|
|
|
played("Round of 128", "winner", "earlyLoser"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(s.has("earlyLoser")).toBe(false); // 0 QP — didn't reach scoring
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("awards NO QP just for winning a pre-scoring round (still short of R16)", () => {
|
|
|
|
|
|
// Won R128, next match (R64) not played → not yet in the Round of 16.
|
|
|
|
|
|
const s = tStates([played("Round of 128", "p", "x")]);
|
|
|
|
|
|
expect(s.has("p")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("gives NO entry floor to undrawn/unplayed players (the 9th-place bug)", () => {
|
|
|
|
|
|
const s = tStates([
|
|
|
|
|
|
pending("Round of 128", "a", "b"),
|
|
|
|
|
|
pending("Round of 128", "c", "d"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(s.size).toBe(0); // nobody has earned anything yet
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("floors a player who reached the Round of 16 (won R32) at T9–16", () => {
|
|
|
|
|
|
// a wins R128 → R64 → R32, so a is now IN the Round of 16 (not yet played).
|
|
|
|
|
|
const s = tStates([
|
|
|
|
|
|
played("Round of 128", "a", "x1"),
|
|
|
|
|
|
played("Round of 64", "a", "x2"),
|
|
|
|
|
|
played("Round of 32", "a", "x3"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(s.get("a")).toEqual({ placement: 9, tieCount: 8 });
|
|
|
|
|
|
// The players a beat in non-scoring rounds earn nothing.
|
|
|
|
|
|
expect(s.has("x1")).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("locks an actual Round of 16 loser at T9–16 and floors the winner at T5–8", () => {
|
|
|
|
|
|
const s = tStates([
|
|
|
|
|
|
played("Round of 16", "r16winner", "r16loser"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(s.get("r16loser")).toEqual({ placement: 9, tieCount: 8 });
|
|
|
|
|
|
expect(s.get("r16winner")).toEqual({ placement: 5, tieCount: 4 }); // QF floor
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-19 01:25:17 +00:00
|
|
|
|
// ── 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|