Review caught that the generic feeder rule was being applied to templates that route by their own logic. It was harmless as dead code; driving the renderer with it made several brackets worse than before. The rule pairs rounds by array order and assumes match n is fed by 2n-1 and 2n. That describes advanceWinnerTemplate, not every bracket: - afl_10's Wildcard Round feeds the Elimination Finals, skipping the round listed next to it, so array order fabricated the entire chain and drew ten wrong connectors contradicting advanceAFLWinner. - fifa_48's Third Place Game sits between the Semifinals and the Finals, so the Finals came out fed by the third place game. Once BracketTreeView filtered the consolation round out, the group had three roots and the whole World Cup bracket rendered with no connectors at all. - ncaa_68 labelled Round of 64 #1/#2 with First Four feeds that advanceFirstFourWinner doesn't use. - nba_20's play-in halves in size but pairs the 7v8 loser with the 9v10 winner. Follow each round's declared feedsInto, and derive edges only where the round halves exactly — the condition under which the generic ceil(n/2) mapping is true. Bespoke transitions that happen to halve are named explicitly. Slots left without a feeder read TBD, which is honest. Dropping those edges sends the group to the fallback, so the fallback now has to keep drawing what those brackets already drew: halving U-shapes by round size, and winner tracing through irregular shapes. Previously it drew nothing, which also silently removed every connector from brackets with no bracketTemplateId. Also from review: - clear-bracket deleted seasonParticipantResults for the entire sports season with no rebuild. That table is keyed by season, not event, so it wiped placements for every other event in the season — permanently zeroing standings on a finalized qualifying season. Delete only the matches and point the admin at Reprocess Bracket, which rebuilds placements correctly. - The clear-bracket form sent confirm=true from a hidden field, making the server's completed-match guard unreachable. It's a checkbox now, so the guard is real, including without JS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnzbrCHoM8ESbtbDamaqFb
130 lines
4.9 KiB
TypeScript
130 lines
4.9 KiB
TypeScript
/**
|
|
* clear-bracket is the only path that can tear down a bracket, so the guard around it
|
|
* matters: it discards recorded results and the placements derived from them.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
findPlayoffMatchesByEventId,
|
|
deletePlayoffMatchesByEventId,
|
|
} from "~/models/playoff-match";
|
|
import { deleteParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
|
import { recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
|
import { getScoringEventById } from "~/models/scoring-event";
|
|
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
|
|
|
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
|
...(await importOriginal<object>()),
|
|
getScoringEventById: vi.fn(),
|
|
updateScoringEvent: vi.fn(),
|
|
isReadOnlySibling: vi.fn(() => false),
|
|
}));
|
|
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
|
...(await importOriginal<object>()),
|
|
findPlayoffMatchesByEventId: vi.fn(),
|
|
deletePlayoffMatchesByEventId: vi.fn(),
|
|
}));
|
|
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
|
...(await importOriginal<object>()),
|
|
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
|
}));
|
|
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
|
...(await importOriginal<object>()),
|
|
recalculateAffectedLeagues: vi.fn(),
|
|
}));
|
|
|
|
const EVENT = { id: "event-1", sportsSeasonId: "season-1" };
|
|
const params = { id: "season-1", eventId: "event-1" };
|
|
|
|
function clearRequest(confirm?: string): Request {
|
|
const body = new FormData();
|
|
body.set("intent", "clear-bracket");
|
|
if (confirm !== undefined) body.set("confirm", confirm);
|
|
return new Request("http://localhost/clear", { method: "POST", body });
|
|
}
|
|
|
|
function match(isComplete: boolean) {
|
|
return { id: `m-${Math.random()}`, isComplete };
|
|
}
|
|
|
|
// The action's real signature carries React Router's generated types; the clear path
|
|
// only reads request and params.
|
|
const run = (request: Request) =>
|
|
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
|
error?: string;
|
|
success?: string;
|
|
}>)({ request, params });
|
|
|
|
describe("clear-bracket", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(getScoringEventById).mockResolvedValue(
|
|
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
|
);
|
|
vi.mocked(deletePlayoffMatchesByEventId).mockResolvedValue(undefined);
|
|
vi.mocked(deleteParticipantResultsBySportsSeasonId).mockResolvedValue(undefined);
|
|
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
|
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
|
);
|
|
});
|
|
|
|
it("deletes the matches", async () => {
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
match(false),
|
|
match(false),
|
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
|
|
const result = await run(clearRequest());
|
|
|
|
expect(result.success).toContain("2 match(es) removed");
|
|
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
|
});
|
|
|
|
it("leaves placements alone — they belong to the whole season, not this event", async () => {
|
|
// seasonParticipantResults is keyed by sports season, so deleting here would wipe
|
|
// every other event's placements with nothing to rebuild them. Reprocess Bracket is
|
|
// the tool that rebuilds them correctly.
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
match(true),
|
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
|
|
const result = await run(clearRequest("true"));
|
|
|
|
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
|
expect(result.success).toContain("Reprocess Bracket");
|
|
});
|
|
|
|
it("refuses to discard completed matches without confirmation", async () => {
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
match(true),
|
|
match(false),
|
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
|
|
const result = await run(clearRequest());
|
|
|
|
expect(result.error).toContain("1 completed match(es)");
|
|
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("discards completed matches once confirmed", async () => {
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
|
match(true),
|
|
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
|
|
|
const result = await run(clearRequest("true"));
|
|
|
|
expect(result.success).toBeDefined();
|
|
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
|
});
|
|
|
|
it("rejects an event with no bracket rather than reporting a no-op success", async () => {
|
|
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
|
[] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
|
);
|
|
|
|
const result = await run(clearRequest("true"));
|
|
|
|
expect(result.error).toContain("no bracket to clear");
|
|
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
|
});
|
|
});
|