Files
OpenFrontIO/tests/client/clan/ClanShared.test.ts
T
Ryan 005e1b6044 clan stats breakdown (#3869)
## Description:

improvements to clan ui. 
<img width="788" height="290" alt="image"
src="https://github.com/user-attachments/assets/736ca147-bff4-44d8-8180-7b80a85556fe"
/>
added "expand all" and new collapsible sections.


<img width="787" height="550" alt="image"
src="https://github.com/user-attachments/assets/deb2f813-854b-46a9-a767-52c4f749f30f"
/>

which changes to collapse all when expanded

also adds more info about team (d,t,q,2,3,4,5,6,7 team)

## 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
2026-05-06 16:09:53 -06:00

215 lines
7.5 KiB
TypeScript

import { render } from "lit";
import { describe, expect, it } from "vitest";
import type {
ClanJoinRequest,
ClanMember,
ClanMemberStats,
} from "../../../src/client/ClanApi";
import {
filterMembersBySearch,
filterRequestsBySearch,
renderMemberStats,
} from "../../../src/client/components/clan/ClanShared";
const members: ClanMember[] = [
{ publicId: "Alice123", role: "leader", joinedAt: "2024-01-01T00:00:00Z" },
{ publicId: "Bob456", role: "officer", joinedAt: "2024-02-01T00:00:00Z" },
{ publicId: "Charlie789", role: "member", joinedAt: "2024-03-01T00:00:00Z" },
];
const requests: ClanJoinRequest[] = [
{ publicId: "Dave111", createdAt: "2024-04-01T00:00:00Z" },
{ publicId: "Eve222", createdAt: "2024-05-01T00:00:00Z" },
];
describe("filterMembersBySearch", () => {
it("returns all members when search is empty", () => {
expect(filterMembersBySearch(members, "")).toEqual(members);
});
it("matches by publicId (case-insensitive)", () => {
const result = filterMembersBySearch(members, "alice");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Alice123");
});
it("matches by role", () => {
const result = filterMembersBySearch(members, "officer");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Bob456");
});
it("matches partial publicId", () => {
const result = filterMembersBySearch(members, "456");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Bob456");
});
it("returns empty array when nothing matches", () => {
expect(filterMembersBySearch(members, "zzz")).toEqual([]);
});
it("matches 'member' role without matching 'leader' or 'officer'", () => {
const result = filterMembersBySearch(members, "member");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Charlie789");
});
});
describe("renderMemberStats", () => {
const ZERO = { wins: 0, losses: 0 } as const;
const stats: ClanMemberStats = {
total: { wins: 7, losses: 5 },
ffa: { wins: 2, losses: 4 },
team: { wins: 5, losses: 1 },
hvn: { ...ZERO },
duos: { wins: 1, losses: 0 },
trios: { wins: 4, losses: 1 },
quads: { ...ZERO },
"2": { ...ZERO },
"3": { ...ZERO },
"4": { ...ZERO },
"5": { ...ZERO },
"6": { ...ZERO },
"7": { ...ZERO },
ranked: { ...ZERO },
"1v1": { ...ZERO },
};
async function renderTo(
result: ReturnType<typeof renderMemberStats>,
): Promise<HTMLElement> {
const host = document.createElement("div");
render(result, host);
document.body.appendChild(host);
// Allow Lit to upgrade the <clan-stats-breakdown> custom element.
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
return host;
}
function findExpandableButton(
host: HTMLElement,
labelKey: string,
): HTMLButtonElement | undefined {
return Array.from(
host.querySelectorAll<HTMLButtonElement>("button[aria-expanded]"),
).find((b) => (b.textContent ?? "").includes(labelKey));
}
async function expandTotal(host: HTMLElement) {
const btn = findExpandableButton(host, "clan_modal.stats_total");
btn!.click();
await new Promise((r) => setTimeout(r, 0));
}
it("renders nothing when stats is undefined", async () => {
const host = await renderTo(renderMemberStats(undefined));
expect(host.textContent?.trim()).toBe("");
});
it("collapses everything except the total row by default", async () => {
const host = await renderTo(renderMemberStats(stats));
const text = host.textContent ?? "";
expect(text).toContain("clan_modal.stats_total");
expect(text).not.toContain("clan_modal.stats_ffa");
expect(text).not.toContain("clan_modal.stats_team");
expect(text).not.toContain("clan_modal.stats_hvn");
expect(text).not.toContain("clan_modal.stats_ranked");
});
it("renders W/L labels inside bar segments and the win-rate per bucket", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const text = host.textContent?.replace(/\s+/g, " ") ?? "";
expect(text).toContain("2W");
expect(text).toContain("4L");
expect(text).toContain("5W");
expect(text).toContain("1L");
expect(text).toContain("33%");
expect(text).toContain("83%");
expect(text).toContain("—");
});
it("renders a proportional win-loss bar when there are games", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const bars = host.querySelectorAll<HTMLDivElement>("[style*='width']");
// Top-level rows after expanding Total: total, ffa, team, hvn, ranked (5).
// Ranked and hvn have 0 games → no segments. Others contribute 2 each.
expect(bars.length).toBe(6);
const widths = Array.from(bars).map((b) =>
(b.getAttribute("style") ?? "").replace(/\s+/g, ""),
);
expect(widths[0]).toContain("width:58.33");
expect(widths[1]).toContain("width:41.66");
expect(widths[2]).toContain("width:33.33");
expect(widths[3]).toContain("width:66.66");
});
it("includes the visible top-level translated bucket labels", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const text = host.textContent ?? "";
expect(text).toContain("clan_modal.stats_total");
expect(text).toContain("clan_modal.stats_ffa");
expect(text).toContain("clan_modal.stats_team");
expect(text).toContain("clan_modal.stats_hvn");
expect(text).toContain("clan_modal.stats_ranked");
// 1v1 lives under the ranked dropdown — hidden until expanded.
expect(text).not.toContain("clan_modal.stats_1v1");
});
it("reveals team sub-buckets when the team row is expanded", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const teamButton = findExpandableButton(host, "clan_modal.stats_team");
expect(teamButton).toBeDefined();
expect(teamButton!.disabled).toBe(false);
teamButton!.click();
await new Promise((r) => setTimeout(r, 0));
const text = host.textContent ?? "";
expect(text).toContain("clan_modal.stats_duos");
expect(text).toContain("clan_modal.stats_trios");
// Buckets with no games are hidden.
expect(text).not.toContain("clan_modal.stats_quads");
});
it("does not render an expandable button for ranked when no breakdown has games", async () => {
const host = await renderTo(renderMemberStats(stats));
await expandTotal(host);
const expandableLabels = Array.from(
host.querySelectorAll<HTMLButtonElement>("button[aria-expanded]"),
).map((b) => b.textContent ?? "");
expect(
expandableLabels.some((t) => t.includes("clan_modal.stats_ranked")),
).toBe(false);
// Sanity: team is still expandable since it has sub-bucket games.
expect(
expandableLabels.some((t) => t.includes("clan_modal.stats_team")),
).toBe(true);
});
});
describe("filterRequestsBySearch", () => {
it("returns all requests when search is empty", () => {
expect(filterRequestsBySearch(requests, "")).toEqual(requests);
});
it("matches by publicId (case-insensitive)", () => {
const result = filterRequestsBySearch(requests, "dave");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Dave111");
});
it("matches partial publicId", () => {
const result = filterRequestsBySearch(requests, "222");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Eve222");
});
it("returns empty array when nothing matches", () => {
expect(filterRequestsBySearch(requests, "zzz")).toEqual([]);
});
});