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>
147 lines
4.1 KiB
TypeScript
147 lines
4.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
playerKey,
|
|
canonicalPlayerName,
|
|
buildResolvedMatches,
|
|
} from "../tennis-draw-mapping";
|
|
import type { FetchedDraw, FetchedPlayer } from "../types";
|
|
|
|
const sinner: FetchedPlayer = { name: "J Sinner", externalId: "Jannik Sinner", seed: 1 };
|
|
const djokovic: FetchedPlayer = { name: "N Djokovic", externalId: "Novak Djokovic", seed: 6 };
|
|
const noLink: FetchedPlayer = { name: "Some Qualifier", externalId: null, seed: null };
|
|
|
|
describe("playerKey", () => {
|
|
it("prefers the external id", () => {
|
|
expect(playerKey(sinner)).toBe("id:Jannik Sinner");
|
|
});
|
|
it("falls back to a normalized name when there is no external id", () => {
|
|
expect(playerKey(noLink)).toBe("name:some qualifier");
|
|
});
|
|
});
|
|
|
|
describe("canonicalPlayerName", () => {
|
|
it("uses the full name from the external id, not the abbreviated display", () => {
|
|
expect(canonicalPlayerName(sinner)).toBe("Jannik Sinner");
|
|
});
|
|
it("uses the display name when there is no external id", () => {
|
|
expect(canonicalPlayerName(noLink)).toBe("Some Qualifier");
|
|
});
|
|
|
|
it("strips the trailing Wikipedia disambiguator", () => {
|
|
expect(
|
|
canonicalPlayerName({ name: "Arthur Fils", externalId: "Arthur Fils (tennis)", seed: null }),
|
|
).toBe("Arthur Fils");
|
|
});
|
|
});
|
|
|
|
describe("buildResolvedMatches", () => {
|
|
const draw: FetchedDraw = {
|
|
templateId: "tennis_128",
|
|
rounds: [
|
|
{
|
|
roundName: "Round of 128",
|
|
matches: [
|
|
{
|
|
externalMatchId: "m-r128-1",
|
|
round: "Round of 128",
|
|
matchNumber: 1,
|
|
player1: sinner,
|
|
player2: noLink,
|
|
winner: 1,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
roundName: "Final",
|
|
matches: [
|
|
{
|
|
externalMatchId: "m-final",
|
|
round: "Final",
|
|
matchNumber: 1,
|
|
player1: djokovic,
|
|
player2: sinner,
|
|
winner: 2,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const idByKey = new Map<string, string>([
|
|
[playerKey(sinner), "sp-sinner"],
|
|
[playerKey(djokovic), "sp-djokovic"],
|
|
[playerKey(noLink), "sp-qual"],
|
|
]);
|
|
const roundIsScoring = new Map<string, boolean>([
|
|
["Round of 128", false],
|
|
["Final", true],
|
|
]);
|
|
|
|
const resolved = buildResolvedMatches(draw, idByKey, roundIsScoring);
|
|
|
|
it("maps winner slot 1 to winnerId/loserId", () => {
|
|
const r128 = resolved.find((m) => m.externalMatchId === "m-r128-1");
|
|
expect(r128?.winnerId).toBe("sp-sinner");
|
|
expect(r128?.loserId).toBe("sp-qual");
|
|
expect(r128?.isScoring).toBe(false);
|
|
});
|
|
|
|
it("maps winner slot 2 to the second player", () => {
|
|
const final = resolved.find((m) => m.externalMatchId === "m-final");
|
|
expect(final?.participant1Id).toBe("sp-djokovic");
|
|
expect(final?.participant2Id).toBe("sp-sinner");
|
|
expect(final?.winnerId).toBe("sp-sinner");
|
|
expect(final?.loserId).toBe("sp-djokovic");
|
|
expect(final?.isScoring).toBe(true);
|
|
});
|
|
|
|
it("leaves winner/loser null when the source reported no winner", () => {
|
|
const noWinnerDraw: FetchedDraw = {
|
|
templateId: "tennis_128",
|
|
rounds: [
|
|
{
|
|
roundName: "Round of 128",
|
|
matches: [
|
|
{
|
|
externalMatchId: "m-x",
|
|
round: "Round of 128",
|
|
matchNumber: 1,
|
|
player1: sinner,
|
|
player2: djokovic,
|
|
winner: null,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const [m] = buildResolvedMatches(noWinnerDraw, idByKey, roundIsScoring);
|
|
expect(m.winnerId).toBeNull();
|
|
expect(m.loserId).toBeNull();
|
|
});
|
|
|
|
it("defaults isScoring to false for an unknown round", () => {
|
|
const [m] = buildResolvedMatches(
|
|
{
|
|
templateId: "tennis_128",
|
|
rounds: [
|
|
{
|
|
roundName: "Mystery",
|
|
matches: [
|
|
{
|
|
externalMatchId: "m-y",
|
|
round: "Mystery",
|
|
matchNumber: 1,
|
|
player1: sinner,
|
|
player2: djokovic,
|
|
winner: 1,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
idByKey,
|
|
new Map(),
|
|
);
|
|
expect(m.isScoring).toBe(false);
|
|
});
|
|
});
|