Files
OpenFrontIO/tests/client/ProfileGameStatsNavigation.test.ts
T
RyanandGitHub 5ae2040d65 add game history to a profile (#4662)
## Description:

adds game tab to player profile:

<img width="1235" height="928" alt="image"
src="https://github.com/user-attachments/assets/660fba48-18d0-4538-a9d5-f33abc0e693d"
/>

also make ui a bit more uniform, both account + profile modal match, and
changed the filtering options to be the same as the games tab

<img width="972" height="581" alt="image"
src="https://github.com/user-attachments/assets/6f10f78e-e32b-42a6-8df4-7aa6f0cf5146"
/>
<img width="1006" height="623" alt="image"
src="https://github.com/user-attachments/assets/caffb742-1d64-4d21-aa13-d3a7191088ca"
/>

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n
2026-07-21 13:56:28 -07:00

328 lines
10 KiB
TypeScript

import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
const copyToClipboardMock = vi.hoisted(() =>
vi.fn(async (_text: string, onSuccess?: () => void) => onSuccess?.()),
);
vi.mock("../../src/client/Api", () => ({
fetchPublicPlayerProfile: vi.fn(async () => ({
createdAt: "2026-01-01T00:00:00.000Z",
stats: { Public: { "Free For All": {} } },
})),
fetchPublicPlayerGames: vi.fn(),
}));
vi.mock("src/client/ClientEnv", () => ({
ClientEnv: { workerPath: vi.fn(() => "w0") },
}));
vi.mock("../../src/client/TerrainMapFileLoader", () => ({
terrainMapFileLoader: {
getMapData: vi.fn(() => ({ webpPath: "/maps/world.webp" })),
},
}));
vi.mock("../../src/client/Utils", () => ({
getMapName: vi.fn((name: string) => name),
renderDuration: vi.fn((seconds: number) => `${seconds}s`),
translateText: vi.fn((key: string) => key),
copyToClipboard: copyToClipboardMock,
}));
vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => {
class FakeGameInfoView extends HTMLElement {}
if (!customElements.get("game-info-view")) {
customElements.define("game-info-view", FakeGameInfoView);
}
return { GameInfoView: FakeGameInfoView };
});
vi.mock(
"../../src/client/components/baseComponents/stats/PlayerStatsTree",
() => {
class FakePlayerStatsTreeView extends HTMLElement {}
if (!customElements.get("player-stats-tree-view")) {
customElements.define("player-stats-tree-view", FakePlayerStatsTreeView);
}
return { PlayerStatsTreeView: FakePlayerStatsTreeView };
},
);
class FakeIntersectionObserver {
constructor(_callback: IntersectionObserverCallback) {}
observe() {}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
root = null;
rootMargin = "";
thresholds = [];
}
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
import { fetchPublicPlayerGames } from "../../src/client/Api";
import { GameStatsModal } from "../../src/client/GameStatsModal";
import { modalRouter } from "../../src/client/ModalRouter";
import { initNavigation } from "../../src/client/Navigation";
import { PlayerProfileModal } from "../../src/client/PlayerProfileModal";
import {
GameMapType,
GameMode,
GameType,
PlayerInfo,
PlayerType,
} from "../../src/core/game/Game";
import { setup } from "../util/Setup";
type ModalShell = HTMLElement & {
getScrollTop(): number;
setScrollTop(value: number): void;
updateComplete: Promise<boolean>;
};
async function waitForProfile(
modal: PlayerProfileModal,
assertion: () => void,
): Promise<void> {
await vi.waitFor(async () => {
await modal.updateComplete;
const shell = modal.querySelector("o-modal") as ModalShell | null;
await shell?.updateComplete;
const history = modal.querySelector("player-game-history-view") as
| (HTMLElement & { updateComplete: Promise<boolean> })
| null;
await history?.updateComplete;
assertion();
});
}
async function waitForStatsModal(
modal: GameStatsModal,
assertion: () => void,
): Promise<void> {
await vi.waitFor(async () => {
await modal.updateComplete;
const shell = modal.querySelector("o-modal") as ModalShell | null;
await shell?.updateComplete;
assertion();
});
}
describe("Profile Games stats navigation", () => {
let modal: PlayerProfileModal;
let statsModal: GameStatsModal;
let playPage: HTMLElement;
beforeAll(() => {
playPage = document.createElement("div");
playPage.id = "page-play";
document.body.appendChild(playPage);
initNavigation();
});
afterAll(() => {
playPage.remove();
});
beforeEach(async () => {
vi.clearAllMocks();
const game = await setup(
"world",
{
gameMap: GameMapType.World,
gameMode: GameMode.FFA,
gameType: GameType.Public,
},
[new PlayerInfo("Player", PlayerType.Human, null, "player-1")],
);
const player = game.player("player-1");
game.setWinner(player, game.stats().stats());
const gameConfig = game.config().gameConfig();
vi.mocked(fetchPublicPlayerGames).mockResolvedValue({
results: [
{
gameId: "game-1",
start: "2026-07-01T12:00:00.000Z",
durationSeconds: game.elapsedGameSeconds(),
map: gameConfig.gameMap,
mode: gameConfig.gameMode,
type: gameConfig.gameType.toLowerCase(),
playerTeams: gameConfig.playerTeams?.toString() ?? null,
rankedType: gameConfig.rankedType ?? "",
result: game.getWinner() === player ? "victory" : "defeat",
totalPlayers: game.players().length,
username: player.name(),
clanTag: player.info().clanTag,
},
],
nextCursor: null,
});
vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) });
history.replaceState(null, "", "/");
modalRouter.register("profile", {
tag: "player-profile-modal",
pageId: "page-profile",
});
modalRouter.register("stats", {
tag: "game-stats-modal",
pageId: "page-stats",
});
if (!customElements.get("player-profile-modal")) {
customElements.define("player-profile-modal", PlayerProfileModal);
}
if (!customElements.get("game-stats-modal")) {
customElements.define("game-stats-modal", GameStatsModal);
}
modal = document.createElement(
"player-profile-modal",
) as PlayerProfileModal;
modal.id = "page-profile";
modal.setAttribute("inline", "");
modal.className = "hidden page-content";
document.body.appendChild(modal);
statsModal = document.createElement("game-stats-modal") as GameStatsModal;
statsModal.id = "page-stats";
statsModal.setAttribute("inline", "");
statsModal.className = "hidden page-content";
document.body.appendChild(statsModal);
await modal.updateComplete;
await statsModal.updateComplete;
history.replaceState(null, "", "/#modal=profile&publicID=player-1");
expect(modalRouter.routeFromHash()).toBe(true);
await waitForProfile(modal, () => {
expect(modal.isOpen()).toBe(true);
});
modal.setActiveTab("games");
await waitForProfile(modal, () => {
expect(modal.querySelector("player-game-history-view")).not.toBeNull();
});
});
afterEach(() => {
window.showPage?.("page-play");
modal.remove();
statsModal.remove();
history.replaceState(null, "", "/");
vi.unstubAllGlobals();
});
it("drills into Stats and returns to the cached Games scroll position", async () => {
const shell = modal.querySelector("o-modal") as ModalShell;
shell.setScrollTop(420);
const statsButton = Array.from(modal.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "game_list.stats",
);
expect(statsButton).toBeTruthy();
statsButton!.click();
await waitForStatsModal(statsModal, () => {
expect(statsModal.isOpen()).toBe(true);
expect(statsModal.querySelector("game-info-view")).not.toBeNull();
});
expect(modal.isOpen()).toBe(false);
const statsView = statsModal.querySelector(
"game-info-view",
) as HTMLElement & { gameId: string };
expect(statsView.gameId).toBe("game-1");
expect(window.location.hash).toBe("#modal=stats&gameID=game-1");
const backButton = statsModal.querySelector(
'[slot="header"] button',
) as HTMLButtonElement;
backButton.click();
await waitForProfile(modal, () => {
expect(modal.querySelector("player-game-history-view")).not.toBeNull();
expect(shell.getScrollTop()).toBe(420);
});
expect(window.location.hash).toBe(
"#modal=profile&publicID=player-1&tab=games",
);
// History wasn't refetched on return — the cached list was reused.
expect(fetchPublicPlayerGames).toHaveBeenCalledOnce();
});
it("reloads the games history when the route changes to a different player", async () => {
// beforeEach opened player-1's history.
expect(fetchPublicPlayerGames).toHaveBeenCalledWith(
"player-1",
expect.anything(),
);
// Editing the hash to a different player re-routes the already-open modal.
// Lit reuses the mounted history view, so it must load the new player's
// games rather than keep showing player-1's.
history.replaceState(
null,
"",
"/#modal=profile&publicID=player-2&tab=games",
);
expect(modalRouter.routeFromHash()).toBe(true);
await waitForProfile(modal, () => {
expect(fetchPublicPlayerGames).toHaveBeenCalledWith(
"player-2",
expect.anything(),
);
});
});
it("resets filters to the default view when the route changes players", async () => {
// Pick a non-default type filter on player-1 (translateText is mocked to
// echo the key, so the Private tab's text is its label key).
const privateTab = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "account_modal.games_type_private",
);
expect(privateTab).toBeTruthy();
privateTab!.click();
await waitForProfile(modal, () => {
const call = vi
.mocked(fetchPublicPlayerGames)
.mock.calls.find(
(c) => c[0] === "player-1" && c[1]?.type === "private",
);
expect(call).toBeTruthy();
});
// Routing to a different player must fall back to the default (All) view
// rather than inheriting player-1's Private filter.
history.replaceState(
null,
"",
"/#modal=profile&publicID=player-2&tab=games",
);
expect(modalRouter.routeFromHash()).toBe(true);
await waitForProfile(modal, () => {
const player2Calls = vi
.mocked(fetchPublicPlayerGames)
.mock.calls.filter((c) => c[0] === "player-2");
// Every player-2 request (there should only be the one reset reload) must
// drop player-1's filters — asserting across all of them catches a stale
// first request even if a later one happens to be clean.
expect(player2Calls.length).toBeGreaterThan(0);
for (const [, opts] of player2Calls) {
expect(opts?.type).toBeUndefined();
expect(opts?.filter).toBeUndefined();
}
});
});
});