Rank the third place game winner instead of leaving them in contention #137
2 changed files with 543 additions and 38 deletions
|
|
@ -13,7 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
import { RankingsRow } from "./RankingsRow";
|
import { RankingsRow } from "./RankingsRow";
|
||||||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||||
|
|
||||||
|
|
@ -174,6 +174,138 @@ export function computeEliminatedByRound(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The score recorded for one participant in a match, or null if they didn't play in it. */
|
||||||
|
function participantScore(match: Match, participantId: string | null): string | null {
|
||||||
|
if (!participantId) return null;
|
||||||
|
if (participantId === match.participant1Id) return match.participant1Score;
|
||||||
|
if (participantId === match.participant2Id) return match.participant2Score;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A consolation final: a round contested by the losers of an earlier round, which
|
||||||
|
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
|
||||||
|
* (fed by the Semifinals) is the only one in the templates today.
|
||||||
|
*/
|
||||||
|
export interface ConsolationRound {
|
||||||
|
/** The consolation round itself, e.g. "Third Place Game". */
|
||||||
|
round: string;
|
||||||
|
/** The round whose losers contest it, e.g. "Semifinals". */
|
||||||
|
feederRound: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the template's consolation round, if it has one.
|
||||||
|
* Exported for unit testing.
|
||||||
|
*/
|
||||||
|
export function findConsolationRound(
|
||||||
|
template: BracketTemplate | undefined
|
||||||
|
): ConsolationRound | undefined {
|
||||||
|
const feeder = template?.rounds.find((r) => r.loserFeedsInto);
|
||||||
|
if (!feeder?.loserFeedsInto) return undefined;
|
||||||
|
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ordered final-rankings list from completed matches.
|
||||||
|
*
|
||||||
|
* Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the
|
||||||
|
* previous round's losers share the next tier, and so on — each round consuming as
|
||||||
|
* many positions as it has matches.
|
||||||
|
*
|
||||||
|
* A consolation round needs different handling, because its winner never loses a
|
||||||
|
* match and so the loser-driven walk above would leave them unranked and "in
|
||||||
|
* contention" forever. Its two places are exactly the top of the tier its feeder
|
||||||
|
* round's losers would otherwise share, so it is resolved *at the feeder round* —
|
||||||
|
* the winner takes that tier's first position and the loser the second — and the
|
||||||
|
* consolation round itself consumes no positions. Positions are derived rather than
|
||||||
|
* hardcoded, so a consolation round hanging off a different feeder still lands right.
|
||||||
|
*
|
||||||
|
* Exported for unit testing.
|
||||||
|
*/
|
||||||
|
export function computeRankedEntries(
|
||||||
|
matches: Match[],
|
||||||
|
rounds: string[],
|
||||||
|
matchesByRound: Map<string, Match[]>,
|
||||||
|
consolation: ConsolationRound | undefined,
|
||||||
|
ownershipMap: Map<string, TeamOwnership>
|
||||||
|
): EliminatedEntry[] {
|
||||||
|
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||||
|
|
||||||
|
// Only take the consolation path when both rounds actually have matches; otherwise
|
||||||
|
// fall through to the loser-driven walk so nothing is dropped.
|
||||||
|
const consolationActive =
|
||||||
|
consolation &&
|
||||||
|
rounds.includes(consolation.round) &&
|
||||||
|
rounds.includes(consolation.feederRound);
|
||||||
|
|
||||||
|
// Consolation matches we can place exactly. Anything else in that round (still in
|
||||||
|
// progress, or missing its hydrated winner/loser) deliberately stays eligible for
|
||||||
|
// the loser-driven walk rather than being silently dropped.
|
||||||
|
const consolationMatches =
|
||||||
|
consolationActive && consolation
|
||||||
|
? (matchesByRound.get(consolation.round) ?? []).filter(
|
||||||
|
(m): m is Match & { winner: Participant; loser: Participant } =>
|
||||||
|
m.isComplete && !!m.winner && !!m.loser
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id));
|
||||||
|
|
||||||
|
const entryFor = (
|
||||||
|
match: Match,
|
||||||
|
participant: Participant,
|
||||||
|
participantId: string | null
|
||||||
|
): Omit<EliminatedEntry, "rankLabel"> => ({
|
||||||
|
participant,
|
||||||
|
score: participantScore(match, participantId),
|
||||||
|
ownership: ownershipMap.get(participant.id) || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const losersByRound = new Map<string, Omit<EliminatedEntry, "rankLabel">[]>();
|
||||||
|
for (const match of matches) {
|
||||||
|
if (!match.isComplete || !match.loser) continue;
|
||||||
|
if (exactlyPlacedMatchIds.has(match.id)) continue;
|
||||||
|
if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue;
|
||||||
|
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||||
|
losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankedEntries: EliminatedEntry[] = [];
|
||||||
|
let nextRank = 2;
|
||||||
|
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||||
|
const roundName = rounds[ri];
|
||||||
|
|
||||||
|
// The consolation match splits the top of its feeder round's tier, so it is
|
||||||
|
// placed first and the round's remaining losers share what's left below it.
|
||||||
|
let tierRank = nextRank;
|
||||||
|
if (consolationActive && roundName === consolation?.feederRound) {
|
||||||
|
for (const match of consolationMatches) {
|
||||||
|
rankedEntries.push({
|
||||||
|
...entryFor(match, match.winner, match.winnerId),
|
||||||
|
rankLabel: `${tierRank}`,
|
||||||
|
});
|
||||||
|
rankedEntries.push({
|
||||||
|
...entryFor(match, match.loser, match.loserId),
|
||||||
|
rankLabel: `${tierRank + 1}`,
|
||||||
|
});
|
||||||
|
tierRank += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const loser of losersByRound.get(roundName) ?? []) {
|
||||||
|
rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consolation round's places belong to its feeder round's tier, so it
|
||||||
|
// consumes none of its own.
|
||||||
|
if (consolationActive && roundName === consolation?.round) continue;
|
||||||
|
|
||||||
|
nextRank += matchesByRound.get(roundName)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rankedEntries;
|
||||||
|
}
|
||||||
|
|
||||||
/** Find the index of the first round that has scoring matches. */
|
/** Find the index of the first round that has scoring matches. */
|
||||||
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
||||||
for (let i = 0; i < rounds.length; i++) {
|
for (let i = 0; i < rounds.length; i++) {
|
||||||
|
|
@ -216,12 +348,10 @@ export function PlayoffBracket({
|
||||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||||
|
|
||||||
const thirdPlaceRound = template?.rounds
|
const consolation = findConsolationRound(template);
|
||||||
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
const thirdPlaceRound = consolation?.round;
|
||||||
?.name;
|
|
||||||
|
|
||||||
// Build elimination rankings
|
// Build elimination rankings
|
||||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
|
||||||
let bracketWinner: Participant | null = null;
|
let bracketWinner: Participant | null = null;
|
||||||
|
|
||||||
const lastRound = rounds[rounds.length - 1];
|
const lastRound = rounds[rounds.length - 1];
|
||||||
|
|
@ -230,43 +360,19 @@ export function PlayoffBracket({
|
||||||
: null;
|
: null;
|
||||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||||
|
|
||||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
|
||||||
|
|
||||||
for (const match of matches) {
|
|
||||||
if (!match.isComplete || !match.loser) continue;
|
|
||||||
const eliminatedInRound = eliminatedByRound.get(match.round);
|
|
||||||
if (!eliminatedInRound?.includes(match.loser.id)) continue;
|
|
||||||
const loserScore =
|
|
||||||
match.loserId === match.participant1Id
|
|
||||||
? match.participant1Score
|
|
||||||
: match.participant2Score;
|
|
||||||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
|
||||||
losersByRound.get(match.round)?.push({
|
|
||||||
participant: match.loser,
|
|
||||||
score: loserScore,
|
|
||||||
ownership: ownershipMap.get(match.loser.id) || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const allBracketParticipantIds = new Set<string>();
|
const allBracketParticipantIds = new Set<string>();
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
||||||
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
||||||
}
|
}
|
||||||
const rankedEntries: EliminatedEntry[] = [];
|
|
||||||
let nextRank = 2;
|
const rankedEntries = computeRankedEntries(
|
||||||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
matches,
|
||||||
const roundName = rounds[ri];
|
rounds,
|
||||||
const roundLosers = losersByRound.get(roundName) || [];
|
matchesByRound,
|
||||||
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
|
consolation,
|
||||||
if (roundLosers.length > 0) {
|
ownershipMap
|
||||||
const rankLabel = `T${nextRank}`;
|
);
|
||||||
for (const loser of roundLosers) {
|
|
||||||
rankedEntries.push({ ...loser, rankLabel });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextRank += totalMatchesInRound;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
PlayoffBracket,
|
||||||
|
buildFeederMap,
|
||||||
|
groupMatchesByRound,
|
||||||
|
computeEliminatedByRound,
|
||||||
|
computeRankedEntries,
|
||||||
|
findConsolationRound,
|
||||||
|
type Match,
|
||||||
|
} from "../PlayoffBracket";
|
||||||
|
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -291,3 +301,392 @@ describe("computeEliminatedByRound", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// computeRankedEntries — consolation ("third place") round handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
|
||||||
|
// losers feed it) and the Finals.
|
||||||
|
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
|
||||||
|
|
||||||
|
const FIFA_CONSOLATION = {
|
||||||
|
round: "Third Place Game",
|
||||||
|
feederRound: "Semifinals",
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatchOpts = {
|
||||||
|
/** Which slot the winner occupies. Defaults to 1. */
|
||||||
|
winnerSlot?: 1 | 2;
|
||||||
|
winnerScore?: string;
|
||||||
|
loserScore?: string;
|
||||||
|
/**
|
||||||
|
* Drop the hydrated `winner` relation, keeping `loser` and both ids — the shape a
|
||||||
|
* hand-built match object can arrive in. The loser is still placeable this way.
|
||||||
|
*/
|
||||||
|
missingWinnerRelation?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A completed match. */
|
||||||
|
function makeRankedMatch(
|
||||||
|
round: string,
|
||||||
|
matchNumber: number,
|
||||||
|
winnerId: string,
|
||||||
|
loserId: string,
|
||||||
|
opts: MatchOpts = {}
|
||||||
|
): Match {
|
||||||
|
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
|
||||||
|
const p1 = winnerIsP1 ? winnerId : loserId;
|
||||||
|
const p2 = winnerIsP1 ? loserId : winnerId;
|
||||||
|
return {
|
||||||
|
id: `${round}-${matchNumber}`,
|
||||||
|
round,
|
||||||
|
matchNumber,
|
||||||
|
participant1Id: p1,
|
||||||
|
participant2Id: p2,
|
||||||
|
winnerId,
|
||||||
|
loserId,
|
||||||
|
isComplete: true,
|
||||||
|
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
|
||||||
|
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
|
||||||
|
participant1: { id: p1, name: p1 },
|
||||||
|
participant2: { id: p2, name: p2 },
|
||||||
|
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
|
||||||
|
loser: { id: loserId, name: loserId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
|
||||||
|
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
|
||||||
|
*/
|
||||||
|
function makePendingMatch(
|
||||||
|
round: string,
|
||||||
|
matchNumber: number,
|
||||||
|
participant1Id: string | null,
|
||||||
|
participant2Id: string | null
|
||||||
|
): Match {
|
||||||
|
return {
|
||||||
|
id: `${round}-${matchNumber}`,
|
||||||
|
round,
|
||||||
|
matchNumber,
|
||||||
|
participant1Id,
|
||||||
|
participant2Id,
|
||||||
|
winnerId: null,
|
||||||
|
loserId: null,
|
||||||
|
isComplete: false,
|
||||||
|
participant1Score: null,
|
||||||
|
participant2Score: null,
|
||||||
|
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
|
||||||
|
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
|
||||||
|
winner: null,
|
||||||
|
loser: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
|
||||||
|
function fifaMatches(
|
||||||
|
overrides: { played?: boolean; thirdPlace?: Match } = {}
|
||||||
|
): Match[] {
|
||||||
|
const played = overrides.played ?? true;
|
||||||
|
return [
|
||||||
|
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||||
|
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||||
|
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||||
|
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||||
|
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||||
|
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||||
|
overrides.thirdPlace ??
|
||||||
|
(played
|
||||||
|
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
|
||||||
|
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
|
||||||
|
played
|
||||||
|
? makeRankedMatch("Finals", 1, "sfA", "sfC")
|
||||||
|
: makePendingMatch("Finals", 1, "sfA", "sfC"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
|
||||||
|
function rankFifa(
|
||||||
|
matches: Match[] = fifaMatches(),
|
||||||
|
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
|
||||||
|
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
|
||||||
|
) {
|
||||||
|
return computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
FIFA_ROUNDS,
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
consolation,
|
||||||
|
ownership
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
|
||||||
|
return entries.find((e) => e.participant.id === id)?.rankLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("findConsolationRound", () => {
|
||||||
|
it("identifies the fifa_48 third place game and the round that feeds it", () => {
|
||||||
|
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
|
||||||
|
round: "Third Place Game",
|
||||||
|
feederRound: "Semifinals",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a template with no consolation round", () => {
|
||||||
|
expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when there is no template", () => {
|
||||||
|
expect(findConsolationRound(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeRankedEntries", () => {
|
||||||
|
describe("fifa_48 third place game", () => {
|
||||||
|
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
|
||||||
|
// sfB lost the semifinal, then won the 3PG.
|
||||||
|
expect(rankOf(rankFifa(), "sfB")).toBe("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks the third place game loser 4th, not 3rd", () => {
|
||||||
|
expect(rankOf(rankFifa(), "sfD")).toBe("4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
|
||||||
|
const entries = rankFifa();
|
||||||
|
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||||
|
expect(rankOf(entries, "qf2")).toBe("T5");
|
||||||
|
expect(rankOf(entries, "qf3")).toBe("T5");
|
||||||
|
expect(rankOf(entries, "qf4")).toBe("T5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
|
||||||
|
const entries = rankFifa();
|
||||||
|
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||||
|
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
|
||||||
|
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
|
||||||
|
"T2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"T5",
|
||||||
|
"T5",
|
||||||
|
"T5",
|
||||||
|
"T5",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves semifinal losers unranked until the third place game is played", () => {
|
||||||
|
const entries = rankFifa(fifaMatches({ played: false }));
|
||||||
|
// Both SF losers are still alive for the 3PG.
|
||||||
|
expect(rankOf(entries, "sfB")).toBeUndefined();
|
||||||
|
expect(rankOf(entries, "sfD")).toBeUndefined();
|
||||||
|
// QF losers are still T5 — the later rounds still consume their positions.
|
||||||
|
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries ownership through onto the third place entries", () => {
|
||||||
|
const ownership = new Map([
|
||||||
|
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
|
||||||
|
]);
|
||||||
|
const entries = rankFifa(fifaMatches(), ownership);
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
|
||||||
|
"Team Nine"
|
||||||
|
);
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scores", () => {
|
||||||
|
it("reads each participant's own score regardless of which slot they occupied", () => {
|
||||||
|
const matches = fifaMatches({
|
||||||
|
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
|
||||||
|
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||||
|
winnerSlot: 2,
|
||||||
|
winnerScore: "3",
|
||||||
|
loserScore: "1",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const entries = rankFifa(matches);
|
||||||
|
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a loser's score from the slot they actually played in", () => {
|
||||||
|
const matches: Match[] = [
|
||||||
|
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||||
|
winnerSlot: 2,
|
||||||
|
winnerScore: "4",
|
||||||
|
loserScore: "2",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
["Semifinals"],
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
undefined,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no score for a participant who occupies neither slot", () => {
|
||||||
|
// A stale row after a bracket edit: loserId no longer matches either slot.
|
||||||
|
// Attributing the other team's score here would look entirely plausible.
|
||||||
|
const stale: Match = {
|
||||||
|
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||||
|
winnerScore: "4",
|
||||||
|
loserScore: "2",
|
||||||
|
}),
|
||||||
|
loserId: "ghost",
|
||||||
|
loser: { id: "ghost", name: "ghost" },
|
||||||
|
};
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
[stale],
|
||||||
|
["Semifinals"],
|
||||||
|
groupMatchesByRound([stale]),
|
||||||
|
undefined,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a consolation round somewhere other than 3rd/4th", () => {
|
||||||
|
// No template ships this today, but the positions must come from the feeder
|
||||||
|
// round rather than being hardcoded to 3 and 4.
|
||||||
|
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
|
||||||
|
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
|
||||||
|
|
||||||
|
const matches: Match[] = [
|
||||||
|
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||||
|
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||||
|
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||||
|
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||||
|
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||||
|
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||||
|
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
|
||||||
|
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
ROUNDS,
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
CONSOLATION,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rankOf(entries, "qf1")).toBe("5");
|
||||||
|
expect(rankOf(entries, "qf2")).toBe("6");
|
||||||
|
// The feeder round's other losers start below the pair, not alongside them.
|
||||||
|
expect(rankOf(entries, "qf3")).toBe("T7");
|
||||||
|
expect(rankOf(entries, "qf4")).toBe("T7");
|
||||||
|
// The rounds above it are unaffected.
|
||||||
|
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||||
|
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consolation matches that cannot be placed exactly", () => {
|
||||||
|
it("still ranks the loser when the winner relation is missing", () => {
|
||||||
|
const matches = fifaMatches({
|
||||||
|
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||||
|
missingWinnerRelation: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const entries = rankFifa(matches);
|
||||||
|
|
||||||
|
// The winner cannot be placed without a participant object, but the loser must
|
||||||
|
// not silently vanish the way it would if the round were skipped wholesale.
|
||||||
|
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
|
||||||
|
const matches: Match[] = [
|
||||||
|
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
|
||||||
|
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||||
|
];
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
["Third Place Game", "Finals"],
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
FIFA_CONSOLATION,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||||
|
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rendered output", () => {
|
||||||
|
/** The card the 3PG winner was incorrectly appearing in. */
|
||||||
|
function inContentionNames() {
|
||||||
|
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
|
||||||
|
if (!card) return [];
|
||||||
|
return within(card as HTMLElement)
|
||||||
|
.getAllByRole("row")
|
||||||
|
.map((r) => r.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does not list the third place game winner as in contention", () => {
|
||||||
|
render(
|
||||||
|
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// sfB won the third place game — they are finished, not still playing.
|
||||||
|
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still lists semifinalists as in contention before the third place game", () => {
|
||||||
|
render(
|
||||||
|
<PlayoffBracket
|
||||||
|
matches={fifaMatches({ played: false })}
|
||||||
|
rounds={FIFA_ROUNDS}
|
||||||
|
bracketTemplateId="fifa_48"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("brackets without a consolation round", () => {
|
||||||
|
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
|
||||||
|
|
||||||
|
it("ranks losers by round with tie labels, unchanged", () => {
|
||||||
|
const matches: Match[] = [
|
||||||
|
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||||
|
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||||
|
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||||
|
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||||
|
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||||
|
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||||
|
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
ROUNDS,
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
undefined,
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||||
|
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||||
|
expect(rankOf(entries, "sfD")).toBe("T3");
|
||||||
|
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||||
|
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue