${this.renderButton(
RankType.Lifetime,
this.rankType === RankType.Lifetime,
"game_info_modal.duration",
+ this.renderClockIcon(),
)}
${this.renderButton(
RankType.ConquestHumans,
isWarRanking(this.rankType),
"game_info_modal.war",
+ this.renderWarIcon(),
)}
${this.renderButton(
RankType.TotalGold,
isEconomyRanking(this.rankType),
"game_info_modal.economy",
+ this.renderEconomyIcon(),
)}
`;
}
- private renderButton(type: RankType, active: boolean, label: string) {
+ private renderButton(
+ type: RankType,
+ active: boolean,
+ label: string,
+ icon: TemplateResult,
+ ) {
return html`
`;
}
- private renderWarSubranking() {
- if (!isWarRanking(this.rankType)) return "";
-
+ private renderMetricSelector(metrics: readonly Metric[], ariaLabel: string) {
+ const hasSixMetrics = metrics.length === 6;
return html`
-
- ${this.renderSubButton(
- RankType.MIRV,
- isBombRanking(this.rankType),
- "game_info_modal.bombs",
- )}
- ${this.renderSubButton(
- RankType.ConquestHumans,
- isConquestRanking(this.rankType),
- "game_info_modal.conquests",
+
+ ${metrics.map((metric, index) =>
+ this.renderMetricButton(
+ metric.type,
+ metric.label,
+ hasSixMetrics
+ ? "col-span-2"
+ : index < 2
+ ? "col-span-3"
+ : "col-span-2",
+ ),
)}
`;
}
- private renderEconomySubranking() {
- if (!isEconomyRanking(this.rankType)) return "";
-
- const econButtons = [
- [RankType.StolenGold, "game_info_modal.pirate"],
- [RankType.ConqueredGold, "game_info_modal.conquered"],
- [RankType.TotalGold, "game_info_modal.total_gold"],
- ];
-
- return html`
-
- ${this.renderSubButton(
- RankType.NavalTrade,
- isTradeRanking(this.rankType),
- "game_info_modal.trade",
- )}
- ${econButtons.map(([type, label]) =>
- this.renderSubButton(type as RankType, this.rankType === type, label),
- )}
-
- `;
- }
-
- private renderSubButton(type: RankType, active: boolean, label: string) {
+ private renderMetricButton(
+ type: RankType,
+ label: string,
+ mobileSpan: string,
+ ) {
+ const active = this.rankType === type;
return html`
`;
}
+ private renderClockIcon(): TemplateResult {
+ return html`
`;
+ }
+
+ private renderWarIcon(): TemplateResult {
+ return html`
`;
+ }
+
+ private renderEconomyIcon(): TemplateResult {
+ return html`
`;
+ }
+
render() {
return html`
- ${this.renderMainButtons()} ${this.renderWarSubranking()}
- ${this.renderEconomySubranking()}
+ ${this.renderMainButtons()}
+ ${isWarRanking(this.rankType)
+ ? this.renderMetricSelector(
+ warMetrics,
+ translateText("game_info_modal.war"),
+ )
+ : isEconomyRanking(this.rankType)
+ ? this.renderMetricSelector(
+ economyMetrics,
+ translateText("game_info_modal.economy"),
+ )
+ : ""}
`;
}
diff --git a/src/client/components/baseComponents/ranking/RankingHeader.ts b/src/client/components/baseComponents/ranking/RankingHeader.ts
deleted file mode 100644
index cd1270d15..000000000
--- a/src/client/components/baseComponents/ranking/RankingHeader.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { LitElement, html, nothing } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { translateText } from "../../../Utils";
-import { RankType } from "./GameInfoRanking";
-
-@customElement("ranking-header")
-export class RankingHeader extends LitElement {
- @property({ type: String }) rankType = RankType.Lifetime;
-
- private onSort(type: RankType) {
- this.dispatchEvent(new CustomEvent("sort", { detail: type }));
- }
-
- render() {
- return html`
-
- ${this.renderHeaderContent()}
-
- `;
- }
-
- private renderHeaderContent() {
- switch (this.rankType) {
- case RankType.Lifetime:
- return html`
- ${translateText("game_info_modal.survival_time")}
-
`;
- case RankType.ConquestHumans:
- case RankType.ConquestBots:
- return html`
-
- ${this.renderMultipleChoiceHeaderButton(
- translateText("game_info_modal.num_of_conquests_humans"),
- RankType.ConquestHumans,
- )}
- /
- ${this.renderMultipleChoiceHeaderButton(
- translateText("game_info_modal.num_of_conquests_bots"),
- RankType.ConquestBots,
- )}
-
- `;
- case RankType.Atoms:
- case RankType.Hydros:
- case RankType.MIRV:
- return html`
-
- ${this.renderMultipleChoiceHeaderButton(
- translateText("game_info_modal.atoms"),
- RankType.Atoms,
- )}
- /
- ${this.renderMultipleChoiceHeaderButton(
- translateText("game_info_modal.hydros"),
- RankType.Hydros,
- )}
- /
- ${this.renderMultipleChoiceHeaderButton(
- translateText("game_info_modal.mirv"),
- RankType.MIRV,
- )}
-
- `;
- case RankType.TotalGold:
- return html`
- ${translateText("game_info_modal.all_gold")}
-
`;
- case RankType.NavalTrade:
- case RankType.TrainTrade:
- return html`
-
- ${this.renderMultipleChoiceHeaderButton(
- translateText("game_info_modal.train_trade"),
- RankType.TrainTrade,
- )}
- /
- ${this.renderMultipleChoiceHeaderButton(
- translateText("game_info_modal.naval_trade"),
- RankType.NavalTrade,
- )}
-
- `;
- case RankType.ConqueredGold:
- return html`
- ${translateText("game_info_modal.conquest_gold")}
-
`;
- case RankType.StolenGold:
- return html`
- ${translateText("game_info_modal.stolen_gold")}
-
`;
- default:
- console.warn("Unhandled RankType", this.rankType);
- return null;
- }
- }
-
- private renderMultipleChoiceHeaderButton(label: string, type: RankType) {
- return html`
-
- `;
- }
-
- createRenderRoot() {
- return this;
- }
-}
diff --git a/src/client/components/baseComponents/stats/GameInfoView.ts b/src/client/components/baseComponents/stats/GameInfoView.ts
index 59144b3d8..eeedabf7d 100644
--- a/src/client/components/baseComponents/stats/GameInfoView.ts
+++ b/src/client/components/baseComponents/stats/GameInfoView.ts
@@ -1,14 +1,29 @@
-import { html, LitElement, type PropertyValues } from "lit";
+import {
+ html,
+ LitElement,
+ type PropertyValues,
+ type TemplateResult,
+} from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { type GameEndInfo } from "../../../../core/Schemas";
-import { GameMode, type GameMapType } from "../../../../core/game/Game";
+import {
+ GameMode,
+ GameType,
+ type GameMapType,
+} from "../../../../core/game/Game";
import { fetchGameById } from "../../../Api";
import { terrainMapFileLoader } from "../../../TerrainMapFileLoader";
import { getMapName, renderDuration, translateText } from "../../../Utils";
-import { Ranking, RankType, type PlayerInfo } from "../ranking/GameInfoRanking";
+import { renderLoadingSpinner } from "../../BaseModal";
+import {
+ RANK_TYPE_LABEL_KEYS,
+ Ranking,
+ RankType,
+ type PlayerInfo,
+} from "../ranking/GameInfoRanking";
import "../ranking/PlayerRow";
import "../ranking/RankingControls";
-import "../ranking/RankingHeader";
+import { formatAbsoluteTime } from "./GameHistoryDates";
/**
* Game-stats content for the Account > Games > Stats drill-down.
@@ -54,7 +69,7 @@ export class GameInfoView extends LitElement {
render() {
if (!this.gameId) return html``;
return html`
-
+
${this.isLoadingGame
? this.renderLoadingAnimation()
: this.loadFailed
@@ -67,31 +82,65 @@ export class GameInfoView extends LitElement {
private renderRanking() {
if (this.rankedPlayers.length === 0) {
return html`
-
-
${translateText("game_info_modal.no_winner")}
+ ${this.renderGameInfo()}
+
+
+ ${this.renderTrophyIcon("size-6")}
+
+
+ ${translateText("game_info_modal.no_winner")}
+
`;
}
return html`
${this.renderGameInfo()}
-
- ${this.renderSummaryTable()}
+
+
+ ${this.renderSummaryTable()}
+
`;
}
private renderError() {
return html`
-
${translateText("game_info_modal.load_failed")}
+
+
+ ${translateText("game_info_modal.load_failed")}
+
@@ -104,16 +153,9 @@ export class GameInfoView extends LitElement {
}
private renderLoadingAnimation() {
- return html`
-
-
- ${translateText("game_info_modal.loading_game_info")}
-
-
-
- `;
+ return renderLoadingSpinner(
+ translateText("game_info_modal.loading_game_info"),
+ );
}
private sort(e: CustomEvent
) {
@@ -130,64 +172,239 @@ export class GameInfoView extends LitElement {
private renderGameInfo() {
const info = this.gameInfo;
if (!info) return html``;
+ const mapName = getMapName(info.config.gameMap) ?? info.config.gameMap;
+ const startDate = new Date(info.start).toISOString();
return html`
- ${this.mapImage
- ? html`

`
- : html`
`}
-
-
-
${translateText(
- info.config.gameMode === GameMode.Team
- ? "game_mode.teams"
- : "game_mode.ffa",
- )}
-
${getMapName(info.config.gameMap) ?? info.config.gameMap}
+
+
+ ${this.mapImage
+ ? html`

this.handleMapImageError()}
+ class="absolute inset-0 h-full w-full object-cover"
+ />`
+ : this.renderMapFallback()}
+
-
${renderDuration(info.duration)}
-
- ${info.players.length} ${translateText("game_info_modal.players")}
+
+
+
+
+ ${translateText(
+ info.config.gameMode === GameMode.Team
+ ? "game_mode.teams"
+ : "game_mode.ffa",
+ )}
+
+
+ ${this.gameTypeLabel(info.config.gameType)}
+
+
+
+ ${mapName}
+
+
+
+ ${this.renderCalendarIcon("size-3.5")}
+
+
+
+
+
+ ${this.renderInfoMetric(
+ this.renderClockIcon("size-4"),
+ translateText("game_info_modal.duration"),
+ renderDuration(info.duration),
+ )}
+ ${this.renderInfoMetric(
+ this.renderPlayersIcon("size-4"),
+ translateText("game_info_modal.players"),
+ String(info.players.length),
+ )}
+
`;
}
- private renderSummaryTable() {
- const bestScore =
- this.rankedPlayers.length > 0 ? this.score(this.rankedPlayers[0]) : 0;
+ private renderInfoMetric(
+ icon: TemplateResult,
+ label: string,
+ value: string,
+ ): TemplateResult {
return html`
-
-
- ${this.rankedPlayers.map(
- (player, index) => html`
-
- `,
- )}
-
+
+
+ ${icon}
+ ${label}
+
+
+ ${value}
+
+
`;
}
+ private renderMapFallback(): TemplateResult {
+ return html`
+
+ `;
+ }
+
+ private handleMapImageError(): void {
+ this.mapImage = null;
+ }
+
+ private gameTypeLabel(gameType: GameType): string {
+ switch (gameType) {
+ case GameType.Public:
+ return translateText("account_modal.games_type_public");
+ case GameType.Private:
+ return translateText("account_modal.games_type_private");
+ case GameType.Singleplayer:
+ return translateText("account_modal.games_type_singleplayer");
+ default:
+ return translateText("game_info_modal.unknown_game_type");
+ }
+ }
+
+ private renderSummaryTable() {
+ const bestScore = this.rankedPlayers.reduce(
+ (best, player) => Math.max(best, this.score(player)),
+ 0,
+ );
+ return html`
+
+
+ ${this.rankedPlayers.map(
+ (player, index) => html`
+
+ `,
+ )}
+
+
+ `;
+ }
+
+ private renderClockIcon(className: string): TemplateResult {
+ return html`
`;
+ }
+
+ private renderCalendarIcon(className: string): TemplateResult {
+ return html`
`;
+ }
+
+ private renderPlayersIcon(className: string): TemplateResult {
+ return html`
`;
+ }
+
+ private renderTrophyIcon(className: string): TemplateResult {
+ return html`
`;
+ }
+
private score(player: PlayerInfo): number {
if (!this.ranking) return 0;
return this.ranking.score(player, this.rankType);
@@ -201,6 +418,7 @@ export class GameInfoView extends LitElement {
this.gameInfo = null;
this.ranking = null;
this.rankedPlayers = [];
+ this.rankType = RankType.Lifetime;
try {
const session = await fetchGameById(gameId);
diff --git a/tests/GameInfoRanking.test.ts b/tests/GameInfoRanking.test.ts
index f77350078..80b426ca5 100644
--- a/tests/GameInfoRanking.test.ts
+++ b/tests/GameInfoRanking.test.ts
@@ -122,6 +122,20 @@ describe("Ranking class", () => {
expect(p1.conquests).toStrictEqual([5n]);
expect(p1.atoms).toBe(1);
expect(p1.mirv).toBe(2);
+ expect(p1.killedAt).toBeUndefined();
+
+ const p3 = players.find((p) => p.id === "p3")!;
+ expect(p3.killedAt).toBe(600);
+ });
+
+ test("preserves a zero death timestamp", () => {
+ const session = makeSession();
+ session.info.players[0]!.stats!.killedAt = 0n;
+
+ const r = new Ranking(session);
+ const p1 = r.allPlayers.find((p) => p.id === "p1")!;
+ expect(p1.killedAt).toBe(0);
+ expect(r.score(p1, RankType.Lifetime)).toBe(0);
});
test("correctly identifies winner", () => {
@@ -130,6 +144,17 @@ describe("Ranking class", () => {
expect(p2.winner).toBe(true);
});
+ test("scores player, nation, and tribe kills separately", () => {
+ const session = makeSession();
+ session.info.players[0]!.stats!.conquests = [5n, 3n, 2n];
+ const ranking = new Ranking(session);
+ const player = ranking.allPlayers.find((entry) => entry.id === "p1")!;
+
+ expect(ranking.score(player, RankType.ConquestHumans)).toBe(5);
+ expect(ranking.score(player, RankType.ConquestNations)).toBe(3);
+ expect(ranking.score(player, RankType.ConquestBots)).toBe(2);
+ });
+
test("rank by total gold", () => {
const r = new Ranking(makeSession());
const rankedPlayers = r.sortedBy(RankType.TotalGold);
diff --git a/tests/client/GameInfoView.test.ts b/tests/client/GameInfoView.test.ts
index a9d39cfe1..1fee7c8f3 100644
--- a/tests/client/GameInfoView.test.ts
+++ b/tests/client/GameInfoView.test.ts
@@ -30,6 +30,7 @@ vi.mock("../../src/client/Utils", async (importOriginal) => {
});
import { fetchGameById } from "../../src/client/Api";
+import { formatAbsoluteTime } from "../../src/client/components/baseComponents/stats/GameHistoryDates";
import { GameInfoView } from "../../src/client/components/baseComponents/stats/GameInfoView";
import { LangSelector } from "../../src/client/LangSelector";
@@ -137,11 +138,64 @@ describe("GameInfoView", () => {
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!.querySelector("section")?.getAttribute("aria-label")).toBe(
+ "game_info_modal.survival_time",
+ );
expect(view!.querySelectorAll("player-row")).toHaveLength(1);
});
});
+ it("shows the game start using the history-card date format", async () => {
+ const session = makeSession("game-1", GameMapType.Montreal);
+ session.info.start = Date.UTC(2026, 3, 20, 16, 38, 22);
+ fetchMock.mockResolvedValue(session);
+ view = mountView("game-1");
+
+ const startDate = new Date(session.info.start).toISOString();
+ await waitForRender(view, () => {
+ const date = view!.querySelector
(
+ "[data-game-date] time",
+ );
+ expect(date?.dateTime).toBe(startDate);
+ expect(date?.textContent).toBe(formatAbsoluteTime(startDate));
+ });
+ });
+
+ it("uses a translated fallback for an unknown game type", async () => {
+ const session = makeSession("game-1", GameMapType.Montreal);
+ session.info.config.gameType = "future-game-type" as GameType;
+ fetchMock.mockResolvedValue(session);
+ view = mountView("game-1");
+
+ await waitForRender(view, () => {
+ expect(view!.textContent).toContain("game_info_modal.unknown_game_type");
+ expect(view!.textContent).not.toContain("future-game-type");
+ });
+ });
+
+ it("replaces a failed map image with the map fallback", async () => {
+ fetchMock.mockResolvedValue(makeSession("game-1", GameMapType.Montreal));
+ view = mountView("game-1");
+
+ await waitForRender(view, () => {
+ const image = view!.querySelector("[data-map-image]");
+ expect(image?.getAttribute("src")).toBe(
+ `/maps/${GameMapType.Montreal}.webp`,
+ );
+ expect(image?.alt).toBe(GameMapType.Montreal);
+ expect(view!.querySelector("[data-map-fallback]")).toBeNull();
+ });
+
+ view
+ .querySelector("[data-map-image]")!
+ .dispatchEvent(new Event("error"));
+
+ await waitForRender(view, () => {
+ expect(view!.querySelector("[data-map-image]")).toBeNull();
+ expect(view!.querySelector("[data-map-fallback]")).not.toBeNull();
+ });
+ });
+
it("renders the no-winner state for a game without ranked players", async () => {
fetchMock.mockResolvedValue(
makeSession("empty", GameMapType.Montreal, false),
@@ -150,6 +204,7 @@ describe("GameInfoView", () => {
await waitForRender(view, () => {
expect(view!.textContent).toContain("game_info_modal.no_winner");
+ expect(view!.querySelector("[data-game-summary]")).not.toBeNull();
expect(view!.querySelector("player-row")).toBeNull();
});
});
@@ -234,24 +289,19 @@ describe("GameInfoView", () => {
expect(view.textContent?.trim()).toBe("");
});
- it("refreshes the stats view and translated ranking children", async () => {
+ it("refreshes the stats view and translated ranking controls", 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(
- "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;
@@ -264,7 +314,6 @@ describe("GameInfoView", () => {
expect(viewUpdate).toHaveBeenCalled();
expect(controlsUpdate).toHaveBeenCalled();
- expect(headerUpdate).toHaveBeenCalled();
});
it("ignores a stale response when a newer game resolves first", async () => {
diff --git a/tests/client/GameStatsModal.test.ts b/tests/client/GameStatsModal.test.ts
index 8ac725616..a7f93f7cb 100644
--- a/tests/client/GameStatsModal.test.ts
+++ b/tests/client/GameStatsModal.test.ts
@@ -9,8 +9,13 @@ import {
vi,
} from "vitest";
+const copyToClipboardMock = vi.hoisted(() =>
+ vi.fn(async (_text: string, onSuccess?: () => void) => onSuccess?.()),
+);
+
vi.mock("../../src/client/Utils", () => ({
translateText: vi.fn((key: string) => key),
+ copyToClipboard: copyToClipboardMock,
}));
vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => {
@@ -21,6 +26,7 @@ vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => {
return { GameInfoView: FakeGameInfoView };
});
+import type { CopyButton } from "../../src/client/components/CopyButton";
import { GameStatsModal } from "../../src/client/GameStatsModal";
import { modalRouter } from "../../src/client/ModalRouter";
import { initNavigation } from "../../src/client/Navigation";
@@ -43,6 +49,8 @@ describe("public game stats route", () => {
});
beforeEach(async () => {
+ copyToClipboardMock.mockClear();
+ vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) });
history.replaceState(null, "", "/");
modalRouter.register("stats", {
tag: "game-stats-modal",
@@ -64,6 +72,7 @@ describe("public game stats route", () => {
window.showPage?.("page-play");
modal.remove();
history.replaceState(null, "", "/");
+ vi.unstubAllGlobals();
});
it("opens a shared gameID without mounting the authenticated account", async () => {
@@ -82,6 +91,30 @@ describe("public game stats route", () => {
expect(statsView?.gameId).toBe("public-game");
});
+ const copyButton = modal.querySelector("copy-button")!;
+ await copyButton.updateComplete;
+ expect(copyButton.copyText).toBe("public-game");
+ expect(copyButton.displayText).toBe("public-game");
+ expect(copyButton.compact).toBe(true);
+ expect(copyButton.showVisibilityToggle).toBe(false);
+
+ const copyActions = copyButton.querySelectorAll("button");
+ expect(copyActions).toHaveLength(1);
+ expect(copyActions[0].textContent).toContain("public-game");
+ expect(copyActions[0].getAttribute("aria-label")).toBe(
+ "common.click_to_copy",
+ );
+ copyActions[0].click();
+ await vi.waitFor(() =>
+ expect(copyToClipboardMock).toHaveBeenCalledWith(
+ "public-game",
+ expect.any(Function),
+ expect.any(Function),
+ ),
+ );
+ await copyButton.updateComplete;
+ expect(copyButton.textContent).toContain("common.copied");
+
expect(document.querySelector("account-modal")).toBeNull();
expect(window.location.hash).toBe("#modal=stats&gameID=public-game");
diff --git a/tests/client/PlayerRow.test.ts b/tests/client/PlayerRow.test.ts
new file mode 100644
index 000000000..50832457d
--- /dev/null
+++ b/tests/client/PlayerRow.test.ts
@@ -0,0 +1,261 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import {
+ type PlayerInfo,
+ RankType,
+} from "../../src/client/components/baseComponents/ranking/GameInfoRanking";
+import { PlayerRow } from "../../src/client/components/baseComponents/ranking/PlayerRow";
+
+const basePlayer: PlayerInfo = {
+ id: "player-1",
+ username: "Ada Lovelace",
+ clanTag: null,
+ gold: [],
+ conquests: [],
+ winner: false,
+ atoms: 0,
+ hydros: 0,
+ mirv: 0,
+};
+
+type RowOptions = {
+ player?: Partial;
+ rankType?: RankType;
+ score?: number;
+ bestScore?: number;
+};
+
+async function mountRow({
+ player = {},
+ rankType = RankType.Lifetime,
+ score = 100,
+ bestScore = 100,
+}: RowOptions = {}): Promise {
+ const row = document.createElement("player-row") as PlayerRow;
+ row.player = { ...basePlayer, ...player };
+ row.rankType = rankType;
+ row.score = score;
+ row.bestScore = bestScore;
+ document.body.appendChild(row);
+ await row.updateComplete;
+ return row;
+}
+
+describe("PlayerRow", () => {
+ let row: PlayerRow | null = null;
+
+ beforeAll(() => {
+ if (!customElements.get("player-row")) {
+ customElements.define("player-row", PlayerRow);
+ }
+ });
+
+ beforeEach(() => {
+ window.BOOTSTRAP_CONFIG = undefined;
+ });
+
+ afterEach(() => {
+ row?.remove();
+ window.BOOTSTRAP_CONFIG = undefined;
+ });
+
+ it("resolves a valid flag through the asset manifest", async () => {
+ window.BOOTSTRAP_CONFIG = {
+ assetManifest: {
+ "flags/test.svg": "/_assets/flags/test.abc123.svg",
+ },
+ cdnBase: "https://cdn.example.test/game-assets",
+ };
+
+ row = await mountRow({ player: { flag: "/flags/test.svg" } });
+
+ const flag = row.querySelector(
+ '[data-player-avatar="flag"]',
+ );
+ expect(flag?.getAttribute("src")).toBe(
+ "https://cdn.example.test/game-assets/_assets/flags/test.abc123.svg",
+ );
+ expect(row.querySelector('[data-player-avatar="fallback"]')).toBeNull();
+ });
+
+ it("replaces a failed flag with the player's initials", async () => {
+ row = await mountRow({
+ player: { username: " alice wonder ", flag: "/flags/missing.svg" },
+ });
+
+ row
+ .querySelector('[data-player-avatar="flag"]')!
+ .dispatchEvent(new Event("error"));
+ await row.updateComplete;
+
+ expect(row.querySelector('[data-player-avatar="flag"]')).toBeNull();
+ expect(
+ row.querySelector('[data-player-avatar="fallback"]')?.textContent?.trim(),
+ ).toBe("AL");
+ });
+
+ it("uses the initials fallback for an invalid asset path", async () => {
+ row = await mountRow({ player: { flag: "../invalid.svg" } });
+
+ expect(row.querySelector('[data-player-avatar="flag"]')).toBeNull();
+ expect(
+ row.querySelector('[data-player-avatar="fallback"]')?.textContent?.trim(),
+ ).toBe("AD");
+ });
+
+ it("renders the clan tag inline before the username", async () => {
+ row = await mountRow({
+ player: { clanTag: "UN", username: "kazz" },
+ });
+
+ const identity = row.querySelector("[data-player-identity]")!;
+ const clanTag = row.querySelector("[data-player-clan-tag]")!;
+ const username = row.querySelector("[data-player-name]")!;
+
+ expect(identity.children[0]).toBe(clanTag);
+ expect(identity.children[1]).toBe(username);
+ expect(clanTag.textContent?.trim()).toBe("UN");
+ expect(username.textContent?.trim()).toBe("kazz");
+ });
+
+ it("preserves an eliminated player's flag and adds eliminated status", async () => {
+ row = await mountRow({
+ player: { flag: "/flags/test.svg", killedAt: 42 },
+ });
+
+ expect(row.querySelector('[data-player-avatar="flag"]')).not.toBeNull();
+ expect(
+ row.querySelector('[data-player-status="eliminated"]')?.textContent,
+ ).toContain("💀");
+ });
+
+ it("treats killedAt zero as eliminated while leaving absent values active", async () => {
+ row = await mountRow({ player: { killedAt: 0 } });
+
+ expect(
+ row.querySelector('[data-player-status="eliminated"]'),
+ ).not.toBeNull();
+
+ row.player = { ...basePlayer };
+ await row.updateComplete;
+
+ expect(row.querySelector('[data-player-status="eliminated"]')).toBeNull();
+ });
+
+ it("shows the winner crown instead of eliminated status", async () => {
+ row = await mountRow({
+ player: { winner: true, killedAt: 42 },
+ });
+
+ expect(row.querySelector('[data-player-status="winner"]')).not.toBeNull();
+ expect(row.querySelector('[data-player-status="eliminated"]')).toBeNull();
+ });
+
+ it("displays lifetime scores as rounded percentages", async () => {
+ row = await mountRow({
+ rankType: RankType.Lifetime,
+ score: 87.6,
+ bestScore: 100,
+ });
+
+ expect(row.textContent).toContain("88%");
+ const progress = row.querySelector('[role="progressbar"]');
+ expect(progress?.getAttribute("aria-valuenow")).toBe("87.6");
+ expect(progress?.getAttribute("aria-valuetext")).toBe("88%");
+ expect(progress?.getAttribute("aria-label")).toContain("Ada Lovelace");
+ const score = row.querySelector("[data-player-score]")!;
+ expect(score.classList.contains("border")).toBe(false);
+ expect(score.classList.contains("rounded-lg")).toBe(false);
+ expect(score.classList.contains("text-sm")).toBe(true);
+ expect(score.classList.contains("text-white/75")).toBe(true);
+ });
+
+ it.each([
+ RankType.ConquestHumans,
+ RankType.ConquestNations,
+ RankType.ConquestBots,
+ ])("renders %s with the plain metric typography", async (rankType) => {
+ row = await mountRow({ rankType, score: 7 });
+
+ const score = row.querySelector("[data-player-score]")!;
+ expect(score.textContent?.trim()).toBe("7");
+ expect(score.classList.contains("border")).toBe(false);
+ expect(score.classList.contains("text-sm")).toBe(true);
+ expect(score.classList.contains("font-mono")).toBe(true);
+ });
+
+ it("uses the same typography for kill, bomb, and economy values", async () => {
+ const sharedClasses = ["text-sm", "font-mono", "font-bold", "tabular-nums"];
+ const rankTypes = [
+ RankType.ConquestHumans,
+ RankType.Atoms,
+ RankType.TotalGold,
+ ];
+
+ for (const rankType of rankTypes) {
+ row?.remove();
+ row = await mountRow({ rankType, score: 7 });
+ const score = row.querySelector("[data-player-score]")!;
+ expect(
+ sharedClasses.every((name) => score.classList.contains(name)),
+ ).toBe(true);
+ }
+ });
+
+ it("shows only the selected bomb metric", async () => {
+ row = await mountRow({
+ player: { atoms: 11, hydros: 22, mirv: 33 },
+ rankType: RankType.Hydros,
+ score: 22,
+ });
+
+ const scores = row.querySelectorAll("[data-player-score]");
+ expect(scores).toHaveLength(1);
+ expect(scores[0].textContent?.trim()).toBe("22");
+ expect(scores[0].getAttribute("aria-label")).toContain(
+ "game_info_modal.hydros",
+ );
+ expect(scores[0].textContent).not.toContain("11");
+ expect(scores[0].textContent).not.toContain("33");
+ expect(scores[0].classList.contains("border")).toBe(false);
+ });
+
+ it("renders economy values without a surrounding card", async () => {
+ row = await mountRow({ rankType: RankType.TotalGold, score: 12_345 });
+
+ const score = row.querySelector("[data-player-score]")!;
+ expect(score.textContent).toContain("12.3K");
+ expect(score.classList.contains("border")).toBe(false);
+ expect(score.classList.contains("rounded-lg")).toBe(false);
+ const coin = score.querySelector("img")!;
+ expect(coin.getAttribute("src")).toContain("GoldCoinIcon.svg");
+ expect(coin.getAttribute("width")).toBe("18");
+ expect(coin.getAttribute("height")).toBe("18");
+ });
+
+ it.each([RankType.TrainTrade, RankType.NavalTrade])(
+ "shows one selected trade score for %s",
+ async (rankType) => {
+ row = await mountRow({ rankType, score: 12_345 });
+
+ const scores = row.querySelectorAll("[data-player-score]");
+ expect(scores).toHaveLength(1);
+ expect(scores[0].textContent).toContain("12.3K");
+ expect(scores[0].getAttribute("aria-label")).toContain(
+ rankType === RankType.TrainTrade
+ ? "game_info_modal.train_trade"
+ : "game_info_modal.naval_trade",
+ );
+ },
+ );
+
+ it.each([RankType.Atoms, RankType.Hydros, RankType.MIRV])(
+ "labels the selected bomb score for %s",
+ async (rankType) => {
+ row = await mountRow({ rankType, score: 7 });
+
+ expect(
+ row.querySelector("[data-player-score]")?.getAttribute("aria-label"),
+ ).toContain(`game_info_modal.${rankType.toLowerCase()}`);
+ },
+ );
+});
diff --git a/tests/client/RankingControls.test.ts b/tests/client/RankingControls.test.ts
new file mode 100644
index 000000000..df97aee01
--- /dev/null
+++ b/tests/client/RankingControls.test.ts
@@ -0,0 +1,82 @@
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { RankType } from "../../src/client/components/baseComponents/ranking/GameInfoRanking";
+import { RankingControls } from "../../src/client/components/baseComponents/ranking/RankingControls";
+
+async function mountControls(rankType: RankType): Promise {
+ const controls = document.createElement(
+ "ranking-controls",
+ ) as RankingControls;
+ controls.rankType = rankType;
+ document.body.appendChild(controls);
+ await controls.updateComplete;
+ return controls;
+}
+
+describe("RankingControls", () => {
+ let controls: RankingControls | null = null;
+
+ beforeAll(() => {
+ if (!customElements.get("ranking-controls")) {
+ customElements.define("ranking-controls", RankingControls);
+ }
+ });
+
+ afterEach(() => controls?.remove());
+
+ it("shows every war metric in one selector and dispatches the selected type", async () => {
+ controls = await mountControls(RankType.ConquestHumans);
+ const metrics = [
+ ...controls.querySelectorAll("[data-ranking-metric]"),
+ ];
+
+ expect(metrics.map((button) => button.dataset.rankingMetric)).toEqual([
+ RankType.ConquestHumans,
+ RankType.ConquestNations,
+ RankType.ConquestBots,
+ RankType.Atoms,
+ RankType.Hydros,
+ RankType.MIRV,
+ ]);
+ expect(metrics[0].getAttribute("aria-pressed")).toBe("true");
+ expect(
+ metrics.filter(
+ (button) => button.getAttribute("aria-pressed") === "true",
+ ),
+ ).toHaveLength(1);
+
+ const onSort = vi.fn();
+ controls.addEventListener("sort", onSort);
+ metrics.forEach((metric) => metric.click());
+ expect(onSort.mock.calls.map(([event]) => event.detail)).toEqual(
+ metrics.map((metric) => metric.dataset.rankingMetric),
+ );
+ });
+
+ it("shows every economy metric in one selector", async () => {
+ controls = await mountControls(RankType.TotalGold);
+
+ expect(
+ [
+ ...controls.querySelectorAll(
+ "[data-ranking-metric]",
+ ),
+ ].map((button) => button.dataset.rankingMetric),
+ ).toEqual([
+ RankType.TotalGold,
+ RankType.ConqueredGold,
+ RankType.StolenGold,
+ RankType.TrainTrade,
+ RankType.NavalTrade,
+ ]);
+
+ const metrics = [
+ ...controls.querySelectorAll("[data-ranking-metric]"),
+ ];
+ expect(metrics[0].getAttribute("aria-pressed")).toBe("true");
+ expect(
+ metrics.filter(
+ (button) => button.getAttribute("aria-pressed") === "true",
+ ),
+ ).toHaveLength(1);
+ });
+});