/to/:id/admin allow editing tournament team map pool if the format has one
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

Closes #3322
This commit is contained in:
Kalle
2026-08-08 13:59:17 +03:00
parent 2cd9cf8214
commit d42a864f77
15 changed files with 451 additions and 140 deletions

View File

@@ -76,6 +76,8 @@ export const action = async (args: ActionFunctionArgs) => {
userId: member.userId,
inGameName: member.inGameName ?? null,
})),
// the API can't edit counterpick maps, so the team's existing pool is carried over as is
mapPool: existingTeam?.mapPool ?? [],
}),
});

View File

@@ -186,7 +186,11 @@ function MapButton({
data-testid={testId}
/>
{selected ? (
<Check className={styles.mapButtonIcon} onClick={onClick} />
<Check
className={styles.mapButtonIcon}
onClick={onClick}
data-testid={`${testId}-picked`}
/>
) : null}
{tiebreaker ? (
<div className={styles.mapButtonText}>Tiebreak</div>

View File

@@ -4,6 +4,7 @@ import {
redirect,
} from "react-router";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { notify } from "~/features/notifications/core/notify.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
@@ -102,6 +103,13 @@ export const upsertRegistrationAction = async (
}))
: [];
// the map pool field is only shown while it can still be changed, so a submission
// from any other state says nothing about the pool the team has
const mapPool =
tournament.teamsPrePickMaps && !tournament.hasStarted
? new MapPool(data.mapPool)
: undefined;
const { appliedTournamentNameChanges } =
await TournamentTeamRepository.upsertRegistration({
tournamentTeamId: team?.id,
@@ -115,6 +123,7 @@ export const upsertRegistrationAction = async (
membersToRemove,
inGameNameUpdates,
tournamentNameUpdates,
mapPool,
});
for (const change of appliedTournamentNameChanges) {

View File

@@ -4,13 +4,22 @@ import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData } from "react-router";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { Label } from "~/components/Label";
import { useUser } from "~/features/auth/core/user";
import {
type CounterPickMapPool,
CounterPickMapPoolPicker,
MapPoolValidationStatusMessage,
useCounterPickMapPoolValidationStatus,
} from "~/features/tournament/components/CounterPickMapPoolPicker";
import { useTournament } from "~/features/tournament/tournament-context";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import { FormField } from "~/form/FormField";
import { FormFieldMessages } from "~/form/fields/FormFieldWrapper";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import type {
ArrayItemRenderContext,
CustomFieldRenderProps,
SelectOption,
TeamSearchFieldOptions,
TournamentSearchFieldOptions,
@@ -79,6 +88,7 @@ export default function TournamentAdminRegistrationPage() {
: null,
tournamentName: member.tournamentName ?? null,
})),
mapPool: team.mapPool ?? [],
}
: undefined;
@@ -298,10 +308,48 @@ function RegistrationFields({ team }: { team: TournamentTeamFull | null }) {
)}
</FormField>
<FormField name="ownerId" options={ownerOptions} />
{tournament.teamsPrePickMaps && !tournament.hasStarted ? (
<FormField name="mapPool">
{(props: CustomFieldRenderProps) => (
<MapPoolField
{...props}
value={props.value as CounterPickMapPool}
/>
)}
</FormField>
) : null}
</>
);
}
function MapPoolField({
name,
value,
error,
onChange,
disabled,
}: CustomFieldRenderProps<CounterPickMapPool>) {
const { t } = useTranslation(["common"]);
const validationStatus = useCounterPickMapPoolValidationStatus(value);
// the pickers are a group of buttons rather than one control, so there is no
// input for the label to point at
return (
<div className="stack sm">
<Label spaced={false}>{t("common:maps.mapPool")}</Label>
<div className="stack lg">
<CounterPickMapPoolPicker
mapPool={value}
onChange={onChange}
disabled={disabled}
/>
<MapPoolValidationStatusMessage status={validationStatus} />
</div>
<FormFieldMessages name={name} error={error} />
</div>
);
}
function ImportTeamSection({
currentTournamentId,
onImport,

View File

@@ -1,6 +1,12 @@
import { z } from "zod";
import { userIsBanned } from "~/features/ban/core/banned.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
isOneModeTournamentOf,
validateCounterPickMapPool,
} from "~/features/tournament/tournament-utils";
import { tournamentTeamNameTaken } from "~/features/tournament/tournament-utils.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import * as UserRepository from "~/features/user-page/UserRepository.server";
@@ -49,6 +55,46 @@ export function adminRegistrationFormSchemaServer({
});
}
// the map pool is only written while it can still be changed, matching the
// form field's own visibility, so any other state says nothing about it
if (tournament.teamsPrePickMaps && !tournament.hasStarted) {
const currentMapPool =
typeof data.tournamentTeamId === "number"
? ((
await TournamentTeamRepository.findMapPoolsByTeamIds([
data.tournamentTeamId,
])
).get(data.tournamentTeamId) ?? [])
: [];
// a pool valid when picked can stop being valid later (a map gets banned, the
// tie-breaker pool changes), so only a changed pool is held to being valid and
// an untouched one can't block unrelated edits to the team
const mapPoolChanged =
MapPool.serialize(data.mapPool) !== MapPool.serialize(currentMapPool);
if (mapPoolChanged) {
const invalidMode = data.mapPool.some(
(map) => !tournament.modesIncluded.includes(map.mode),
);
const status = validateCounterPickMapPool(
new MapPool(data.mapPool),
isOneModeTournamentOf(
tournament.ctx.mapPickingStyle,
tournament.ctx.toSetMapPool,
),
tournament.ctx.tieBreakerMapPool,
);
if (invalidMode || status !== "VALID") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "forms:errors.invalidMapPool",
path: ["mapPool"],
});
}
}
}
const team =
typeof data.tournamentTeamId === "number"
? tournament.teamById(data.tournamentTeamId)

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import {
array,
customField,
fieldset,
idConstantOptional,
image,
@@ -13,6 +14,7 @@ import {
tournamentSearchOptional,
userSearch,
} from "~/form/fields";
import { modeShort, stageId } from "~/utils/zod";
import { IN_GAME_NAME_MAX_LENGTH } from "../user-page/in-game-name";
import { USER } from "../user-page/user-page-constants";
/**
@@ -64,6 +66,10 @@ export const adminRegistrationFormSchema = z
max: ADMIN_REGISTRATION_MAX_MEMBERS,
field: memberFieldset,
}),
mapPool: customField(
{ initialValue: [] },
z.array(z.object({ mode: modeShort, stageId })),
),
})
.superRefine((data, ctx) => {
if (data.linkedTeam) {

View File

@@ -211,11 +211,11 @@ export function insert({
/**
* Creates a new registration or applies a full-state edit to an existing one in a
* single transaction: team name, linked sendou.ink team, owner assignment/transfer,
* member adds/removes, in-game name updates and tournament name updates. Pass
* `tournamentTeamId` to edit an existing team, or omit it to create a new one (all
* members are then "added" and `ownerUserId` becomes the owner). The caller is
* responsible for validating the derived ops and for any side effects (cache updates,
* notifications) outside the transaction.
* member adds/removes, in-game name updates, tournament name updates and the
* counterpick map pool. Pass `tournamentTeamId` to edit an existing team, or omit it
* to create a new one (all members are then "added" and `ownerUserId` becomes the
* owner). The caller is responsible for validating the derived ops and for any side
* effects (cache updates, notifications) outside the transaction.
*
* Returns the tournament name changes that were actually applied (submitted values
* equal to the user's current one are no-ops), for the caller to log.
@@ -232,6 +232,7 @@ export function upsertRegistration({
membersToRemove,
inGameNameUpdates,
tournamentNameUpdates,
mapPool,
}: {
/** Present when editing an existing team, omitted when creating a new one. */
tournamentTeamId?: number;
@@ -253,6 +254,8 @@ export function upsertRegistration({
userId: number;
tournamentName: string | null;
}>;
/** Counterpick map pool to replace the team's with. Omitted leaves it as is. */
mapPool?: MapPool;
}) {
const isNew = typeof tournamentTeamId !== "number";
@@ -301,6 +304,10 @@ export function upsertRegistration({
});
}
if (mapPool) {
await replaceCounterpickMaps(trx, { tournamentTeamId: id, mapPool });
}
for (const userId of membersToRemove) {
await TournamentAuditLogRepository.insert(
{
@@ -956,30 +963,40 @@ export function leave({
});
}
export function upsertCounterpickMaps({
tournamentTeamId,
mapPool,
}: {
export function upsertCounterpickMaps(args: {
tournamentTeamId: Tables["TournamentTeam"]["id"];
mapPool: MapPool;
}) {
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("MapPoolMap")
.where("MapPoolMap.tournamentTeamId", "=", tournamentTeamId)
.execute();
return db.transaction().execute((trx) => replaceCounterpickMaps(trx, args));
}
await trx
.insertInto("MapPoolMap")
.values(
mapPool.stageModePairs.map(({ stageId, mode }) => ({
tournamentTeamId,
stageId,
mode,
})),
)
.execute();
});
async function replaceCounterpickMaps(
trx: Transaction<DB>,
{
tournamentTeamId,
mapPool,
}: {
tournamentTeamId: Tables["TournamentTeam"]["id"];
mapPool: MapPool;
},
) {
await trx
.deleteFrom("MapPoolMap")
.where("MapPoolMap.tournamentTeamId", "=", tournamentTeamId)
.execute();
if (mapPool.stageModePairs.length === 0) return;
await trx
.insertInto("MapPoolMap")
.values(
mapPool.stageModePairs.map(({ stageId, mode }) => ({
tournamentTeamId,
stageId,
mode,
})),
)
.execute();
}
async function findTeamRecentMaps(teamId: number, limit: number) {

View File

@@ -0,0 +1,111 @@
import { useTranslation } from "react-i18next";
import { Alert } from "~/components/Alert";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { TOURNAMENT } from "../tournament-constants";
import { useTournament } from "../tournament-context";
import {
type CounterPickValidationStatus,
isOneModeTournamentOf,
validateCounterPickMapPool,
} from "../tournament-utils";
export type CounterPickMapPool = Array<{
mode: ModeShort;
stageId: StageId;
}>;
/** Picker for the counterpick maps of a team, one picker per mode the tournament is played on. */
export function CounterPickMapPoolPicker({
mapPool,
onChange,
disabled,
}: {
mapPool: CounterPickMapPool;
onChange: (mapPool: CounterPickMapPool) => void;
disabled?: boolean;
}) {
const tournament = useTournament();
const oneModeOnlyFor = oneModeTournamentOf(tournament);
return (
<>
{tournament.modesIncluded.map((mode) => (
<ModeMapPoolPicker
key={mode}
amountToPick={
oneModeOnlyFor
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE
}
mode={mode}
tiebreaker={
tournament.ctx.tieBreakerMapPool.find(
(stage) => stage.mode === mode,
)?.stageId
}
pool={mapPool
.filter((map) => map.mode === mode)
.map((map) => map.stageId)}
onChange={(stageIds) =>
onChange([
...mapPool.filter((map) => map.mode !== mode),
...stageIds.map((stageId) => ({ mode, stageId })),
])
}
disabled={disabled}
/>
))}
</>
);
}
/** Validates a counterpick map pool against the tournament being viewed. */
export function useCounterPickMapPoolValidationStatus(
mapPool: CounterPickMapPool,
) {
const tournament = useTournament();
return validateCounterPickMapPool(
new MapPool(mapPool),
oneModeTournamentOf(tournament),
tournament.ctx.tieBreakerMapPool,
);
}
/** Explains why a counterpick map pool can't be saved. Renders nothing for statuses without an explanation. */
export function MapPoolValidationStatusMessage({
status,
}: {
status: CounterPickValidationStatus;
}) {
const { t } = useTranslation(["common"]);
if (
status !== "TOO_MUCH_STAGE_REPEAT" &&
status !== "STAGE_REPEAT_IN_SAME_MODE" &&
status !== "INCLUDES_BANNED" &&
status !== "INCLUDES_TIEBREAKER"
) {
return null;
}
return (
<div className="mt-4">
<Alert alertClassName="w-max" variation="WARNING" tiny>
{t(`common:maps.validation.${status}`, {
maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT,
})}
</Alert>
</div>
);
}
function oneModeTournamentOf(tournament: Tournament): ModeShort | null {
return isOneModeTournamentOf(
tournament.ctx.mapPickingStyle,
tournament.ctx.toSetMapPool,
);
}

View File

@@ -148,7 +148,11 @@ function TeamMapPool({
{mapPool.map(({ mode, stageId }, i) => {
return (
<div key={i}>
<StageImage stageId={stageId} width={85} />
<StageImage
stageId={stageId}
width={85}
testId={`team-map-pool-${mode}-${stageId}`}
/>
<div className={styles.teamWithRosterMapPoolModeInfo}>
<ModeImage mode={mode} size={16} />
</div>

View File

@@ -16,8 +16,12 @@ import { containerClassName } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker";
import {
type CounterPickMapPool,
CounterPickMapPoolPicker,
MapPoolValidationStatusMessage,
useCounterPickMapPoolValidationStatus,
} from "~/features/tournament/components/CounterPickMapPoolPicker";
import { useTournament } from "~/features/tournament/tournament-context";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import { FormField } from "~/form/FormField";
@@ -26,7 +30,6 @@ import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useHydrated } from "~/hooks/useHydrated";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import {
LOG_IN_URL,
SENDOU_INK_BASE_URL,
@@ -37,7 +40,6 @@ import { action } from "../actions/to.$id.register.server";
import type { TournamentRegisterPageLoader } from "../loaders/to.$id.register.server";
import { loader } from "../loaders/to.$id.register.server";
import styles from "../tournament.module.css";
import { TOURNAMENT } from "../tournament-constants";
import {
type RegisterTeamFormValues,
registerTeamFormSchema,
@@ -48,10 +50,6 @@ import {
deleteTeamMemberSchema,
updateMapPoolSchema,
} from "../tournament-schemas";
import {
type CounterPickValidationStatus,
validateCounterPickMapPool,
} from "../tournament-utils";
export { action, loader };
@@ -228,7 +226,7 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
<>
<FillRoster ownTeam={ownTeam} ownTeamCheckedIn={ownTeamCheckedIn} />
{tournament.teamsPrePickMaps ? (
<CounterPickMapPoolPicker key={tournament.ctx.id} />
<TeamCounterPickMapPoolPicker key={tournament.ctx.id} />
) : null}
</>
) : null}
@@ -256,7 +254,7 @@ function ReadOnlyRegistrationForms() {
<TeamInfo ownTeam={team} canUnregister={false} readOnly />
<FillRoster ownTeam={team} ownTeamCheckedIn={checkedIn} readOnly />
{tournament.teamsPrePickMaps ? (
<CounterPickMapPoolPicker readOnly mapPool={team.mapPool ?? []} />
<TeamCounterPickMapPoolPicker readOnly mapPool={team.mapPool ?? []} />
) : null}
</div>
);
@@ -916,7 +914,7 @@ function DeleteMember({ members }: { members: TournamentTeamFull["members"] }) {
);
}
function CounterPickMapPoolPicker({
function TeamCounterPickMapPoolPicker({
readOnly = false,
mapPool,
}: {
@@ -924,17 +922,13 @@ function CounterPickMapPoolPicker({
mapPool?: NonNullable<TournamentTeamFull["mapPool"]>;
}) {
const { t } = useTranslation(["common", "game-misc", "tournament"]);
const tournament = useTournament();
const fetcher = useFetcher();
const data = useLoaderData<TournamentRegisterPageLoader>();
const [counterPickMaps, setCounterPickMaps] = React.useState(
mapPool ?? data?.mapPool ?? [],
);
const [counterPickMaps, setCounterPickMaps] =
React.useState<CounterPickMapPool>(mapPool ?? data?.mapPool ?? []);
const counterPickMapPool = new MapPool(counterPickMaps);
const isOneModeTournamentOf =
tournament.modesIncluded.length === 1 ? tournament.modesIncluded[0] : null;
const validationStatus =
useCounterPickMapPoolValidationStatus(counterPickMaps);
return (
<div>
@@ -948,46 +942,12 @@ function CounterPickMapPoolPicker({
name="mapPool"
value={JSON.stringify(counterPickMaps)}
/>
{rankedModesShort
.filter(
(mode) =>
!isOneModeTournamentOf || isOneModeTournamentOf === mode,
)
.map((mode) => {
return (
<ModeMapPoolPicker
key={mode}
amountToPick={
isOneModeTournamentOf
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE
}
mode={mode}
tiebreaker={
tournament.ctx.tieBreakerMapPool.find(
(stage) => stage.mode === mode,
)?.stageId
}
pool={
counterPickMaps
.filter((m) => m.mode === mode)
.map((m) => m.stageId) ?? []
}
onChange={(stageIds) =>
setCounterPickMaps([
...counterPickMaps.filter((m) => m.mode !== mode),
...stageIds.map((stageId) => ({ mode, stageId })),
])
}
disabled={readOnly}
/>
);
})}
{readOnly ? null : validateCounterPickMapPool(
counterPickMapPool,
isOneModeTournamentOf,
tournament.ctx.tieBreakerMapPool,
) === "VALID" ? (
<CounterPickMapPoolPicker
mapPool={counterPickMaps}
onChange={setCounterPickMaps}
disabled={readOnly}
/>
{readOnly ? null : validationStatus === "VALID" ? (
<SubmitButton
schema={updateMapPoolSchema}
_action="UPDATE_MAP_POOL"
@@ -998,42 +958,10 @@ function CounterPickMapPoolPicker({
{t("common:actions.save")}
</SubmitButton>
) : (
<MapPoolValidationStatusMessage
status={validateCounterPickMapPool(
counterPickMapPool,
isOneModeTournamentOf,
tournament.ctx.tieBreakerMapPool,
)}
/>
<MapPoolValidationStatusMessage status={validationStatus} />
)}
</fetcher.Form>
</section>
</div>
);
}
function MapPoolValidationStatusMessage({
status,
}: {
status: CounterPickValidationStatus;
}) {
const { t } = useTranslation(["common"]);
if (
status !== "TOO_MUCH_STAGE_REPEAT" &&
status !== "STAGE_REPEAT_IN_SAME_MODE" &&
status !== "INCLUDES_BANNED" &&
status !== "INCLUDES_TIEBREAKER"
)
return null;
return (
<div className="mt-4">
<Alert alertClassName="w-max" variation="WARNING" tiny>
{t(`common:maps.validation.${status}`, {
maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT,
})}
</Alert>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import type { Page } from "@playwright/test";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
/** Stage the counterpick picking starts from, leaving the lowest ids to tiebreakers. */
const FIRST_COUNTERPICK_STAGE_ID = 5;
/** A stage of a `ModeMapPoolPicker`, whether picked or not. */
export function counterpickMap(page: Page, mode: ModeShort, stageId: StageId) {
return page.getByTestId(`map-pool-${mode}-${stageId}`);
}
/** The check overlaying a picked stage. It covers the stage itself, so unpicking goes through it. */
export function pickedCounterpickMap(
page: Page,
mode: ModeShort,
stageId: StageId,
) {
return page.getByTestId(`map-pool-${mode}-${stageId}-picked`);
}
/**
* Picks the required amount of counterpick maps for every ranked mode, skipping
* banned ones, and returns what was picked.
*/
export async function pickCounterpickMaps(page: Page) {
const picked: Array<{ mode: ModeShort; stageId: StageId }> = [];
let stageId = FIRST_COUNTERPICK_STAGE_ID;
for (const mode of rankedModesShort) {
for (let i = 0; i < TOURNAMENT.COUNTERPICK_MAPS_PER_MODE; i++) {
while (BANNED_MAPS[mode].includes(stageId as StageId)) {
stageId++;
}
await counterpickMap(page, mode, stageId as StageId).click();
picked.push({ mode, stageId: stageId as StageId });
stageId++;
}
}
return picked;
}

View File

@@ -1,9 +1,15 @@
import { expect, type Page } from "@playwright/test";
import { adminRegistrationFormSchema } from "~/features/tournament-admin/tournament-admin-registration-schemas";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import {
tournamentAdminRegistrationEditPage,
tournamentAdminRegistrationPage,
} from "~/utils/urls";
import {
counterpickMap,
pickCounterpickMaps,
pickedCounterpickMap,
} from "../../helpers/counterpick-map-pool";
import {
navigate,
selectTournament,
@@ -27,6 +33,7 @@ export class TournamentAdminRegistrationPage {
teamNameInput: page.getByLabel("Team name"),
importTeamButton: page.getByRole("button", { name: "Import team" }),
importDialogHeading: page.getByRole("heading", { name: "Import team" }),
invalidMapPoolError: page.getByText("Invalid map pool"),
};
}
@@ -75,6 +82,23 @@ export class TournamentAdminRegistrationPage {
return submit(this.page);
}
/** Picks the required amount of counterpick maps for every mode, skipping banned ones. */
pickCounterpickMaps() {
return pickCounterpickMaps(this.page);
}
pickCounterpickMap(mode: ModeShort, stageId: StageId) {
return counterpickMap(this.page, mode, stageId).click();
}
unpickCounterpickMap(mode: ModeShort, stageId: StageId) {
return pickedCounterpickMap(this.page, mode, stageId).click();
}
pickedCounterpickMap(mode: ModeShort, stageId: StageId) {
return pickedCounterpickMap(this.page, mode, stageId);
}
async openImportDialog() {
await this.locators.importTeamButton.click();
}

View File

@@ -1,17 +1,15 @@
import type { Page } from "@playwright/test";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { registerTeamFormSchema } from "~/features/tournament/tournament-register-schemas";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { tournamentRegisterPage } from "~/utils/urls";
import {
counterpickMap,
pickCounterpickMaps,
} from "../../helpers/counterpick-map-pool";
import { navigate, submit } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { TournamentNav } from "./tournament-nav";
/** Stage the counterpick picking starts from, leaving the lowest ids to tiebreakers. */
const FIRST_COUNTERPICK_STAGE_ID = 5;
export class TournamentRegisterPage {
private readonly page: Page;
readonly nav;
@@ -52,7 +50,7 @@ export class TournamentRegisterPage {
}
counterpickMap(mode: ModeShort, stageId: StageId) {
return this.page.getByTestId(`map-pool-${mode}-${stageId}`);
return counterpickMap(this.page, mode, stageId);
}
addPlayer() {
@@ -60,19 +58,8 @@ export class TournamentRegisterPage {
}
/** Picks the required amount of counterpick maps for every mode, skipping banned ones. */
async pickCounterpickMaps() {
let stageId = FIRST_COUNTERPICK_STAGE_ID;
for (const mode of rankedModesShort) {
for (let i = 0; i < TOURNAMENT.COUNTERPICK_MAPS_PER_MODE; i++) {
while (BANNED_MAPS[mode].includes(stageId as StageId)) {
stageId++;
}
await this.counterpickMap(mode, stageId as StageId).click();
stageId++;
}
}
pickCounterpickMaps() {
return pickCounterpickMaps(this.page);
}
saveCounterpickMaps() {

View File

@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { tournamentTeamPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
@@ -19,4 +20,9 @@ export class TournamentTeamPage {
url: tournamentTeamPage({ tournamentId, tournamentTeamId }),
});
}
/** A map of the team's counterpick map pool, shown to organizers before the tournament starts. */
mapPoolStage(mode: ModeShort, stageId: StageId) {
return this.page.getByTestId(`team-map-pool-${mode}-${stageId}`);
}
}

View File

@@ -1,6 +1,7 @@
import * as fs from "node:fs/promises";
import { addMinutes, subDays } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import type { TournamentMapPickingStyle } from "~/features/tournament/tournament-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { Factories } from "./helpers/factories";
import { expect, impersonate, test } from "./helpers/playwright";
@@ -19,6 +20,8 @@ import { TournamentTeamPage } from "./pages/tournament/tournament-team-page";
const ROSTER_SIZE = 4;
const CAPTAIN_DISCORD_ID = "1234567890123456789";
/** Stage above the ones the counterpick picking helper uses, so swapping to it is always a change. */
const REPLACEMENT_STAGE_ID = 17;
test.describe("Tournament admin team management", () => {
test("edits a registration, checks a team in and out, unregisters it and records it in the audit log", async ({
@@ -138,6 +141,71 @@ test.describe("Tournament admin team management", () => {
);
});
test("sets the counterpick map pool of a team that has none, rejects an incomplete edit to it and then edits it", async ({
page,
factories,
}) => {
// teams pre-pick their maps, so the registration form has a map pool to edit
const tournament = await createTournament(factories, {
mapPickingStyle: "AUTO_ALL",
});
const roster = await factories.UserFactory.createMany(ROSTER_SIZE);
const team = await factories.TournamentTeamFactory.create({
tournamentId: tournament.id,
team: pickUpTeam("Poolless Pandas"),
memberUserIds: roster.map((user) => user.id),
});
await impersonate(page, NZAP_TEST_ID);
const registration = new TournamentAdminRegistrationPage(page);
await registration.gotoEdit(tournament.id, team.id);
await expect(registration.locators.editHeading).toBeVisible();
const picked = await registration.pickCounterpickMaps();
await registration.save();
const admin = new TournamentAdminPage(page);
await expect(admin.locators.searchInput).toBeVisible();
const teamPage = new TournamentTeamPage(page);
await teamPage.goto(tournament.id, team.id);
for (const { mode, stageId } of picked) {
await expect(teamPage.mapPoolStage(mode, stageId)).toBeVisible();
}
// a pool left incomplete is rejected instead of overwriting the saved one
const [replaced] = picked;
await registration.gotoEdit(tournament.id, team.id);
await expect(
registration.pickedCounterpickMap(replaced.mode, replaced.stageId),
).toBeVisible();
await registration.unpickCounterpickMap(replaced.mode, replaced.stageId);
await registration.save();
await expect(registration.locators.invalidMapPoolError).toBeVisible();
await teamPage.goto(tournament.id, team.id);
await expect(
teamPage.mapPoolStage(replaced.mode, replaced.stageId),
).toBeVisible();
// swapping one of the picked maps for another is reflected on the team page
await registration.gotoEdit(tournament.id, team.id);
await registration.unpickCounterpickMap(replaced.mode, replaced.stageId);
await registration.pickCounterpickMap(replaced.mode, REPLACEMENT_STAGE_ID);
await registration.save();
await expect(admin.locators.searchInput).toBeVisible();
await teamPage.goto(tournament.id, team.id);
await expect(
teamPage.mapPoolStage(replaced.mode, REPLACEMENT_STAGE_ID),
).toBeVisible();
await expect(
teamPage.mapPoolStage(replaced.mode, replaced.stageId),
).toHaveCount(0);
});
test("exports the team list", async ({ page, factories }) => {
const tournament = await createTournament(factories);
const roster = await factories.UserFactory.createMany(ROSTER_SIZE);
@@ -325,7 +393,11 @@ async function createTournament(
factories: Factories,
{
establishedOrganization = false,
}: { establishedOrganization?: boolean } = {},
mapPickingStyle,
}: {
establishedOrganization?: boolean;
mapPickingStyle?: TournamentMapPickingStyle;
} = {},
) {
const organization = establishedOrganization
? await factories.TournamentOrganizationFactory.create(
@@ -338,6 +410,8 @@ async function createTournament(
authorId: NZAP_TEST_ID,
organizationId: organization?.id ?? null,
startTimes: [dateToDatabaseTimestamp(addMinutes(new Date(), 30))],
// spread so the factory's own default is not overwritten with undefined
...(mapPickingStyle ? { mapPickingStyle } : {}),
});
}