From 24f146ccda3becea48f954949ede8322df6dbe4e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 9 Dec 2024 22:02:34 +0200 Subject: [PATCH 1/7] Initial --- .../components/MatchRosters.tsx | 22 ++++++++-- .../components/TeamRosterInputs.tsx | 5 ++- .../tournament-bracket/core/Tournament.ts | 15 ++++++- .../core/summarizer.server.ts | 3 +- .../allMatchResultsByTournamentId.server.ts | 1 + .../routes/to.$id.matches.$mid.tsx | 6 +++ .../tournament-bracket-utils.ts | 42 +++++++++++++++++++ .../tournament/actions/to.$id.admin.server.ts | 21 +++++----- 8 files changed, 97 insertions(+), 18 deletions(-) diff --git a/app/features/tournament-bracket/components/MatchRosters.tsx b/app/features/tournament-bracket/components/MatchRosters.tsx index 97cb8fe67f..35e1ba083b 100644 --- a/app/features/tournament-bracket/components/MatchRosters.tsx +++ b/app/features/tournament-bracket/components/MatchRosters.tsx @@ -4,6 +4,7 @@ import { Avatar } from "~/components/Avatar"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { tournamentTeamPage, userPage } from "~/utils/urls"; import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; +import { isMatchTeamMember } from "../tournament-bracket-utils"; const INACTIVE_PLAYER_CSS = "tournament__team-with-roster__member__inactive text-lighter-important"; @@ -17,11 +18,24 @@ export function MatchRosters({ const teamOne = teams[0] ? tournament.teamById(teams[0]) : undefined; const teamTwo = teams[1] ? tournament.teamById(teams[1]) : undefined; - const teamOnePlayers = data.match.players.filter( - (p) => p.tournamentTeamId === teamOne?.id, + teamOne?.members[0].createdAt; + const teamOnePlayers = data.match.players.filter((participant) => + isMatchTeamMember({ + matchIsOver: data.matchIsOver, + team: teamOne, + participant, + tournament, + results: data.results, + }), ); - const teamTwoPlayers = data.match.players.filter( - (p) => p.tournamentTeamId === teamTwo?.id, + const teamTwoPlayers = data.match.players.filter((participant) => + isMatchTeamMember({ + matchIsOver: data.matchIsOver, + team: teamTwo, + participant, + tournament, + results: data.results, + }), ); const teamOneParticipatedPlayers = teamOnePlayers.filter((p) => diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx index 46df13c1eb..597bac0fee 100644 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx @@ -216,12 +216,12 @@ function _TeamRoster({ teamId={team.id} checkedPlayers={checkedInputPlayerIds()} presentational={!revising && (presentational || !editingRoster)} - handlePlayerClick={(playerId: number) => { + handlePlayerClick={(playerId) => { if (!setCheckedPlayers) return; setCheckedPlayers((oldPlayers) => { const newPlayers = clone(oldPlayers); - if (oldPlayers.flat().includes(playerId)) { + if (oldPlayers[idx].includes(playerId)) { newPlayers[idx] = newPlayers[idx].filter((id) => id !== playerId); } else { newPlayers[idx].push(playerId); @@ -452,6 +452,7 @@ function TeamRosterInputsCheckboxes({ name="playerName" disabled={mode() === "DISABLED" || mode() === "PRESENTATIONAL"} value={member.id} + // xxx: check checked={checkedPlayers.flat().includes(member.id)} onChange={() => handlePlayerClick(member.id)} data-testid={`player-checkbox-${i}`} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 98eafb3541..8e248726be 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -958,9 +958,22 @@ export class Tournament { teamMemberOfByUser(user: OptionalIdObject) { if (!user) return null; - return this.ctx.teams.find((team) => + const teams = this.ctx.teams.filter((team) => team.members.some((member) => member.userId === user.id), ); + + let result: (typeof teams)[number] | null = null; + let latestCreatedAt = 0; + for (const team of teams) { + const member = team.members.find((member) => member.userId === user.id)!; + + if (member.createdAt > latestCreatedAt) { + result = team; + latestCreatedAt = member.createdAt; + } + } + + return result; } teamMemberOfProgressStatus(user: OptionalIdObject) { diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index 8e6b86e6d8..d29f691bc5 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -16,6 +16,7 @@ import { removeDuplicates } from "~/utils/arrays"; import invariant from "~/utils/invariant"; import type { Tables } from "../../../db/tables"; import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server"; +import { ensureOneStandingPerUser } from "../tournament-bracket-utils"; import type { Standing } from "./Bracket"; export interface TournamentSummary { @@ -88,7 +89,7 @@ export function tournamentSummary({ : [], tournamentResults: tournamentResults({ participantCount: teams.length, - finalStandings, + finalStandings: ensureOneStandingPerUser(finalStandings), }), }; } diff --git a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts index b88ab050e5..9390c908d9 100644 --- a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts +++ b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts @@ -54,6 +54,7 @@ export interface AllMatchResult { stageId: StageId; mode: ModeShort; winnerTeamId: number; + // xxx: not enough info for result creation etc. userIds: number[]; }>; } diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx index 48096583c1..63979cf19b 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -186,6 +186,12 @@ export const action: ActionFunction = async ({ params, request }) => { validate(teamOneRoster, "Team one has no active roster"); validate(teamTwoRoster, "Team two has no active roster"); + validate( + new Set([...teamOneRoster, ...teamTwoRoster]).size === + tournament.minMembersPerTeam * 2, + "Duplicate user in rosters", + ); + sql.transaction(() => { manager.update.match({ id: match.id, diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 1538e3a2ba..be1566a1d9 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -12,6 +12,7 @@ import { removeDuplicates } from "~/utils/arrays"; import { sumArray } from "~/utils/number"; import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server"; import type { TournamentLoaderData } from "../tournament/routes/to.$id"; +import type { Standing } from "./core/Bracket"; import type { Tournament } from "./core/Tournament"; import type { TournamentDataTeam } from "./core/Tournament.server"; @@ -270,3 +271,44 @@ export function tournamentTeamToActiveRosterUserIds( return null; } + +// xxx: adjust +export function isMatchTeamMember(args: { + matchIsOver: boolean; + team?: TournamentDataTeam; + participant: { id: number; tournamentTeamId: number }; + tournament: Tournament; + results: Array<{ createdAt: number }>; +}) { + const isTeamMember = args.participant.tournamentTeamId === args.team?.id; + if (!isTeamMember) return false; + if (!args.matchIsOver) return isTeamMember; + + // it's possible they were added after the match ended + // so it could be a situation "Player" vs. "Player" + const matchEndedAt = args.results[args.results.length - 1].createdAt; + const memberAddedAt = + args.team?.members.find((member) => member.userId === args.participant.id) + ?.createdAt ?? 0; + + return memberAddedAt < matchEndedAt; +} + +// deal with user getting added to multiple teams by the TO +export function ensureOneStandingPerUser(standings: Standing[]) { + const userIds = new Set(); + + return standings.map((standing) => { + return { + ...standing, + team: { + ...standing.team, + members: standing.team.members.filter((member) => { + if (userIds.has(member.userId)) return false; + userIds.add(member.userId); + return true; + }), + }, + }; + }); +} diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.server.ts index 8afb027b65..1aaf81f889 100644 --- a/app/features/tournament/actions/to.$id.admin.server.ts +++ b/app/features/tournament/actions/to.$id.admin.server.ts @@ -195,14 +195,10 @@ export const action: ActionFunction = async ({ request, params }) => { const previousTeam = tournament.teamMemberOfByUser({ id: data.userId }); - if (tournament.hasStarted) { - validate( - !previousTeam || previousTeam.checkIns.length === 0, - "User is already on a checked in team", - ); - } else { - validate(!previousTeam, "User is already on a team"); - } + validate( + tournament.hasStarted || !previousTeam, + "User is already in a team", + ); validate( !userIsBanned(data.userId), @@ -213,8 +209,13 @@ export const action: ActionFunction = async ({ request, params }) => { userId: data.userId, newTeamId: team.id, previousTeamId: previousTeam?.id, - // this team is not checked in so we can simply delete it - whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined, + // this team is not checked in & tournament started, so we can simply delete it + whatToDoWithPreviousTeam: + previousTeam && + previousTeam.checkIns.length === 0 && + tournament.hasStarted + ? "DELETE" + : undefined, tournamentId, inGameName: await inGameNameIfNeeded({ tournament, From 5f7e817d1e559c09b6531be5e7597cad01cf08e8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 21 Dec 2024 14:27:55 +0200 Subject: [PATCH 2/7] Load as objects --- .../components/MatchActions.tsx | 26 ++++++++++++++----- .../components/StartedMatch.tsx | 4 ++- .../queries/findResultsByMatchId.server.ts | 18 ++++++++++--- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/app/features/tournament-bracket/components/MatchActions.tsx b/app/features/tournament-bracket/components/MatchActions.tsx index d347a5d872..b0d30daad4 100644 --- a/app/features/tournament-bracket/components/MatchActions.tsx +++ b/app/features/tournament-bracket/components/MatchActions.tsx @@ -41,12 +41,26 @@ export function MatchActions({ >(() => { if (result) { return [ - result.participantIds.filter((id) => - teams[0].members.some((member) => member.userId === id), - ), - result.participantIds.filter((id) => - teams[1].members.some((member) => member.userId === id), - ), + result.participants + .filter((participant) => + teams[0].members.some( + (member) => + member.userId === participant.userId && + (!participant.tournamentTeamId || + teams[0].id === participant.tournamentTeamId), + ), + ) + .map((p) => p.userId), + result.participants + .filter((participant) => + teams[1].members.some( + (member) => + member.userId === participant.userId && + (!participant.tournamentTeamId || + teams[1].id === participant.tournamentTeamId), + ), + ) + .map((p) => p.userId), ]; } diff --git a/app/features/tournament-bracket/components/StartedMatch.tsx b/app/features/tournament-bracket/components/StartedMatch.tsx index 7eae1d4710..d9c486fc30 100644 --- a/app/features/tournament-bracket/components/StartedMatch.tsx +++ b/app/features/tournament-bracket/components/StartedMatch.tsx @@ -630,7 +630,9 @@ function StartedMatchTabs({ teams[1], tournament.minMembersPerTeam, ), - result?.participantIds, + result?.participants + .map((p) => `${p.userId}-${p.tournamentTeamId}`) + .join(","), result?.opponentOnePoints, result?.opponentTwoPoints, ].join("-"); diff --git a/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts b/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts index b2d15d78f1..087f09ff9e 100644 --- a/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts +++ b/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts @@ -1,6 +1,6 @@ import { sql } from "~/db/sql"; import type { Tables } from "~/db/tables"; -import type { TournamentMatchGameResult, User } from "~/db/types"; +import type { TournamentMatchGameResult } from "~/db/types"; import { parseDBArray } from "~/utils/sql"; const stm = sql.prepare(/* sql */ ` @@ -12,7 +12,12 @@ const stm = sql.prepare(/* sql */ ` "TournamentMatchGameResult"."createdAt", "TournamentMatchGameResult"."opponentOnePoints", "TournamentMatchGameResult"."opponentTwoPoints", - json_group_array("TournamentMatchGameResultParticipant"."userId") as "participantIds" + json_group_array( + json_object( + 'tournamentTeamId', "TournamentMatchGameResultParticipant"."tournamentTeamId", + 'userId', "TournamentMatchGameResultParticipant"."userId" + ) + ) as "participants" from "TournamentMatchGameResult" left join "TournamentMatchGameResultParticipant" on "TournamentMatchGameResultParticipant"."matchGameResultId" = "TournamentMatchGameResult"."id" @@ -26,7 +31,12 @@ interface FindResultsByMatchIdResult { winnerTeamId: TournamentMatchGameResult["winnerTeamId"]; stageId: TournamentMatchGameResult["stageId"]; mode: TournamentMatchGameResult["mode"]; - participantIds: Array; + participants: Array< + Pick< + Tables["TournamentMatchGameResultParticipant"], + "tournamentTeamId" | "userId" + > + >; createdAt: TournamentMatchGameResult["createdAt"]; opponentOnePoints: Tables["TournamentMatchGameResult"]["opponentOnePoints"]; opponentTwoPoints: Tables["TournamentMatchGameResult"]["opponentTwoPoints"]; @@ -39,6 +49,6 @@ export function findResultsByMatchId( return rows.map((row) => ({ ...row, - participantIds: parseDBArray(row.participantIds), + participants: parseDBArray(row.participants), })); } From daeafc1fe04df5385713947397e985d2bb252e81 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 22 Dec 2024 10:10:55 +0200 Subject: [PATCH 3/7] fix types --- .../tournament-bracket/components/TeamRosterInputs.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx index 597bac0fee..dd1a884486 100644 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx @@ -174,8 +174,14 @@ function _TeamRoster({ ); const checkedInputPlayerIds = () => { - if (result?.participantIds && !revising) { - return result.participantIds; + if (result?.participants && !revising) { + return result.participants + .filter( + (participant) => + !participant.tournamentTeamId || + participant.tournamentTeamId === team.id, + ) + .map((participant) => participant.userId); } if (editingRoster) return checkedPlayers.split(",").map(Number); From 03cdc8b19229c62b131e78ffa6ee7a7748d000da Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 22 Dec 2024 10:46:37 +0200 Subject: [PATCH 4/7] Progress --- .../components/MatchActions.tsx | 2 +- .../components/MatchRosters.tsx | 22 ++++--------------- .../components/TeamRosterInputs.tsx | 3 +-- .../tournament-bracket-utils.ts | 22 ------------------- 4 files changed, 6 insertions(+), 43 deletions(-) diff --git a/app/features/tournament-bracket/components/MatchActions.tsx b/app/features/tournament-bracket/components/MatchActions.tsx index b0d30daad4..9cea07513d 100644 --- a/app/features/tournament-bracket/components/MatchActions.tsx +++ b/app/features/tournament-bracket/components/MatchActions.tsx @@ -354,7 +354,7 @@ function EditScoreForm({ return ( - isMatchTeamMember({ - matchIsOver: data.matchIsOver, - team: teamOne, - participant, - tournament, - results: data.results, - }), + const teamOnePlayers = data.match.players.filter( + (p) => p.tournamentTeamId === teamOne?.id, ); - const teamTwoPlayers = data.match.players.filter((participant) => - isMatchTeamMember({ - matchIsOver: data.matchIsOver, - team: teamTwo, - participant, - tournament, - results: data.results, - }), + const teamTwoPlayers = data.match.players.filter( + (p) => p.tournamentTeamId === teamTwo?.id, ); const teamOneParticipatedPlayers = teamOnePlayers.filter((p) => diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx index dd1a884486..8554b90c31 100644 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx @@ -458,8 +458,7 @@ function TeamRosterInputsCheckboxes({ name="playerName" disabled={mode() === "DISABLED" || mode() === "PRESENTATIONAL"} value={member.id} - // xxx: check - checked={checkedPlayers.flat().includes(member.id)} + checked={checkedPlayers.includes(member.id)} onChange={() => handlePlayerClick(member.id)} data-testid={`player-checkbox-${i}`} />{" "} diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index be1566a1d9..1c9e884d40 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -272,28 +272,6 @@ export function tournamentTeamToActiveRosterUserIds( return null; } -// xxx: adjust -export function isMatchTeamMember(args: { - matchIsOver: boolean; - team?: TournamentDataTeam; - participant: { id: number; tournamentTeamId: number }; - tournament: Tournament; - results: Array<{ createdAt: number }>; -}) { - const isTeamMember = args.participant.tournamentTeamId === args.team?.id; - if (!isTeamMember) return false; - if (!args.matchIsOver) return isTeamMember; - - // it's possible they were added after the match ended - // so it could be a situation "Player" vs. "Player" - const matchEndedAt = args.results[args.results.length - 1].createdAt; - const memberAddedAt = - args.team?.members.find((member) => member.userId === args.participant.id) - ?.createdAt ?? 0; - - return memberAddedAt < matchEndedAt; -} - // deal with user getting added to multiple teams by the TO export function ensureOneStandingPerUser(standings: Standing[]) { const userIds = new Set(); From ac987cf22757296f5531235f2211c7cd666c8c0f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 22 Dec 2024 12:05:41 +0200 Subject: [PATCH 5/7] Summarizer --- .../core/summarizer.server.ts | 162 ++++++++---------- .../core/summarizer.test.ts | 101 ++++++++++- .../allMatchResultsByTournamentId.server.ts | 38 +++- scripts/calc-seeding-skills.ts | 6 +- tsconfig.json | 2 +- 5 files changed, 192 insertions(+), 117 deletions(-) diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index d29f691bc5..c204eb0c1f 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -58,13 +58,10 @@ export function tournamentSummary({ seedingSkillCountsFor: Tables["SeedingSkill"]["type"] | null; calculateSeasonalStats?: boolean; }): TournamentSummary { - const userIdsToTeamId = userIdsToTeamIdRecord(teams); - return { skills: calculateSeasonalStats ? skills({ results, - userIdsToTeamId, queryCurrentTeamRating, queryCurrentUserRating, queryTeamPlayerRatingAverage, @@ -74,18 +71,15 @@ export function tournamentSummary({ ? calculateIndividualPlayerSkills({ queryCurrentUserRating: queryCurrentSeedingRating, results, - userIdsToTeamId, }).map((skill) => ({ ...skill, type: seedingSkillCountsFor, ordinal: ordinal(skill), })) : [], - mapResultDeltas: calculateSeasonalStats - ? mapResultDeltas({ results, userIdsToTeamId }) - : [], + mapResultDeltas: calculateSeasonalStats ? mapResultDeltas(results) : [], playerResultDeltas: calculateSeasonalStats - ? playerResultDeltas({ results, userIdsToTeamId }) + ? playerResultDeltas(results) : [], tournamentResults: tournamentResults({ participantCount: teams.length, @@ -108,7 +102,6 @@ export function userIdsToTeamIdRecord(teams: TeamsArg) { function skills(args: { results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; queryCurrentTeamRating: (identifier: string) => Rating; queryTeamPlayerRatingAverage: (identifier: string) => Rating; queryCurrentUserRating: (userId: number) => Rating; @@ -123,11 +116,9 @@ function skills(args: { export function calculateIndividualPlayerSkills({ results, - userIdsToTeamId, queryCurrentUserRating, }: { results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; queryCurrentUserRating: (userId: number) => Rating; }) { const userRatings = new Map(); @@ -145,12 +136,16 @@ export function calculateIndividualPlayerSkills({ ? match.opponentOne.id : match.opponentTwo.id; - const allUserIds = removeDuplicates(match.maps.flatMap((m) => m.userIds)); - const loserUserIds = allUserIds.filter( - (userId) => userIdsToTeamId[userId] !== winnerTeamId, + const participants = match.maps.flatMap((m) => m.participants); + const winnerUserIds = removeDuplicates( + participants + .filter((p) => p.tournamentTeamId === winnerTeamId) + .map((p) => p.userId), ); - const winnerUserIds = allUserIds.filter( - (userId) => userIdsToTeamId[userId] === winnerTeamId, + const loserUserIds = removeDuplicates( + participants + .filter((p) => p.tournamentTeamId !== winnerTeamId) + .map((p) => p.userId), ); const [ratedWinners, ratedLosers] = rate([ @@ -191,12 +186,10 @@ export function calculateIndividualPlayerSkills({ function calculateTeamSkills({ results, - userIdsToTeamId, queryCurrentTeamRating, queryTeamPlayerRatingAverage, }: { results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; queryCurrentTeamRating: (identifier: string) => Rating; queryTeamPlayerRatingAverage: (identifier: string) => Rating; }) { @@ -216,18 +209,18 @@ function calculateTeamSkills({ : match.opponentTwo.id; const winnerTeamIdentifiers = match.maps.flatMap((m) => { - const winnerUserIds = m.userIds.filter( - (userId) => userIdsToTeamId[userId] === winnerTeamId, - ); + const winnerUserIds = m.participants + .filter((p) => p.tournamentTeamId === winnerTeamId) + .map((p) => p.userId); return userIdsToIdentifier(winnerUserIds); }); const winnerTeamIdentifier = selectMostPopular(winnerTeamIdentifiers); const loserTeamIdentifiers = match.maps.flatMap((m) => { - const loserUserIds = m.userIds.filter( - (userId) => userIdsToTeamId[userId] !== winnerTeamId, - ); + const loserUserIds = m.participants + .filter((p) => p.tournamentTeamId !== winnerTeamId) + .map((p) => p.userId); return userIdsToIdentifier(loserUserIds); }); @@ -295,13 +288,9 @@ function selectMostPopular(items: T[]): T { return shuffle(mostPopularItems)[0][0]; } -function mapResultDeltas({ - results, - userIdsToTeamId, -}: { - results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; -}): TournamentSummary["mapResultDeltas"] { +function mapResultDeltas( + results: AllMatchResult[], +): TournamentSummary["mapResultDeltas"] { const result: TournamentSummary["mapResultDeltas"] = []; const addMapResult = ( @@ -331,18 +320,13 @@ function mapResultDeltas({ for (const match of results) { for (const map of match.maps) { - for (const userId of map.userIds) { - const tournamentTeamId = userIdsToTeamId[userId]; - invariant( - tournamentTeamId, - `Couldn't resolve tournament team id for user id ${userId}`, - ); - + for (const participant of map.participants) { addMapResult({ mode: map.mode, stageId: map.stageId, - type: tournamentTeamId === map.winnerTeamId ? "win" : "loss", - userId, + type: + participant.tournamentTeamId === map.winnerTeamId ? "win" : "loss", + userId: participant.userId, }); } } @@ -351,13 +335,9 @@ function mapResultDeltas({ return result; } -function playerResultDeltas({ - results, - userIdsToTeamId, -}: { - results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; -}): TournamentSummary["playerResultDeltas"] { +function playerResultDeltas( + results: AllMatchResult[], +): TournamentSummary["playerResultDeltas"] { const result: TournamentSummary["playerResultDeltas"] = []; const addPlayerResult = ( @@ -382,48 +362,46 @@ function playerResultDeltas({ for (const match of results) { for (const map of match.maps) { - for (const ownerUserId of map.userIds) { - for (const otherUserId of map.userIds) { - if (ownerUserId === otherUserId) continue; - - const ownTournamentTeamId = userIdsToTeamId[ownerUserId]; - invariant( - ownTournamentTeamId, - `Couldn't resolve tournament team id for user id ${ownerUserId}`, - ); - const otherTournamentTeamId = userIdsToTeamId[otherUserId]; - invariant( - otherTournamentTeamId, - `Couldn't resolve tournament team id for user id ${otherUserId}`, - ); - - const won = ownTournamentTeamId === map.winnerTeamId; + for (const ownerParticipant of map.participants) { + for (const otherParticipant of map.participants) { + if (ownerParticipant.userId === otherParticipant.userId) continue; + + const won = ownerParticipant.tournamentTeamId === map.winnerTeamId; addPlayerResult({ - ownerUserId, - otherUserId, + ownerUserId: ownerParticipant.userId, + otherUserId: otherParticipant.userId, mapLosses: won ? 0 : 1, mapWins: won ? 1 : 0, setLosses: 0, setWins: 0, type: - ownTournamentTeamId === otherTournamentTeamId ? "MATE" : "ENEMY", + ownerParticipant.tournamentTeamId === + otherParticipant.tournamentTeamId + ? "MATE" + : "ENEMY", }); } } } - const mostPopularUserIds = (() => { + const mostPopularParticipants = (() => { const alphaIdentifiers: string[] = []; const bravoIdentifiers: string[] = []; for (const map of match.maps) { - const alphaUserIds = map.userIds.filter( - (userId) => userIdsToTeamId[userId] === match.opponentOne.id, - ); - const bravoUserIds = map.userIds.filter( - (userId) => userIdsToTeamId[userId] === match.opponentTwo.id, - ); + const alphaUserIds = map.participants + .filter( + (participant) => + participant.tournamentTeamId === match.opponentOne.id, + ) + .map((p) => p.userId); + const bravoUserIds = map.participants + .filter( + (participant) => + participant.tournamentTeamId === match.opponentTwo.id, + ) + .map((p) => p.userId); alphaIdentifiers.push(userIdsToIdentifier(alphaUserIds)); bravoIdentifiers.push(userIdsToIdentifier(bravoUserIds)); @@ -433,41 +411,39 @@ function playerResultDeltas({ const bravoIdentifier = selectMostPopular(bravoIdentifiers); return [ - ...identifierToUserIds(alphaIdentifier), - ...identifierToUserIds(bravoIdentifier), + ...identifierToUserIds(alphaIdentifier).map((id) => ({ + userId: id, + tournamentTeamId: match.opponentOne.id, + })), + ...identifierToUserIds(bravoIdentifier).map((id) => ({ + userId: id, + tournamentTeamId: match.opponentTwo.id, + })), ]; })(); - for (const ownerUserId of mostPopularUserIds) { - for (const otherUserId of mostPopularUserIds) { - if (ownerUserId === otherUserId) continue; - - const ownTournamentTeamId = userIdsToTeamId[ownerUserId]; - invariant( - ownTournamentTeamId, - `Couldn't resolve tournament team id for user id ${ownerUserId}`, - ); - const otherTournamentTeamId = userIdsToTeamId[otherUserId]; - invariant( - otherTournamentTeamId, - `Couldn't resolve tournament team id for user id ${otherUserId}`, - ); + for (const ownerParticipant of mostPopularParticipants) { + for (const otherParticipant of mostPopularParticipants) { + if (ownerParticipant.userId === otherParticipant.userId) continue; const result = - match.opponentOne.id === ownTournamentTeamId + match.opponentOne.id === ownerParticipant.tournamentTeamId ? match.opponentOne.result : match.opponentTwo.result; const won = result === "win"; addPlayerResult({ - ownerUserId, - otherUserId, + ownerUserId: ownerParticipant.userId, + otherUserId: otherParticipant.userId, mapLosses: 0, mapWins: 0, setLosses: won ? 0 : 1, setWins: won ? 1 : 0, type: - ownTournamentTeamId === otherTournamentTeamId ? "MATE" : "ENEMY", + ownerParticipant.tournamentTeamId === + otherParticipant.tournamentTeamId + ? "MATE" + : "ENEMY", }); } } diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 19e91405df..dd2d5d4331 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -72,13 +72,31 @@ describe("tournamentSummary()", () => { { mode: "SZ", stageId: 1, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, { mode: "TC", stageId: 2, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, ], @@ -182,13 +200,31 @@ describe("tournamentSummary()", () => { { mode: "SZ", stageId: 1, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, { mode: "TC", stageId: 2, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, ], @@ -208,13 +244,31 @@ describe("tournamentSummary()", () => { { mode: "SZ", stageId: 1, - userIds: [1, 20, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 20 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, { mode: "TC", stageId: 2, - userIds: [1, 20, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 20 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, ], @@ -265,19 +319,46 @@ describe("tournamentSummary()", () => { { mode: "SZ", stageId: 1, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, { mode: "TC", stageId: 2, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 2, }, { mode: "TC", stageId: 2, - userIds: [1, 20, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 20 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, ], @@ -371,3 +452,5 @@ describe("tournamentSummary()", () => { expect(result.every((r) => r.wins === 1 && r.losses === 0)).toBeTruthy(); }); }); + +// xxx: test dupes diff --git a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts index 9390c908d9..a4d773cde5 100644 --- a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts +++ b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts @@ -1,12 +1,18 @@ import { sql } from "~/db/sql"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; +import invariant from "~/utils/invariant"; import { parseDBArray, parseDBJsonArray } from "~/utils/sql"; const stm = sql.prepare(/* sql */ ` with "q1" as ( select "TournamentMatchGameResult".*, - json_group_array("TournamentMatchGameResultParticipant"."userId") as "userIds" + json_group_array( + json_object( + 'tournamentTeamId', "TournamentMatchGameResultParticipant"."tournamentTeamId", + 'userId', "TournamentMatchGameResultParticipant"."userId" + ) + ) as "participants" from "TournamentMatchGameResult" left join "TournamentMatchGameResultParticipant" on "TournamentMatchGameResultParticipant"."matchGameResultId" = "TournamentMatchGameResult"."id" group by "TournamentMatchGameResult"."id" @@ -26,8 +32,8 @@ const stm = sql.prepare(/* sql */ ` "q1"."mode", 'winnerTeamId', "q1"."winnerTeamId", - 'userIds', - "q1"."userIds" + 'participants', + "q1"."participants" ) ) as "maps" from @@ -54,8 +60,11 @@ export interface AllMatchResult { stageId: StageId; mode: ModeShort; winnerTeamId: number; - // xxx: not enough info for result creation etc. - userIds: number[]; + participants: Array<{ + // in the DB this can actually also be null, but for new tournaments it should always be a number + tournamentTeamId: number; + userId: number; + }>; }>; } @@ -76,10 +85,21 @@ export function allMatchResultsByTournamentId( score: row.opponentTwoScore, result: row.opponentTwoResult, }, - maps: parseDBJsonArray(row.maps).map((map: any) => ({ - ...map, - userIds: parseDBArray(map.userIds), - })), + maps: parseDBJsonArray(row.maps).map((map: any) => { + const participants = parseDBArray(map.participants); + invariant(participants.length > 0, "No participants found"); + invariant( + participants.every( + (p: any) => typeof p.tournamentTeamId === "number", + ), + "Some participants have no team id", + ); + + return { + ...map, + participants, + }; + }), }; }); } diff --git a/scripts/calc-seeding-skills.ts b/scripts/calc-seeding-skills.ts index feb61ead03..a2efd2afd5 100644 --- a/scripts/calc-seeding-skills.ts +++ b/scripts/calc-seeding-skills.ts @@ -3,10 +3,7 @@ import { type Rating, ordinal, rating } from "openskill"; import { db } from "../app/db/sql"; import type { Tables } from "../app/db/tables"; import { tournamentFromDB } from "../app/features/tournament-bracket/core/Tournament.server"; -import { - calculateIndividualPlayerSkills, - userIdsToTeamIdRecord, -} from "../app/features/tournament-bracket/core/summarizer.server"; +import { calculateIndividualPlayerSkills } from "../app/features/tournament-bracket/core/summarizer.server"; import { allMatchResultsByTournamentId } from "../app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server"; import invariant from "../app/utils/invariant"; import { logger } from "../app/utils/logger"; @@ -29,7 +26,6 @@ async function main() { return ratings.get(userId) ?? rating(); }, results, - userIdsToTeamId: userIdsToTeamIdRecord(tournament.ctx.teams), }); for (const { userId, mu, sigma } of skills) { diff --git a/tsconfig.json b/tsconfig.json index 09ddd911ea..54ebb2a01e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "include": ["./types/env.d.ts", "**/*.ts", "**/*.tsx"], "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2019"], + "lib": ["DOM", "DOM.Iterable", "es2024"], "module": "ESNext", "isolatedModules": true, "esModuleInterop": true, From 0656e960a84dcf384c92fc926947c73363065a8d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 27 Dec 2024 21:23:00 +0200 Subject: [PATCH 6/7] Show alert about readding player --- .../tournament/routes/to.$id.admin.tsx | 218 ++++++++++-------- e2e/tournament-bracket.spec.ts | 1 + 2 files changed, 123 insertions(+), 96 deletions(-) diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index a478e9985b..a6127b54ef 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -26,6 +26,7 @@ import { tournamentEditPage, tournamentPage, } from "~/utils/urls"; +import { Alert } from "../../../components/Alert"; import { Dialog } from "../../../components/Dialog"; import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector"; import { useTournament } from "./to.$id"; @@ -201,6 +202,7 @@ function TeamActions() { ? actions.find((a) => a.when.length === 0)! : actions[0], ); + const [selectedUserId, setSelectedUserId] = React.useState(); const selectedTeam = tournament.teamById(selectedTeamId); @@ -251,113 +253,137 @@ function TeamActions() { return true; }); + const showAlreadyInTeamAlert = () => { + if (selectedAction.type !== "ADD_MEMBER") return false; + if ( + !selectedUserId || + !tournament.teamMemberOfByUser({ id: selectedUserId }) + ) { + return false; + } + + return true; + }; + return ( - -
- - -
- {selectedAction.inputs.includes("REGISTERED_TEAM") ? ( +
+
- + -
- ) : null} - {selectedAction.inputs.includes("TEAM_NAME") ? ( -
- - -
- ) : null} - {selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? ( -
- - -
- ) : null} - {selectedAction.inputs.includes("USER") ? ( -
- - -
- ) : null} - {selectedAction.inputs.includes("BRACKET") ? ( -
- -
- ) : null} - {selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? ( -
- -
- -
#
- + + +
+ ) : null} + {selectedAction.inputs.includes("TEAM_NAME") ? ( +
+ + +
+ ) : null} + {selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? ( +
+ + +
+ ) : null} + {selectedAction.inputs.includes("USER") ? ( +
+ + setSelectedUserId(newUser.id)} />
-
+ ) : null} + {selectedAction.inputs.includes("BRACKET") ? ( +
+ + +
+ ) : null} + {selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? ( +
+ +
+ +
#
+ +
+
+ ) : null} + + Go + +
+ {showAlreadyInTeamAlert() ? ( + This player is already in a team ) : null} - - Go - - +
); } diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 6f2fd9bf47..5c84c00610 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -447,6 +447,7 @@ test.describe("Tournament bracket", () => { ).toHaveCount(3); }); + // xxx: e2e test for adding dupe player test("changes SOS format and progresses with it", async ({ page }) => { const tournamentId = 4; From 05b1b40e1687f673601bb604e0469cec98c74ce8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 28 Dec 2024 11:04:11 +0200 Subject: [PATCH 7/7] Tests --- .../core/summarizer.test.ts | 19 ++++++++-- app/features/tournament/routes/to.$id.tsx | 7 +++- e2e/tournament-bracket.spec.ts | 36 ++++++++++++++----- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index dd2d5d4331..945caf9beb 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -43,15 +43,20 @@ describe("tournamentSummary()", () => { function summarize({ results, seedingSkillCountsFor, + withMemberInTwoTeams = false, }: { results?: AllMatchResult[]; seedingSkillCountsFor?: Tables["SeedingSkill"]["type"]; + withMemberInTwoTeams?: boolean; } = {}) { return tournamentSummary({ finalStandings: [ { placement: 1, - team: createTeam(1, [1, 2, 3, 4]), + team: createTeam( + 1, + withMemberInTwoTeams ? [1, 2, 3, 4, 5] : [1, 2, 3, 4], + ), }, { placement: 2, @@ -159,6 +164,16 @@ describe("tournamentSummary()", () => { expect(summary.tournamentResults.length).toBe(4 * 4); }); + test("calculates final standings, handling a player in two teams", () => { + const summary = summarize({ withMemberInTwoTeams: true }); + expect( + summary.tournamentResults.some( + (result) => result.tournamentTeamId === 1 && result.userId === 5, + ), + ).toBeTruthy(); + expect(summary.tournamentResults.length).toBe(4 * 4); + }); + test("winners skill should go up, losers skill should go down", () => { const summary = summarize(); const winnerSkill = summary.skills.find((s) => s.userId === 1); @@ -452,5 +467,3 @@ describe("tournamentSummary()", () => { expect(result.every((r) => r.wins === 1 && r.losses === 0)).toBeTruthy(); }); }); - -// xxx: test dupes diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index d81623c44f..f9eee557c7 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -251,7 +251,12 @@ export function TournamentLayout() { {t("tournament:tabs.brackets")} - + {t("tournament:tabs.teams", { count: tournament.ctx.teams.length })} {!tournament.everyBracketOver && tournament.subsFeatureEnabled && ( diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 5c84c00610..16e4184357 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -6,6 +6,7 @@ import { isNotVisible, navigate, seed, + selectUser, submit, } from "~/utils/playwright"; import { @@ -131,13 +132,6 @@ const backToBracket = async (page: Page) => { const expectScore = (page: Page, score: [number, number]) => expect(page.getByText(score.join("-"))).toBeVisible(); -// 1) Report winner of N-ZAP's first match -// 2) Report winner of the adjacent match by using admin powers -// 3) Report one match on the only losers side match available -// 4) Try to reopen N-ZAP's first match and fail -// 5) Undo score of first losers match -// 6) Try to reopen N-ZAP's first match and succeed -// 7) As N-ZAP, undo all scores and switch to different team sweeping test.describe("Tournament bracket", () => { test("sets active roster as regular member", async ({ page }) => { const tournamentId = 1; @@ -179,6 +173,13 @@ test.describe("Tournament bracket", () => { ).not.toBeChecked(); }); + // 1) Report winner of N-ZAP's first match + // 2) Report winner of the adjacent match by using admin powers + // 3) Report one match on the only losers side match available + // 4) Try to reopen N-ZAP's first match and fail + // 5) Undo score of first losers match + // 6) Try to reopen N-ZAP's first match and succeed + // 7) As N-ZAP, undo all scores and switch to different team sweeping test("reports score and sees bracket update", async ({ page }) => { const tournamentId = 2; await startBracket(page); @@ -447,8 +448,9 @@ test.describe("Tournament bracket", () => { ).toHaveCount(3); }); - // xxx: e2e test for adding dupe player - test("changes SOS format and progresses with it", async ({ page }) => { + test("changes SOS format and progresses with it & adds a member to another team", async ({ + page, + }) => { const tournamentId = 4; await seed(page, "SMALL_SOS"); @@ -491,6 +493,22 @@ test.describe("Tournament bracket", () => { await page.locator('[data-match-id="7"]').click(); await expect(page.getByTestId("back-to-bracket-button")).toBeVisible(); + + await page.getByTestId("admin-tab").click(); + await page.getByLabel("Action").selectOption("ADD_MEMBER"); + await page.getByLabel("Team", { exact: true }).selectOption("303"); // a team in the Mako bracket + await selectUser({ + labelName: "User", + userName: "Sendou", + page, + }); + await submit(page); + + await page.getByTestId("teams-tab").click(); + + await expect( + page.getByTestId("team-member-name").getByText("Sendou"), + ).toHaveCount(2); }); test("organizer edits a match after it is done", async ({ page }) => {