Files
OpenFrontIO/tests/client/LeaderboardModal.test.ts
T
Ryan df05d21fc2 Clan System Part 2 - UI (#3625)
## Description:

Continuation from #3276 

Adds the complete client-side clan UI as a Lit web component
(`<clan-modal>`), a typed API client with Zod-validated responses,
shared response schemas, and a reusable `<confirm-dialog>` component.


### New: `ClanModal.ts`

| View | What it does |
|------|-------------|
| **My Clans** | Lists joined clans + pending join requests (built from
`/users/@me`, no extra fetches) |
| **Browse** | Search by tag (min 3 chars), paginated results,
configurable per-page (10/25/50) |
| **Clan Detail** | Stats, paginated + searchable member list, role
badges, join/leave/request actions |
| **Manage** | Edit name (max 35 chars) + description, toggle
open/invite-only, disband |
| **Transfer** | Leadership transfer with member selector + confirmation
|
| **Requests** | Approve/deny join requests (leader/officer) |
| **Bans** | View and unban (leader/officer) |
| **My Requests** | View and withdraw outgoing requests |

### New: `ConfirmDialog.ts`

Reusable `<confirm-dialog>` Lit component — replaces native
`confirm()`/`prompt()` which are blocked or broken on mobile and
CrazyGames iframes. Supports danger/warning variants and an optional
textarea (used for ban reasons). Fires `confirm`/`cancel` events.

### New: `ClanApi.ts`

Typed API client covering all clan endpoints. Every response is
Zod-validated. Auth header is always last in the spread (can't be
overridden by callers). Unknown server error messages always fall back
to a generic client-side string — never displayed verbatim.

### New: `ClanApiSchemas.ts` (in `src/core/`)

Shared Zod schemas for clan API responses with max-length constraints on
`name` (35) and `description` (200). Lives in `core/` so it can be
consumed by both client code and the leaderboard table.

### Modified: `ApiSchemas.ts`

- Added `clans` and `clanRequests` arrays to `UserMeResponseSchema`
- Moved clan leaderboard schemas out to `ClanApiSchemas.ts`
- Renamed `LeaderboardClanTagSchema` → `RequiredClanTagSchema`

### Modified: `Api.ts`

- Added `invalidateUserMe()` to bust the cached `/users/me` response
after mutations
- Removed `fetchClanLeaderboard` (moved to `ClanApi.ts`)

### Tests

- `ClanModal.test.ts` — rendering, view navigation, user actions
- `ClanApiQueries.test.ts` — fetch functions, error handling, pagination
- `ClanApiMutations.test.ts` — join, leave, kick, ban, promote,
transfer, etc.
- `ClanApiBans.test.ts` — ban/unban calls and error paths
- `ClanApiSchemas.test.ts` — Zod schema validation edge cases
- `LeaderboardModal.test.ts` — updated imports

## Notable design decisions

- **Not-logged-in state** — shows "Sign in to join clans" instead of
false "no clans" empty state
- **Rate limit feedback** — reads `Retry-After` header and surfaces wait
time to the user

## 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

w.o.n

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
2026-04-30 21:27:35 -06:00

391 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@lit-labs/virtualizer/virtualize.js", async () => {
const { html } = await import("lit");
return {
virtualize: vi.fn(() => html``),
};
});
vi.mock("../../src/client/Utils", () => ({
translateText: vi.fn((key: string) => {
const translations: Record<string, string> = {
"leaderboard_modal.win_score_tooltip":
"Weighted wins based on clan participation and match difficulty",
"leaderboard_modal.loss_score_tooltip":
"Weighted losses based on clan participation and match difficulty",
"leaderboard_modal.title": "Leaderboard",
"leaderboard_modal.ranked_tab": "Ranked",
"leaderboard_modal.clans_tab": "Clans",
"leaderboard_modal.refresh_time": "Refreshed every 1 hour",
"leaderboard_modal.error": "Something went wrong",
"leaderboard_modal.rank": "Rank",
"leaderboard_modal.clan": "Clan",
"leaderboard_modal.games": "Games",
"leaderboard_modal.win_score": "Win Score",
"leaderboard_modal.loss_score": "Loss Score",
"leaderboard_modal.win_loss_ratio": "W/L",
"leaderboard_modal.ratio": "Ratio",
"leaderboard_modal.elo": "Elo",
"leaderboard_modal.player": "Player",
"leaderboard_modal.loading": "Loading",
"leaderboard_modal.try_again": "Try Again",
"leaderboard_modal.no_data_yet": "No data yet",
"leaderboard_modal.no_stats": "No stats",
"leaderboard_modal.your_ranking": "Your ranking",
"common.close": "Close",
};
return translations[key] || key;
}),
}));
vi.mock("../../src/client/Api", () => {
const getApiBase = () => "http://localhost:3000";
return {
getApiBase: vi.fn(getApiBase),
getUserMe: vi.fn(async () => false),
fetchPlayerLeaderboard: vi.fn(async (page: number) => {
const url = new URL(`${getApiBase()}/leaderboard/ranked`);
url.searchParams.set("page", String(page));
const res = await fetch(url.toString(), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
if (res.status === 400) {
const errorJson = await res.json().catch(() => null);
if (errorJson?.message?.includes("Page must be between")) {
return "reached_limit";
}
}
return false;
}
return res.json();
}),
};
});
vi.mock("../../src/client/ClanApi", () => {
const getApiBase = () => "http://localhost:3000";
return {
fetchClanLeaderboard: vi.fn(async () => {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return false;
return res.json();
}),
};
});
const jsonRes = (data: any, ok = true, status = 200) => ({
ok,
status,
json: async () => data,
});
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: any) => {
const url =
typeof input === "string" ? input : (input?.url ?? String(input));
if (url.includes("/public/clans/leaderboard")) {
return jsonRes({ start: "...", end: "...", clans: [] });
}
if (url.includes("/leaderboard/ranked")) {
return jsonRes({ "1v1": [] });
}
return jsonRes({}, false, 404);
}),
);
});
import { LeaderboardModal } from "../../src/client/LeaderboardModal";
describe("LeaderboardModal", () => {
let modal: LeaderboardModal;
const awaitChildUpdate = async (selector: string) => {
const el = modal.querySelector(selector) as {
updateComplete?: Promise<unknown>;
} | null;
if (el?.updateComplete) {
await el.updateComplete;
}
};
const getClanTable = () =>
modal.querySelector("leaderboard-clan-table") as {
loadClanLeaderboard: () => Promise<void>;
updateComplete: Promise<unknown>;
} | null;
const getPlayerList = () =>
modal.querySelector("leaderboard-player-list") as {
loadPlayerLeaderboard: (reset?: boolean) => Promise<void>;
updateComplete: Promise<unknown>;
playerData: Array<Record<string, unknown>>;
currentUserEntry?: { playerId: string } | null;
} | null;
beforeEach(async () => {
vi.stubGlobal("fetch", vi.fn());
if (!customElements.get("leaderboard-modal")) {
customElements.define("leaderboard-modal", LeaderboardModal);
}
modal = document.createElement("leaderboard-modal") as LeaderboardModal;
document.body.appendChild(modal);
await modal.updateComplete;
});
afterEach(() => {
document.body.removeChild(modal);
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("Tooltip Implementation - Issue #2508", () => {
it("should render Win Score and Loss Score columns with title attributes", async () => {
// Mock fetch to return sample clan leaderboard data
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
start: "2025-01-01T00:00:00Z",
end: "2025-01-07T23:59:59Z",
clans: [
{
clanTag: "[TEST]",
games: 10,
wins: 8,
losses: 2,
playerSessions: 25,
weightedWins: 8.5,
weightedLosses: 1.5,
weightedWLRatio: 5.67,
},
{
clanTag: "[DEMO]",
games: 8,
wins: 6,
losses: 2,
playerSessions: 20,
weightedWins: 6.0,
weightedLosses: 2.0,
weightedWLRatio: 3.0,
},
],
}),
});
(modal as unknown as { activeTab: string }).activeTab = "clans";
const clanTable = getClanTable();
expect(clanTable).toBeTruthy();
await clanTable!.loadClanLeaderboard();
await clanTable!.updateComplete;
const allHeaders = modal.querySelectorAll("th");
let winScoreHeader: Element | null = null;
let lossScoreHeader: Element | null = null;
// Find the headers by their text content and title attribute
allHeaders.forEach((th) => {
const title = th.getAttribute("title");
if (title?.includes("Weighted wins")) {
winScoreHeader = th;
} else if (title?.includes("Weighted losses")) {
lossScoreHeader = th;
}
});
// Assert that headers exist with correct tooltip text
expect(winScoreHeader).toBeTruthy();
expect(lossScoreHeader).toBeTruthy();
expect(winScoreHeader!.getAttribute("title")).toBe(
"Weighted wins based on clan participation and match difficulty",
);
expect(lossScoreHeader!.getAttribute("title")).toBe(
"Weighted losses based on clan participation and match difficulty",
);
});
it("should use translateText for tooltip internationalization", async () => {
// Verify translation keys are correct
const { translateText } = await import("../../src/client/Utils");
expect(translateText("leaderboard_modal.win_score_tooltip")).toBe(
"Weighted wins based on clan participation and match difficulty",
);
expect(translateText("leaderboard_modal.loss_score_tooltip")).toBe(
"Weighted losses based on clan participation and match difficulty",
);
});
});
describe("Player Data Mapping", () => {
it("should map ranked leaderboard data and set current user entry", async () => {
const { getUserMe } = await import("../../src/client/Api");
(getUserMe as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
player: { publicId: "player-2" },
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
"1v1": [
{
rank: 1,
elo: 1200,
peakElo: 1300,
wins: 6,
losses: 4,
total: 10,
public_id: "player-1",
username: "Alpha",
clanTag: "[AAA]",
},
{
rank: 2,
elo: 1100,
peakElo: 1250,
wins: 4,
losses: 6,
total: 10,
public_id: "player-2",
username: "Bravo",
clanTag: null,
},
],
}),
});
const playerList = getPlayerList();
expect(playerList).toBeTruthy();
await playerList!.loadPlayerLeaderboard(true);
await playerList!.updateComplete;
const playerData = playerList!.playerData;
expect(playerData).toHaveLength(2);
expect(playerData[0]).toEqual(
expect.objectContaining({
playerId: "player-1",
username: "Alpha",
clanTag: "[AAA]",
elo: 1200,
games: 10,
wins: 6,
losses: 4,
winRate: 0.6,
}),
);
expect(playerData[1]).toEqual(
expect.objectContaining({
playerId: "player-2",
username: "Bravo",
clanTag: undefined,
winRate: 0.4,
}),
);
expect(playerList!.currentUserEntry?.playerId).toBe("player-2");
});
});
describe("Modal Functionality", () => {
it("should initialize with default state", () => {
expect(modal).toBeTruthy();
expect((modal as unknown as { activeTab: string }).activeTab).toBe(
"players",
);
});
it("should be a custom element", () => {
expect(modal).toBeInstanceOf(LeaderboardModal);
expect(modal.tagName.toLowerCase()).toBe("leaderboard-modal");
});
it("should close on Escape when open", () => {
const mockModalEl = { open: vi.fn(), close: vi.fn() };
Object.defineProperty(modal, "modalEl", {
get: () => mockModalEl,
configurable: true,
});
(modal as unknown as { onOpen: () => void }).onOpen = vi.fn();
modal.open();
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
true,
);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
false,
);
expect(mockModalEl.close).toHaveBeenCalled();
});
});
describe("Modal Interaction", () => {
it("should switch to clans tab and request clan leaderboard data", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
start: "2025-01-01T00:00:00Z",
end: "2025-01-07T23:59:59Z",
clans: [],
}),
});
const tab = modal.querySelector("#clan-leaderboard-tab");
expect(tab).toBeTruthy();
tab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect((modal as unknown as { activeTab: string }).activeTab).toBe(
"clans",
);
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:3000/public/clans/leaderboard",
{ headers: { Accept: "application/json" } },
);
await Promise.resolve();
await modal.updateComplete;
await awaitChildUpdate("leaderboard-clan-table");
});
it("should render a no data state for empty clan leaderboard", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
start: "2025-01-01T00:00:00Z",
end: "2025-01-07T23:59:59Z",
clans: [],
}),
});
(modal as unknown as { activeTab: string }).activeTab = "clans";
const clanTable = getClanTable();
expect(clanTable).toBeTruthy();
await clanTable!.loadClanLeaderboard();
await clanTable!.updateComplete;
expect(modal.textContent).toContain("No data yet");
expect(modal.textContent).toContain("No stats");
});
it("should render an error state when clan leaderboard fails", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({}),
});
(modal as unknown as { activeTab: string }).activeTab = "clans";
const clanTable = getClanTable();
expect(clanTable).toBeTruthy();
await clanTable!.loadClanLeaderboard();
await clanTable!.updateComplete;
expect(modal.textContent).toContain("Something went wrong");
expect(modal.textContent).toContain("Try Again");
});
});
});