Files
OpenFrontIO/tests/StatsTableVirtualization.test.ts
T
RyanandGitHub d76a0c4009 feat(client): configurable leaderboard and team stats columns (#4646)
## Description:

- rips out some duped code, making tables use the same layout, where the
only thing different is the row content.
- updated text to be emojis to save space
- updated columns to be as wide as they need to be
- updated cells to have left/right thin borders to read easier
- added more supported column types
- removed the + button to always show everyone, where the current player
is either at the bottom or in the top 5
- added a cogwheel to change options
- can select different options for each menu type (player/team stats)

<img width="582" height="630" alt="image"
src="https://github.com/user-attachments/assets/e54dc3d5-eb2c-4bab-8732-06bea68aec45"
/>

works fine on mobile, will probably need some future love though (has
h-scroll, so all options can be seen by scrolling):


## 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-22 13:13:43 -07:00

129 lines
3.9 KiB
TypeScript

import { PlayerStats } from "../src/client/hud/layers/PlayerStats";
import type { GameView, PlayerView } from "../src/client/view";
import { UserSettings } from "../src/core/game/UserSettings";
function player(id: string, tiles: number): PlayerView {
return {
id: () => id,
displayName: () => id,
numTilesOwned: () => tiles,
gold: () => BigInt(tiles),
troops: () => tiles,
totalUnitLevels: () => tiles,
isAlive: () => true,
isOnSameTeam: () => false,
} as unknown as PlayerView;
}
function gameWith(players: PlayerView[], me: PlayerView | null): GameView {
return {
myPlayer: () => me,
playerViews: () => players,
config: () => ({ maxTroops: () => 100 }),
numLandTiles: () => 100,
numTilesWithFallout: () => 0,
} as unknown as GameView;
}
async function mount(game: GameView): Promise<PlayerStats> {
const playerStats = new PlayerStats();
playerStats.game = game;
playerStats.visible = true;
document.body.append(playerStats);
playerStats.refresh();
await playerStats.updateComplete;
return playerStats;
}
function scrollRows(el: PlayerStats): string[] {
return [...el.querySelectorAll(".stats-table-scroll .stats-table-row")].map(
(row) => row.textContent?.trim().split(/\s+/)[0] ?? "",
);
}
// jsdom reports zero element sizes, so the component falls back to its
// defaults: 24px rows, 180px viewport, 4 overscan rows. The expected
// window is ceil(180 / 24) + 4 = 12 rows at scrollTop 0.
describe("StatsTable virtualization", () => {
beforeEach(() => {
localStorage.clear();
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
afterEach(() => {
document.body.innerHTML = "";
});
it("renders only a window of rows for large player lists", async () => {
const players = Array.from({ length: 300 }, (_, i) =>
player(`p${i}`, 1000 - i),
);
const el = await mount(gameWith(players, null));
expect(scrollRows(el)).toEqual(
Array.from({ length: 12 }, (_, i) => `${i + 1}`),
);
const bottomSpacer = el.querySelector(
".stats-table-scroll .stats-table-spacer:last-child",
);
expect(bottomSpacer?.getAttribute("style")).toContain(
`height: ${(300 - 12) * 24}px`,
);
el.remove();
});
it("reveals later rows with a top spacer when scrolled", async () => {
const players = Array.from({ length: 300 }, (_, i) =>
player(`p${i}`, 1000 - i),
);
const el = await mount(gameWith(players, null));
const scroller = el.querySelector<HTMLElement>(".stats-table-scroll")!;
scroller.scrollTop = 240;
scroller.dispatchEvent(new Event("scroll"));
await el.updateComplete;
// first = floor(240/24) - 4 = 6; last = ceil((240+180)/24) + 4 = 22
expect(scrollRows(el)).toEqual(
Array.from({ length: 16 }, (_, i) => `${i + 7}`),
);
const topSpacer = scroller.querySelector(".stats-table-spacer");
expect(topSpacer?.getAttribute("style")).toContain(`height: ${6 * 24}px`);
el.remove();
});
it("renders the pinned row even when it is far outside the window", async () => {
const players = Array.from({ length: 299 }, (_, i) =>
player(`p${i}`, 1000 - i),
);
const me = player("me", 1);
players.push(me);
const el = await mount(gameWith(players, me));
const pinned = el.querySelector(".stats-table-pinned-row");
expect(pinned?.textContent).toContain("me");
expect(pinned?.textContent?.trim().split(/\s+/)[0]).toBe("300");
expect(scrollRows(el)).toEqual(
Array.from({ length: 12 }, (_, i) => `${i + 1}`),
);
el.remove();
});
it("renders small lists fully without spacers", async () => {
const players = Array.from({ length: 7 }, (_, i) =>
player(`p${i}`, 100 - i),
);
const el = await mount(gameWith(players, null));
expect(scrollRows(el)).toEqual(["1", "2", "3", "4", "5", "6", "7"]);
expect(el.querySelector(".stats-table-spacer")).toBeNull();
el.remove();
});
});