Files
OpenFrontIO/tests/client/GameInfoView.test.ts
T
RyanandGitHub 0fc80e931e convert stats modal to embed instead of popup (#4626)
## Description:

convert stats modal to embed instead of popup

before:
<img width="1193" height="939" alt="image"
src="https://github.com/user-attachments/assets/4f96843a-3b96-4874-8292-e670181bd87c"
/>

after:
<img width="1226" height="863" alt="image"
src="https://github.com/user-attachments/assets/801273cc-77cd-41b6-bc77-4fdc65afef71"
/>


also has context to remember where we were in the scrolled list, so
pressing "back" keeps the position

## 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-17 08:49:44 -07:00

264 lines
7.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
Difficulty,
GameMapSize,
GameMapType,
GameMode,
GameType,
} from "../../src/core/game/Game";
import type { AnalyticsRecord, GameConfig } from "../../src/core/Schemas";
vi.mock("../../src/client/Api", () => ({
fetchGameById: vi.fn(async () => false),
}));
vi.mock("../../src/client/TerrainMapFileLoader", () => ({
terrainMapFileLoader: {
getMapData: vi.fn((map: GameMapType) => ({
webpPath: `/maps/${map}.webp`,
})),
},
}));
vi.mock("../../src/client/Utils", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../src/client/Utils")>();
return {
...actual,
getMapName: vi.fn((mapName: string | undefined) => mapName ?? null),
};
});
import { fetchGameById } from "../../src/client/Api";
import { GameInfoView } from "../../src/client/components/baseComponents/stats/GameInfoView";
import { LangSelector } from "../../src/client/LangSelector";
const config: GameConfig = {
gameMap: GameMapType.Montreal,
difficulty: Difficulty.Medium,
donateGold: false,
donateTroops: false,
gameType: GameType.Public,
gameMode: GameMode.FFA,
gameMapSize: GameMapSize.Normal,
nations: "disabled",
bots: 0,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
maxPlayers: 40,
disabledUnits: [],
randomSpawn: false,
};
function makeSession(
gameID: string,
gameMap: GameMapType,
withPlayer = true,
): AnalyticsRecord {
return {
version: "v0.0.2",
info: {
duration: 1_000,
winner: withPlayer ? ["player", `${gameID}-player`] : undefined,
players: withPlayer
? [
{
clientID: `${gameID}-player`,
username: `${gameID} player`,
clanTag: null,
stats: {
units: { port: [1n, 0n, 0n, 1n] },
conquests: [1n],
},
persistentID: null,
},
]
: [],
gameID,
lobbyCreatedAt: 0,
config: { ...config, gameMap },
start: 0,
end: 1_000,
num_turns: 100,
lobbyFillTime: 0,
},
gitCommit: "DEV",
subdomain: "",
domain: "",
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function mountView(gameId: string): GameInfoView {
const view = document.createElement("game-info-view") as GameInfoView;
view.gameId = gameId;
document.body.appendChild(view);
return view;
}
async function waitForRender(
view: GameInfoView,
assertion: () => void,
): Promise<void> {
await vi.waitFor(async () => {
await view.updateComplete;
assertion();
});
}
describe("GameInfoView", () => {
let view: GameInfoView | null = null;
const fetchMock = vi.mocked(fetchGameById);
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue(false);
if (!customElements.get("game-info-view")) {
customElements.define("game-info-view", GameInfoView);
}
});
afterEach(() => {
view?.remove();
});
it("renders the game summary, controls, and player rows", async () => {
fetchMock.mockResolvedValue(makeSession("game-1", GameMapType.Montreal));
view = mountView("game-1");
await waitForRender(view, () => {
expect(view!.textContent).toContain(GameMapType.Montreal);
expect(view!.querySelector("ranking-controls")).not.toBeNull();
expect(view!.querySelector("ranking-header")).not.toBeNull();
expect(view!.querySelectorAll("player-row")).toHaveLength(1);
});
});
it("renders the no-winner state for a game without ranked players", async () => {
fetchMock.mockResolvedValue(
makeSession("empty", GameMapType.Montreal, false),
);
view = mountView("empty");
await waitForRender(view, () => {
expect(view!.textContent).toContain("game_info_modal.no_winner");
expect(view!.querySelector("player-row")).toBeNull();
});
});
it("clears rendered game state when gameId becomes null", async () => {
fetchMock.mockResolvedValue(makeSession("game-1", GameMapType.Montreal));
view = mountView("game-1");
await waitForRender(view, () => {
expect(view!.querySelectorAll("player-row")).toHaveLength(1);
});
view.gameId = null;
await waitForRender(view, () => {
const state = view as unknown as {
gameInfo: AnalyticsRecord["info"] | null;
rankedPlayers: unknown[];
};
expect(view!.textContent?.trim()).toBe("");
expect(state.gameInfo).toBeNull();
expect(state.rankedPlayers).toEqual([]);
});
});
it("ignores a pending response after gameId becomes null", async () => {
const pending = deferred<AnalyticsRecord | false>();
fetchMock.mockReturnValue(pending.promise);
view = mountView("pending");
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledWith("pending"));
view.gameId = null;
await waitForRender(view, () => {
expect(view!.textContent?.trim()).toBe("");
});
pending.resolve(makeSession("pending", GameMapType.Montreal));
await pending.promise;
await Promise.resolve();
await view.updateComplete;
const state = view as unknown as {
gameInfo: AnalyticsRecord["info"] | null;
rankedPlayers: unknown[];
};
expect(state.gameInfo).toBeNull();
expect(state.rankedPlayers).toEqual([]);
expect(view.textContent?.trim()).toBe("");
});
it("refreshes the stats view and translated ranking children", async () => {
fetchMock.mockResolvedValue(makeSession("game-1", GameMapType.Montreal));
view = mountView("game-1");
await waitForRender(view, () => {
expect(view!.querySelector("ranking-controls")).not.toBeNull();
expect(view!.querySelector("ranking-header")).not.toBeNull();
});
const controls = view.querySelector<
HTMLElement & { requestUpdate(): void }
>("ranking-controls")!;
const header = view.querySelector<HTMLElement & { requestUpdate(): void }>(
"ranking-header",
)!;
const viewUpdate = vi.spyOn(view, "requestUpdate");
const controlsUpdate = vi.spyOn(controls, "requestUpdate");
const headerUpdate = vi.spyOn(header, "requestUpdate");
const selector = new LangSelector();
selector.translations = { "main.title": "OpenFront" };
selector.defaultTranslations = selector.translations;
(
selector as unknown as {
applyTranslation(): void;
}
).applyTranslation();
expect(viewUpdate).toHaveBeenCalled();
expect(controlsUpdate).toHaveBeenCalled();
expect(headerUpdate).toHaveBeenCalled();
});
it("ignores a stale response when a newer game resolves first", async () => {
const first = deferred<AnalyticsRecord | false>();
const second = deferred<AnalyticsRecord | false>();
fetchMock.mockImplementation((gameId) =>
gameId === "first" ? first.promise : second.promise,
);
view = mountView("first");
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledWith("first"));
view.gameId = "second";
await view.updateComplete;
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledWith("second"));
second.resolve(makeSession("second", GameMapType.Europe));
await second.promise;
await waitForRender(view, () => {
expect(view!.textContent).toContain(GameMapType.Europe);
});
first.resolve(makeSession("first", GameMapType.Montreal));
await first.promise;
await Promise.resolve();
await view.updateComplete;
expect(view.textContent).toContain(GameMapType.Europe);
expect(view.textContent).not.toContain(GameMapType.Montreal);
});
});