brackt/app/services/match-sync/__tests__/tennis-draw-mapping.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

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);
});
});