diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts new file mode 100644 index 000000000..68c6901ca --- /dev/null +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ + send: vi.fn(), + notifyNotificationsChanged: vi.fn(), + notifyRoomsChangedByRoomIds: vi.fn(), +})); + +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import type { TournamentSettings } from "~/db/tables-json"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import type { bracketSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; +import invariant from "~/utils/invariant"; +import { wrappedAction } from "~/utils/Test"; +import { action } from "./to.$id.brackets.server"; + +const bracketsAction = wrappedAction({ + action, + isJsonSubmission: true, +}); + +const TEAM_COUNT = 8; + +/** Two pools of four, the top two of each pool advancing to a single group swiss. */ +const POOLS_TO_SWISS: TournamentSettings["bracketProgression"] = [ + { + name: "Pools", + type: "round_robin", + requiresCheckIn: false, + settings: { teamsPerGroup: 4 }, + }, + { + name: "Swiss", + type: "swiss", + requiresCheckIn: false, + settings: { groupCount: 1, roundCount: 3 }, + sources: [{ bracketIdx: 0, placements: [1, 2] }], + }, +]; + +const users = UserFactory.pool(); +const organizerId = () => users.id(1); + +describe("Brackets action UNADVANCE_BRACKET", () => { + beforeEach(async () => { + await users.create(TEAM_COUNT); + }); + + test("deletes a swiss round of a follow-up swiss bracket whose own follow-ups have not started", async () => { + const tournament = await TournamentFactory.createPlayed( + { + authorId: organizerId(), + bracketProgression: POOLS_TO_SWISS, + minMembersPerTeam: 1, + }, + { + teamRosters: users.ids(TEAM_COUNT).map((userId) => [userId]), + playedOut: 0, + }, + ); + await TournamentFactory.startBracket(tournament.id, { bracketIdx: 1 }); + + const before = await tournamentFromDB(tournament.id); + const swiss = before.bracketByIdx(1); + invariant(swiss && !swiss.preview); + const group = swiss.data.group[0]; + const firstRound = swiss.data.round.find( + (round) => round.groupId === group.id && round.number === 1, + ); + invariant(firstRound); + expect( + swiss.data.match.filter((match) => match.roundId === firstRound.id) + .length, + ).toBeGreaterThan(0); + + const response = await bracketsAction( + { + _action: "UNADVANCE_BRACKET", + bracketIdx: 1, + groupId: group.id, + roundId: firstRound.id, + }, + { user: organizerId(), params: { id: String(tournament.id) } }, + ); + + expect( + response instanceof Response ? response.headers.get("Location") : null, + ).toBeNull(); + + const after = await tournamentFromDB(tournament.id); + const swissAfter = after.bracketByIdx(1); + invariant(swissAfter); + expect( + swissAfter.data.match.filter((match) => match.roundId === firstRound.id), + ).toHaveLength(0); + }); + + test("deletes a swiss round of a starting swiss bracket", async () => { + const tournament = await TournamentFactory.create({ + authorId: organizerId(), + bracketProgression: [ + { + name: "Swiss", + type: "swiss", + requiresCheckIn: false, + settings: { groupCount: 1, roundCount: 3 }, + }, + ], + minMembersPerTeam: 1, + }); + for (const userId of users.ids(TEAM_COUNT)) { + await TournamentTeamFactory.create( + { tournamentId: tournament.id, memberUserIds: [userId] }, + { isCheckedIn: true }, + ); + } + await TournamentFactory.startBracket(tournament.id, { bracketIdx: 0 }); + + const before = await tournamentFromDB(tournament.id); + const swiss = before.bracketByIdx(0); + invariant(swiss && !swiss.preview); + const group = swiss.data.group[0]; + const firstRound = swiss.data.round.find( + (round) => round.groupId === group.id && round.number === 1, + ); + invariant(firstRound); + + const response = await bracketsAction( + { + _action: "UNADVANCE_BRACKET", + bracketIdx: 0, + groupId: group.id, + roundId: firstRound.id, + }, + { user: organizerId(), params: { id: String(tournament.id) } }, + ); + + expect( + response instanceof Response ? response.headers.get("Location") : null, + ).toBeNull(); + }); +}); diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 5f2e7fa02..6c416f7d3 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -262,7 +262,7 @@ export const action: ActionFunction = async ({ params, request }) => { bracket.type === "swiss", "Can't unadvance non-swiss bracket", ); - errorToastIfFalsyNoFollowUpBrackets(tournament); + errorToastIfFalsyNoFollowUpBrackets(tournament, data.bracketIdx); await BracketRepository.deleteRoundMatches({ groupId: data.groupId, @@ -340,9 +340,12 @@ export const action: ActionFunction = async ({ params, request }) => { return null; }; -function errorToastIfFalsyNoFollowUpBrackets(tournament: Tournament) { +function errorToastIfFalsyNoFollowUpBrackets( + tournament: Tournament, + bracketIdx: number, +) { const followUpBrackets = tournament.brackets.filter((b) => - b.sources?.some((source) => source.bracketIdx === 0), + b.sources?.some((source) => source.bracketIdx === bracketIdx), ); errorToastIfFalsy( diff --git a/changelog/2026-09-02-swiss-round-delete.md b/changelog/2026-09-02-swiss-round-delete.md new file mode 100644 index 000000000..3c6719874 --- /dev/null +++ b/changelog/2026-09-02-swiss-round-delete.md @@ -0,0 +1,5 @@ +--- +navItem: calendar +type: bug +--- +Deleting a round of a Swiss bracket is no longer blocked when the Swiss bracket is not the tournament's first bracket