Files
sendou.ink/e2e/user-page.spec.ts
2026-09-09 21:46:09 +03:00

457 lines
14 KiB
TypeScript

import { addDays } from "date-fns";
import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import * as Seasons from "~/features/mmr/core/Seasons";
import {
FULL_GROUP_SIZE,
SENDOUQ_BEST_OF,
} from "~/features/sendouq/q-constants";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import type { Factories } from "./helpers/factories";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { SettingsPage } from "./pages/settings/settings-page";
import { UserEditWidgetsPage } from "./pages/user/user-edit-widgets-page";
import { UserPage } from "./pages/user/user-page";
import { UserSeasonsPage } from "./pages/user/user-seasons-page";
/** The only season the e2e seasons list has finished, i.e. the exportable one. */
const FINISHED_SEASON = 0;
const LUNA_BLASTER: MainWeaponId = 200;
const SCORCH_GORGE: StageId = 0;
/** Maps of a seeded concluded match that get played: alpha wins them all straight. */
const PLAYED_MAPS_COUNT = Math.ceil(SENDOUQ_BEST_OF / 2);
test.describe("User page", () => {
test("uses badge pagination", async ({ page, factories }) => {
await factories.BadgeFactory.create(
{ displayName: "Sunken Scroll" },
{ ownerIds: [NZAP_TEST_ID] },
);
// enough badges for two pages: 1 big + 9 small on the first page
await factories.BadgeFactory.createMany(
12,
(index) => ({ displayName: `Badge ${index + 1}` }),
{ ownerIds: [ADMIN_ID] },
);
const userPage = new UserPage(page);
await userPage.goto(NZAP_TEST_DISCORD_ID);
await expect(userPage.locators.badgeDisplay).toBeVisible();
await isNotVisible(userPage.locators.badgePaginationButtons);
await userPage.goto(ADMIN_DISCORD_ID);
// badges are shown newest first, so the last created one is the big badge
await expect(userPage.badgeImage("Badge 12")).toBeVisible();
await userPage.locators.badgePaginationButtons.nth(1).click();
await userPage.badgeImage("Badge 1").click();
await expect(userPage.badgeImage("Badge 1")).toHaveAttribute(
"width",
"125",
);
});
test("customize which badge is shown as big by default as normal user", async ({
page,
factories,
}) => {
const [firstBadge] = await factories.BadgeFactory.createMany(
2,
(index) => ({ displayName: `Badge ${index + 1}` }),
{ ownerIds: [NZAP_TEST_ID] },
);
await impersonate(page, NZAP_TEST_ID);
const editWidgets = new UserEditWidgetsPage(page);
await editWidgets.goto(NZAP_TEST_DISCORD_ID);
await editWidgets.openWidgetSettings("badges-owned");
await editWidgets.selectFavoriteBadge(firstBadge.id);
await editWidgets.save();
const userPage = new UserPage(page);
await userPage.goto(NZAP_TEST_DISCORD_ID);
await expect(userPage.badgeImage("Badge 1")).toHaveAttribute(
"width",
"125",
);
});
test("customize big badge + small badge first page order as supporter", async ({
page,
factories,
}) => {
const badges = await factories.BadgeFactory.createMany(
3,
(index) => ({ displayName: `Badge ${index + 1}` }),
{ ownerIds: [ADMIN_ID] },
);
await factories.UserFactory.grant(ADMIN_ID, { patronTier: 2 });
await impersonate(page);
const editWidgets = new UserEditWidgetsPage(page);
await editWidgets.goto(ADMIN_DISCORD_ID);
await editWidgets.openWidgetSettings("badges-owned");
await editWidgets.selectFavoriteBadge(badges[0].id);
await expect(editWidgets.locators.badgeDisplay).toBeVisible();
await editWidgets.selectFavoriteBadge(badges[1].id);
await editWidgets.save();
const userPage = new UserPage(page);
await userPage.goto(ADMIN_DISCORD_ID);
await expect(userPage.badgeImage("Badge 1")).toHaveAttribute(
"width",
"125",
);
await expect(userPage.badgeImage("Badge 2")).toBeVisible();
});
test("edits user profile", async ({ page, factories }) => {
await factories.UserFactory.updateProfile(ADMIN_ID, { country: "FI" });
await impersonate(page);
const userPage = new UserPage(page);
await userPage.goto(ADMIN_DISCORD_ID);
await expect(userPage.flag("FI")).toBeVisible();
const editProfile = await userPage.openEditProfile();
await editProfile.form.fill("inGameName", "Lean#1234");
await editProfile.selectCountry("Sweden");
await editProfile.save();
await expect(userPage.flag("SE")).toBeVisible();
await expect(userPage.text("Lean#1234")).toBeVisible();
});
test("customizes theme colors and resets them", async ({
page,
factories,
}) => {
// custom theme colors are a supporter perk
await factories.UserFactory.grant(ADMIN_ID, { patronTier: 2 });
await impersonate(page);
const settings = new SettingsPage(page);
await settings.goto("theme");
await expect(settings.hasCustomTheme()).resolves.toBe(false);
await settings.setBaseHue("120");
await settings.saveTheme();
await settings.reload();
await expect(settings.hasCustomTheme()).resolves.toBe(true);
await settings.resetTheme();
await settings.reload();
await expect(settings.hasCustomTheme()).resolves.toBe(false);
});
test("exports season summary image as a supporter", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(ADMIN_ID, { patronTier: 2 });
await playFinishedSeason(factories, ADMIN_ID);
await impersonate(page);
const seasonsPage = new UserSeasonsPage(page);
await seasonsPage.goto(ADMIN_DISCORD_ID);
await seasonsPage.openExportDialog();
await expect(seasonsPage.exportDialogText("Best win streak")).toBeVisible();
const download = await seasonsPage.downloadExportedImage();
expect(download.suggestedFilename()).toBe(
`season-${FINISHED_SEASON}-summary.png`,
);
});
test("shows supporter perk explanation instead of exporting for non-supporter mid-season", async ({
page,
factories,
}) => {
await playFinishedSeason(factories, NZAP_TEST_ID);
await impersonate(page, NZAP_TEST_ID);
const seasonsPage = new UserSeasonsPage(page);
await seasonsPage.goto(NZAP_TEST_DISCORD_ID);
await seasonsPage.openExportDialog();
await expect(seasonsPage.locators.supporterPerkExplanation).toBeVisible();
await isNotVisible(seasonsPage.locators.downloadButton);
});
test("shows the match profile weapon pool", async ({ page, factories }) => {
await factories.UserFactory.grant(ADMIN_ID, {
matchProfile: {
weaponPool: ([200, 1100, 2000, 4000] as const).map((id) => ({
id,
isFavorite: false,
})),
},
});
await impersonate(page);
const userPage = new UserPage(page);
await userPage.goto(ADMIN_DISCORD_ID);
for (const [i, id] of [200, 1100, 2000, 4000].entries()) {
await expect(userPage.weaponPoolImage(id, i + 1)).toBeVisible();
}
});
test("shows the weapon pool widget's own list over the match profile pool", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(ADMIN_ID, {
matchProfile: {
weaponPool: [{ id: 1100, isFavorite: false }],
},
});
await impersonate(page);
const editWidgetsPage = new UserEditWidgetsPage(page);
await editWidgetsPage.goto(ADMIN_DISCORD_ID);
await editWidgetsPage.openWidgetSettings("weapon-pool");
await editWidgetsPage.selectWeaponPoolWeapon("Luna Blaster");
await editWidgetsPage.selectWeaponPoolWeapon("Splattershot");
await editWidgetsPage.save();
const userPage = new UserPage(page);
await userPage.goto(ADMIN_DISCORD_ID);
await expect(userPage.weaponPoolImage(200, 1)).toBeVisible();
await expect(userPage.weaponPoolImage(40, 2)).toBeVisible();
await isNotVisible(userPage.weaponPoolImage(1100, 1));
});
test("chooses result highlights which the results list then shows by default", async ({
page,
factories,
}) => {
const zonesEvent = await factories.CalendarEventFactory.create({
authorId: ADMIN_ID,
name: "In The Zone 30",
});
const poolEvent = await factories.CalendarEventFactory.create({
authorId: ADMIN_ID,
name: "Paddling Pool 253",
});
for (const [event, placement] of [
[zonesEvent, 2],
[poolEvent, 1],
] as const) {
await factories.CalendarEventResultFactory.create({
eventId: event.id,
participantCount: 16,
results: [
{
teamName: "Team Olive",
placement,
players: [
{ userId: ADMIN_ID, name: null },
{ userId: null, name: "Mako" },
{ userId: null, name: "Marie" },
{ userId: null, name: "Callie" },
],
},
],
});
}
await impersonate(page);
const userPage = new UserPage(page);
await userPage.goto(ADMIN_DISCORD_ID);
const resultsPage = await userPage.openResults();
await expect(resultsPage.eventName("In The Zone 30")).toBeVisible();
await expect(resultsPage.eventName("Paddling Pool 253")).toBeVisible();
const highlightsPage = await resultsPage.openChooseHighlights();
await highlightsPage.resultCheckbox(/In The Zone 30/).check();
await highlightsPage.save();
await expect(resultsPage.eventName("In The Zone 30")).toBeVisible();
await isNotVisible(resultsPage.eventName("Paddling Pool 253"));
await page.context().clearCookies();
await resultsPage.goto(ADMIN_DISCORD_ID);
await isNotVisible(resultsPage.eventName("Paddling Pool 253"));
await resultsPage.toggleHighlightsOnly();
await expect(resultsPage.eventName("Paddling Pool 253")).toBeVisible();
await resultsPage.toggleHighlightsOnly();
await isNotVisible(resultsPage.eventName("Paddling Pool 253"));
});
test("edits profile widgets, lists vods and shows season stats", async ({
page,
factories,
}) => {
await factories.VodFactory.createMany(2, (index) => ({
submitterUserId: ADMIN_ID,
pov: { type: "USER" as const, userId: ADMIN_ID },
title: `Ranked grind episode ${index + 1}`,
}));
const mates = await factories.UserFactory.createMany(FULL_GROUP_SIZE - 2);
const enemies = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
const match = await factories.SQMatchFactory.create(
{
alphaUserIds: [ADMIN_ID, NZAP_TEST_ID, ...mates.map((mate) => mate.id)],
bravoUserIds: enemies.map((enemy) => enemy.id),
mapList: Array.from({ length: SENDOUQ_BEST_OF }, () => ({
mode: "SZ" as const,
stageId: SCORCH_GORGE,
source: "BOTH" as const,
})),
},
{ isConcluded: true },
);
await factories.SQReportedWeaponFactory.createMany(
PLAYED_MAPS_COUNT,
(index) => ({
groupMatchId: match.id,
mapIndex: index,
userId: ADMIN_ID,
weaponSplId: LUNA_BLASTER,
}),
);
await impersonate(page);
const userPage = new UserPage(page);
await userPage.goto(ADMIN_DISCORD_ID);
// no team, so the default layout's teams widget has nothing to show
await isNotVisible(userPage.widgetHeading("Teams"));
const editWidgets = await userPage.openEditWidgets();
// the default layout is what an untouched profile starts editing from
await editWidgets.openWidgetSettings("bio");
await editWidgets.fillBio("Reformed Hydra main");
await editWidgets.save();
await expect(userPage.widgetHeading("Bio")).toBeVisible();
await expect(userPage.text("Reformed Hydra main")).toBeVisible();
await expect(userPage.widgetHeading("Member #")).toBeVisible();
// admin is the first user created, so their join order is 1
await expect(userPage.exactText("#1")).toBeVisible();
const vodsPage = await userPage.openVods();
await expect(vodsPage.vodTitle("Ranked grind episode 1")).toBeVisible();
await expect(vodsPage.vodTitle("Ranked grind episode 2")).toBeVisible();
const seasonsPage = new UserSeasonsPage(page);
await seasonsPage.goto(ADMIN_DISCORD_ID);
await seasonsPage.openStatsTab("Weapons");
await expect(
seasonsPage.weaponUsageImage("Luna Blaster (100%)"),
).toBeVisible();
await seasonsPage.openStatsTab("Stages");
await expect(
seasonsPage.stageRecord(`${PLAYED_MAPS_COUNT}W 0L`),
).toBeVisible();
await seasonsPage.openStatsTab("Teammates");
await expect(seasonsPage.playerLink("N-ZAP")).toBeVisible();
});
test("gates supporter only widgets behind supporter status", async ({
page,
factories,
}) => {
await impersonate(page);
const editWidgets = new UserEditWidgetsPage(page);
await editWidgets.goto(ADMIN_DISCORD_ID);
await expect(editWidgets.supporterOnlyLabel("bio-md")).toBeVisible();
await isNotVisible(editWidgets.addWidgetButton("bio-md"));
await factories.UserFactory.grant(ADMIN_ID, { patronTier: 2 });
await editWidgets.goto(ADMIN_DISCORD_ID);
await isNotVisible(editWidgets.supporterOnlyLabel("bio-md"));
await editWidgets.removeWidget("bio");
await editWidgets.addWidget("bio-md");
await editWidgets.fillBio("**Reformed** Hydra main");
await editWidgets.save();
const userPage = new UserPage(page);
await expect(userPage.widget("bio-md").locator("strong")).toHaveText(
"Reformed",
);
});
test("redirects to the preferred identifier", async ({ page, factories }) => {
const customUrl = "zapfish";
await factories.UserFactory.updateProfile(NZAP_TEST_ID, { customUrl });
const userPage = new UserPage(page);
await userPage.gotoWithIdentifier(NZAP_TEST_ID);
await expect(page).toHaveURL(`/u/${customUrl}`);
await userPage.gotoWithIdentifier(NZAP_TEST_DISCORD_ID);
await expect(page).toHaveURL(`/u/${customUrl}`);
await userPage.gotoWithIdentifier(customUrl);
await expect(page).toHaveURL(`/u/${customUrl}`);
// without a custom URL the Discord id is the preferred identifier
await userPage.gotoWithIdentifier(ADMIN_ID);
await expect(page).toHaveURL(`/u/${ADMIN_DISCORD_ID}`);
});
});
/**
* Plays the user through a whole season of SendouQ, ending it with a calculated
* (i.e. non-approximate) skill. The matches are spread over the days of the only
* finished season so that the season summary has something to show.
*/
async function playFinishedSeason(factories: Factories, userId: number) {
const mates = await factories.UserFactory.createMany(FULL_GROUP_SIZE - 1);
const enemies = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
const ownGroup = [userId, ...mates.map((mate) => mate.id)];
const opposingGroup = enemies.map((enemy) => enemy.id);
const { starts } = Seasons.nthToDateRange(FINISHED_SEASON);
for (let index = 0; index < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; index++) {
// alpha wins every map, so which side the user is on decides the set
const userWon = index % 3 !== 0;
await factories.SQMatchFactory.create(
{
alphaUserIds: userWon ? ownGroup : opposingGroup,
bravoUserIds: userWon ? opposingGroup : ownGroup,
},
{ isConcluded: true, createdAt: addDays(starts, index) },
);
}
await factories.reseason(FINISHED_SEASON);
}