brackt/app/models/__tests__/qualifying-bracket-scoring.test.ts
Chris Parsons 81d063faa1
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m13s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
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 21:48:00 -07:00

251 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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());
});
});
// ── 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 T916", () => {
// 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 T916 and floors the winner at T58", () => {
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
});
});
// ── 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();
});
});