mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
Clan Game History (#3988)
## Description: Adds <img width="1046" height="901" alt="image" src="https://github.com/user-attachments/assets/930b0d27-4707-4836-b068-620346e7e3a7" /> continuation of infra https://github.com/openfrontio/infra/pull/345 ## 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
This commit is contained in:
@@ -10,11 +10,11 @@ vi.mock("../../../src/client/Auth", () => ({
|
||||
|
||||
import {
|
||||
fetchClanDetail,
|
||||
fetchClanGames,
|
||||
fetchClanLeaderboard,
|
||||
fetchClanMembers,
|
||||
fetchClanRequests,
|
||||
fetchClans,
|
||||
fetchClanStats,
|
||||
} from "../../../src/client/ClanApi";
|
||||
|
||||
const okJson = (data: unknown, status = 200) => ({
|
||||
@@ -72,61 +72,6 @@ describe("fetchClanLeaderboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchClanStats", () => {
|
||||
const clanStats = {
|
||||
clanTag: "TEST",
|
||||
games: 20,
|
||||
wins: 15,
|
||||
losses: 5,
|
||||
stats: {
|
||||
total: { wins: 15, losses: 5 },
|
||||
ffa: { wins: 7, losses: 3 },
|
||||
team: { wins: 4, losses: 1 },
|
||||
hvn: { wins: 1, losses: 0 },
|
||||
duos: { wins: 2, losses: 0 },
|
||||
trios: { wins: 1, losses: 1 },
|
||||
quads: { wins: 1, losses: 0 },
|
||||
"2": { wins: 2, losses: 0 },
|
||||
"3": { wins: 1, losses: 1 },
|
||||
"4": { wins: 1, losses: 0 },
|
||||
"5": { wins: 0, losses: 0 },
|
||||
"6": { wins: 0, losses: 0 },
|
||||
"7": { wins: 0, losses: 0 },
|
||||
ranked: { wins: 3, losses: 1 },
|
||||
"1v1": { wins: 3, losses: 1 },
|
||||
},
|
||||
teamTypeWL: { ffa: { wl: [15, 5] } },
|
||||
teamCountWL: { "2": { wl: [10, 3] } },
|
||||
};
|
||||
|
||||
it("returns parsed data from json.clan on success", async () => {
|
||||
mockFetch(() => okJson({ clan: clanStats }));
|
||||
const result = await fetchClanStats("TEST");
|
||||
expect(result).toEqual(clanStats);
|
||||
});
|
||||
|
||||
it("returns false when json.clan is missing", async () => {
|
||||
mockFetch(() => okJson({}));
|
||||
const result = await fetchClanStats("TEST");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on non-ok response", async () => {
|
||||
mockFetch(() => failRes(404));
|
||||
const result = await fetchClanStats("TEST");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on network error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new Error("offline"))),
|
||||
);
|
||||
const result = await fetchClanStats("TEST");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchClanDetail", () => {
|
||||
const clanInfo = {
|
||||
name: "Test Clan",
|
||||
@@ -389,3 +334,126 @@ describe("fetchClanRequests", () => {
|
||||
expect(headers.Authorization).toBe("Bearer test-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchClanGames", () => {
|
||||
const gamesResponse = {
|
||||
results: [
|
||||
{
|
||||
gameId: "g1",
|
||||
start: "2024-06-01T00:00:00.000Z",
|
||||
durationSeconds: 1234,
|
||||
map: "World",
|
||||
mode: "Team",
|
||||
playerTeams: "Duos",
|
||||
result: "victory",
|
||||
totalPlayers: 8,
|
||||
clanPlayers: [{ publicId: "p1", username: "alice", won: true }],
|
||||
},
|
||||
],
|
||||
nextCursor: "opaque-cursor-abc123",
|
||||
};
|
||||
|
||||
it("returns parsed data on success", async () => {
|
||||
mockFetch(() => okJson(gamesResponse));
|
||||
const result = await fetchClanGames("TEST");
|
||||
expect(result).toEqual(gamesResponse);
|
||||
});
|
||||
|
||||
it("accepts a null nextCursor (no more pages)", async () => {
|
||||
mockFetch(() => okJson({ ...gamesResponse, nextCursor: null }));
|
||||
const result = await fetchClanGames("TEST");
|
||||
expect("error" in result).toBe(false);
|
||||
if (!("error" in result)) expect(result.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("omits filter and cursor query params when not provided", async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
(_input: string | URL | Request, _init?: RequestInit) =>
|
||||
Promise.resolve(okJson(gamesResponse)),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await fetchClanGames("TEST");
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.has("filter")).toBe(false);
|
||||
expect(url.searchParams.has("cursor")).toBe(false);
|
||||
expect(url.pathname).toBe("/clans/TEST/games");
|
||||
});
|
||||
|
||||
it("passes filter and cursor as query params", async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
(_input: string | URL | Request, _init?: RequestInit) =>
|
||||
Promise.resolve(okJson(gamesResponse)),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await fetchClanGames("TEST", {
|
||||
filter: "team",
|
||||
cursor: "opaque-cursor-abc123",
|
||||
});
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("filter")).toBe("team");
|
||||
expect(url.searchParams.get("cursor")).toBe("opaque-cursor-abc123");
|
||||
});
|
||||
|
||||
it("URL-encodes the clan tag", async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
(_input: string | URL | Request, _init?: RequestInit) =>
|
||||
Promise.resolve(okJson(gamesResponse)),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await fetchClanGames("A/B");
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
|
||||
// encodeURIComponent('/') === '%2F'
|
||||
expect(calledUrl).toContain("/clans/A%2FB/games");
|
||||
});
|
||||
|
||||
it("returns { error: 'forbidden' } on 403", async () => {
|
||||
mockFetch(() => failRes(403));
|
||||
const result = await fetchClanGames("TEST");
|
||||
expect(result).toEqual({ error: "forbidden" });
|
||||
});
|
||||
|
||||
it("returns { error: 'failed' } on other non-ok responses", async () => {
|
||||
mockFetch(() => failRes(500));
|
||||
const result = await fetchClanGames("TEST");
|
||||
expect(result).toEqual({ error: "failed" });
|
||||
});
|
||||
|
||||
it("returns { error: 'failed' } when Zod validation fails", async () => {
|
||||
mockFetch(() => okJson({ results: "not-an-array", nextCursor: 42 }));
|
||||
const result = await fetchClanGames("TEST");
|
||||
expect(result).toEqual({ error: "failed" });
|
||||
});
|
||||
|
||||
it("returns { error: 'failed' } on network error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new Error("offline"))),
|
||||
);
|
||||
const result = await fetchClanGames("TEST");
|
||||
expect(result).toEqual({ error: "failed" });
|
||||
});
|
||||
|
||||
it("sends Authorization header", async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
(_input: string | URL | Request, _init?: RequestInit) =>
|
||||
Promise.resolve(okJson(gamesResponse)),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await fetchClanGames("TEST");
|
||||
|
||||
const headers = fetchSpy.mock.calls[0]![1]?.headers as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(headers.Authorization).toBe("Bearer test-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ClanBanSchema,
|
||||
ClanGameFilterSchema,
|
||||
ClanGamePlayerSchema,
|
||||
ClanGameResultSchema,
|
||||
ClanGameSchema,
|
||||
ClanGamesResponseSchema,
|
||||
ClanInfoSchema,
|
||||
ClanJoinRequestSchema,
|
||||
ClanMemberSchema,
|
||||
ClanStatsSchema,
|
||||
} from "../../../src/core/ClanApiSchemas";
|
||||
|
||||
describe("ClanInfoSchema", () => {
|
||||
@@ -153,62 +157,6 @@ describe("ClanJoinRequestSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClanStatsSchema", () => {
|
||||
const validStats = {
|
||||
clanTag: "ABcd1",
|
||||
games: 10,
|
||||
wins: 7,
|
||||
losses: 3,
|
||||
stats: {
|
||||
total: { wins: 7, losses: 3 },
|
||||
ffa: { wins: 3, losses: 2 },
|
||||
team: { wins: 2, losses: 1 },
|
||||
hvn: { wins: 1, losses: 0 },
|
||||
duos: { wins: 1, losses: 0 },
|
||||
trios: { wins: 0, losses: 1 },
|
||||
quads: { wins: 1, losses: 0 },
|
||||
"2": { wins: 1, losses: 0 },
|
||||
"3": { wins: 0, losses: 1 },
|
||||
"4": { wins: 1, losses: 0 },
|
||||
"5": { wins: 0, losses: 0 },
|
||||
"6": { wins: 0, losses: 0 },
|
||||
"7": { wins: 0, losses: 0 },
|
||||
ranked: { wins: 1, losses: 0 },
|
||||
"1v1": { wins: 1, losses: 0 },
|
||||
},
|
||||
teamTypeWL: { ffa: { wl: [7, 3] } },
|
||||
teamCountWL: { "2": { wl: [4, 1] } },
|
||||
};
|
||||
|
||||
it("accepts a valid clan tag (2-5 alphanumeric chars)", () => {
|
||||
for (const tag of ["AB", "abc12", "XYZAB"]) {
|
||||
const result = ClanStatsSchema.safeParse({ ...validStats, clanTag: tag });
|
||||
expect(result.success, `tag "${tag}" should be valid`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects tags that are too short", () => {
|
||||
const result = ClanStatsSchema.safeParse({ ...validStats, clanTag: "A" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects tags that are too long", () => {
|
||||
const result = ClanStatsSchema.safeParse({
|
||||
...validStats,
|
||||
clanTag: "TOOLNG",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects tags with non-alphanumeric characters", () => {
|
||||
const result = ClanStatsSchema.safeParse({
|
||||
...validStats,
|
||||
clanTag: "AB-CD",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClanBanSchema", () => {
|
||||
const validBan = {
|
||||
publicId: "player-1",
|
||||
@@ -252,3 +200,160 @@ describe("ClanBanSchema", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClanGameResultSchema", () => {
|
||||
it.each(["victory", "defeat", "incomplete"])("accepts %s", (value) => {
|
||||
expect(ClanGameResultSchema.safeParse(value).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown result value", () => {
|
||||
expect(ClanGameResultSchema.safeParse("win").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClanGameFilterSchema", () => {
|
||||
it.each(["ffa", "team", "hvn", "ranked"])("accepts %s", (value) => {
|
||||
expect(ClanGameFilterSchema.safeParse(value).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown filter value", () => {
|
||||
expect(ClanGameFilterSchema.safeParse("all").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClanGamePlayerSchema", () => {
|
||||
const validPlayer = {
|
||||
publicId: "p1",
|
||||
username: "alice",
|
||||
won: true,
|
||||
};
|
||||
|
||||
it("accepts a valid player", () => {
|
||||
expect(ClanGamePlayerSchema.safeParse(validPlayer).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when won is not a boolean", () => {
|
||||
expect(
|
||||
ClanGamePlayerSchema.safeParse({ ...validPlayer, won: "true" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when required fields are missing", () => {
|
||||
expect(ClanGamePlayerSchema.safeParse({ publicId: "p1" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClanGameSchema", () => {
|
||||
const validGame = {
|
||||
gameId: "g1",
|
||||
start: "2024-06-01T00:00:00.000Z",
|
||||
durationSeconds: 1234,
|
||||
map: "World",
|
||||
mode: "Team",
|
||||
playerTeams: "Duos",
|
||||
rankedType: "1v1",
|
||||
result: "victory" as const,
|
||||
totalPlayers: 8,
|
||||
clanPlayers: [{ publicId: "p1", username: "alice", won: true }],
|
||||
};
|
||||
|
||||
it("accepts a fully-populated game", () => {
|
||||
expect(ClanGameSchema.safeParse(validGame).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts playerTeams: null (FFA / non-team games)", () => {
|
||||
const result = ClanGameSchema.safeParse({
|
||||
...validGame,
|
||||
playerTeams: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts totalPlayers: null (historical rows)", () => {
|
||||
const result = ClanGameSchema.safeParse({
|
||||
...validGame,
|
||||
totalPlayers: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a row with map/mode/rankedType/result omitted", () => {
|
||||
const minimal = {
|
||||
gameId: validGame.gameId,
|
||||
start: validGame.start,
|
||||
durationSeconds: validGame.durationSeconds,
|
||||
playerTeams: validGame.playerTeams,
|
||||
totalPlayers: validGame.totalPlayers,
|
||||
clanPlayers: validGame.clanPlayers,
|
||||
};
|
||||
expect(ClanGameSchema.safeParse(minimal).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-ISO start", () => {
|
||||
expect(
|
||||
ClanGameSchema.safeParse({ ...validGame, start: "June 1 2024" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a negative durationSeconds", () => {
|
||||
expect(
|
||||
ClanGameSchema.safeParse({ ...validGame, durationSeconds: -1 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a negative totalPlayers", () => {
|
||||
expect(
|
||||
ClanGameSchema.safeParse({ ...validGame, totalPlayers: -1 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unknown result value", () => {
|
||||
expect(
|
||||
ClanGameSchema.safeParse({ ...validGame, result: "win" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClanGamesResponseSchema", () => {
|
||||
const validGame = {
|
||||
gameId: "g1",
|
||||
start: "2024-06-01T00:00:00.000Z",
|
||||
durationSeconds: 1234,
|
||||
clanPlayers: [{ publicId: "p1", username: "alice", won: true }],
|
||||
};
|
||||
|
||||
it("accepts a non-empty page with an opaque cursor", () => {
|
||||
// The cursor is contractually opaque (see ClanGamesResponseSchema
|
||||
// comment) — use a non-date token to make that explicit.
|
||||
const result = ClanGamesResponseSchema.safeParse({
|
||||
results: [validGame],
|
||||
nextCursor: "opaque-cursor-abc123",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success)
|
||||
expect(result.data.nextCursor).toBe("opaque-cursor-abc123");
|
||||
});
|
||||
|
||||
it("accepts an empty page with a null cursor", () => {
|
||||
const result = ClanGamesResponseSchema.safeParse({
|
||||
results: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when nextCursor is missing (must be string or null)", () => {
|
||||
const result = ClanGamesResponseSchema.safeParse({ results: [] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when results is not an array", () => {
|
||||
const result = ClanGamesResponseSchema.safeParse({
|
||||
results: "not-an-array",
|
||||
nextCursor: null,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { flushAsync } from "./ClanModalTestUtils";
|
||||
|
||||
// ─── Mocks (defined before imports so vi.mock hoisting applies) ─────────────
|
||||
|
||||
vi.mock("../../../src/client/Utils", () => ({
|
||||
// Echo the key so we can assert on translation slugs.
|
||||
translateText: vi.fn((key: string) => key),
|
||||
showToast: vi.fn(),
|
||||
// Cheap stub so we don't pull in the real i18n module.
|
||||
renderDuration: vi.fn((s: number) => `${s}s`),
|
||||
getMapName: vi.fn((m: string | undefined) => m ?? null),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/client/Auth", () => ({
|
||||
getAuthHeader: vi.fn(async () => "Bearer test-token"),
|
||||
userAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/client/ClanApi", () => ({
|
||||
fetchClanGames: vi.fn(async () => ({ results: [], nextCursor: null })),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/client/TerrainMapFileLoader", () => ({
|
||||
terrainMapFileLoader: {
|
||||
getMapData: vi.fn(() => ({ webpPath: "/maps/test.webp" })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/client/ClientEnv", () => ({
|
||||
ClientEnv: {
|
||||
workerPath: vi.fn(() => "w0"),
|
||||
},
|
||||
}));
|
||||
|
||||
// ClanShared re-exports from BaseModal; stub it directly so we don't pull
|
||||
// BaseModal's dependency graph into this unit test.
|
||||
vi.mock("../../../src/client/components/clan/ClanShared", async () => {
|
||||
const { html } = await import("lit");
|
||||
return {
|
||||
renderLoadingSpinner: vi.fn(() => html`<div data-testid="spinner"></div>`),
|
||||
showToast: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// CopyButton is a custom element; stub it so its dependency graph (Auth,
|
||||
// API, etc.) doesn't get pulled in transitively.
|
||||
vi.mock("../../../src/client/components/CopyButton", () => ({}));
|
||||
|
||||
// jsdom doesn't ship IntersectionObserver — provide the minimum surface
|
||||
// the component touches (observe / disconnect). Tests below trigger the
|
||||
// callback manually when needed.
|
||||
class FakeIntersectionObserver {
|
||||
callback: IntersectionObserverCallback;
|
||||
static last: FakeIntersectionObserver | null = null;
|
||||
observed: Element[] = [];
|
||||
constructor(cb: IntersectionObserverCallback) {
|
||||
this.callback = cb;
|
||||
FakeIntersectionObserver.last = this;
|
||||
}
|
||||
observe(el: Element) {
|
||||
this.observed.push(el);
|
||||
}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
root = null;
|
||||
rootMargin = "";
|
||||
thresholds = [];
|
||||
}
|
||||
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
|
||||
|
||||
// ─── Imports under test ──────────────────────────────────────────────────────
|
||||
|
||||
import type { ClanGame, ClanGamesResponse } from "../../../src/client/ClanApi";
|
||||
import { fetchClanGames } from "../../../src/client/ClanApi";
|
||||
import {
|
||||
ClanGameHistoryView,
|
||||
type ClanGameHistoryCache,
|
||||
} from "../../../src/client/components/clan/ClanGameHistoryView";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeGame(overrides: Partial<ClanGame> = {}): ClanGame {
|
||||
return {
|
||||
gameId: "g1",
|
||||
start: "2024-06-01T12:00:00.000Z",
|
||||
durationSeconds: 600,
|
||||
map: "World",
|
||||
mode: "Team",
|
||||
playerTeams: "Duos",
|
||||
rankedType: undefined,
|
||||
result: "victory",
|
||||
totalPlayers: 8,
|
||||
clanPlayers: [{ publicId: "p1", username: "alice", won: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function mountView(props: Partial<ClanGameHistoryView> = {}) {
|
||||
if (!customElements.get("clan-game-history-view")) {
|
||||
customElements.define("clan-game-history-view", ClanGameHistoryView);
|
||||
}
|
||||
const el = document.createElement(
|
||||
"clan-game-history-view",
|
||||
) as ClanGameHistoryView;
|
||||
// Apply props before mount so connectedCallback sees them.
|
||||
Object.assign(el, { clanTag: "TST", ...props });
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
const mockFetch = (impl: () => Promise<unknown>) => {
|
||||
(fetchClanGames as ReturnType<typeof vi.fn>).mockImplementationOnce(impl);
|
||||
};
|
||||
|
||||
const okPage = (
|
||||
games: ClanGame[],
|
||||
nextCursor: string | null = null,
|
||||
): ClanGamesResponse => ({ results: games, nextCursor });
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ClanGameHistoryView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(fetchClanGames as ReturnType<typeof vi.fn>).mockResolvedValue(okPage([]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll("clan-game-history-view").forEach((el) => {
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial load + caching", () => {
|
||||
it("fetches games on mount when no cache is provided", async () => {
|
||||
mockFetch(() => Promise.resolve(okPage([makeGame()])));
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
expect(fetchClanGames).toHaveBeenCalledWith("TST", {
|
||||
filter: undefined,
|
||||
cursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the fetch when a matching cache is supplied", async () => {
|
||||
const cache: ClanGameHistoryCache = {
|
||||
tag: "TST",
|
||||
filter: "ffa",
|
||||
games: [makeGame({ gameId: "cached" })],
|
||||
nextCursor: "cursor-1",
|
||||
};
|
||||
const el = await mountView({ cachedState: cache });
|
||||
await flushAsync(el);
|
||||
|
||||
expect(fetchClanGames).not.toHaveBeenCalled();
|
||||
// Sentinel must be present so the observer can pick up where the
|
||||
// previous session left off — non-null cursor means more pages.
|
||||
expect(el.querySelector("[data-scroll-sentinel]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("ignores the cache when the tag does not match and fetches instead", async () => {
|
||||
mockFetch(() => Promise.resolve(okPage([makeGame()])));
|
||||
const cache: ClanGameHistoryCache = {
|
||||
tag: "OTHER",
|
||||
filter: "all",
|
||||
games: [makeGame()],
|
||||
nextCursor: null,
|
||||
};
|
||||
const el = await mountView({ cachedState: cache });
|
||||
await flushAsync(el);
|
||||
|
||||
expect(fetchClanGames).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter switching", () => {
|
||||
it("hard-resets games and refetches with the chosen filter", async () => {
|
||||
mockFetch(() =>
|
||||
Promise.resolve(okPage([makeGame({ gameId: "first" })], "cursor-1")),
|
||||
);
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
mockFetch(() => Promise.resolve(okPage([makeGame({ gameId: "ffa" })])));
|
||||
// Click the FFA filter tab. translateText echoes the key.
|
||||
const ffaTab = Array.from(el.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("clan_modal.history_type_ffa"),
|
||||
)!;
|
||||
ffaTab.click();
|
||||
await flushAsync(el);
|
||||
|
||||
expect(fetchClanGames).toHaveBeenLastCalledWith("TST", {
|
||||
filter: "ffa",
|
||||
cursor: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor pagination (append)", () => {
|
||||
it("sends the saved cursor on the next page request and concatenates results", async () => {
|
||||
mockFetch(() =>
|
||||
Promise.resolve(okPage([makeGame({ gameId: "p1" })], "next-token")),
|
||||
);
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
mockFetch(() =>
|
||||
Promise.resolve(okPage([makeGame({ gameId: "p2" })], null)),
|
||||
);
|
||||
// Drive the observer callback manually — sentinel becomes intersecting.
|
||||
const observer = FakeIntersectionObserver.last;
|
||||
expect(observer).not.toBeNull();
|
||||
observer!.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: observer!.observed[0],
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
observer as unknown as IntersectionObserver,
|
||||
);
|
||||
await flushAsync(el);
|
||||
|
||||
expect(fetchClanGames).toHaveBeenLastCalledWith("TST", {
|
||||
filter: undefined,
|
||||
cursor: "next-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves prior games and surfaces a retry footer when append fails", async () => {
|
||||
mockFetch(() =>
|
||||
Promise.resolve(okPage([makeGame({ gameId: "p1" })], "next-token")),
|
||||
);
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
mockFetch(() => Promise.resolve({ error: "failed" }));
|
||||
const observer = FakeIntersectionObserver.last!;
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: observer.observed[0],
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
observer as unknown as IntersectionObserver,
|
||||
);
|
||||
await flushAsync(el);
|
||||
|
||||
// Retry footer is rendered (the key is `history_load_more_failed`)
|
||||
expect(el.textContent).toContain("clan_modal.history_load_more_failed");
|
||||
// The first-page game card is still in the DOM — checking for a
|
||||
// per-game render artefact rather than gameId since CopyButton is
|
||||
// stubbed out and does not surface its `displayText`.
|
||||
expect(el.textContent).toContain("clan_modal.history_game_type");
|
||||
// And the empty state must NOT have replaced the list.
|
||||
expect(el.textContent).not.toContain("clan_modal.history_empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error / forbidden / empty states", () => {
|
||||
it("renders the members-only message on 403", async () => {
|
||||
mockFetch(() => Promise.resolve({ error: "forbidden" }));
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
expect(el.textContent).toContain("clan_modal.history_members_only");
|
||||
});
|
||||
|
||||
it("renders the unavailable state with a try-again button on non-403 errors", async () => {
|
||||
mockFetch(() => Promise.resolve({ error: "failed" }));
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
expect(el.textContent).toContain("clan_modal.history_unavailable");
|
||||
expect(el.textContent).toContain("leaderboard_modal.try_again");
|
||||
});
|
||||
|
||||
it("renders the empty state when results is []", async () => {
|
||||
mockFetch(() => Promise.resolve(okPage([])));
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
expect(el.textContent).toContain("clan_modal.history_empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderResultBadge", () => {
|
||||
// Drive a single render and read the badge text out of the DOM so
|
||||
// we test the actual code path (including isFfa).
|
||||
async function badgeTextFor(game: ClanGame): Promise<string> {
|
||||
mockFetch(() => Promise.resolve(okPage([game])));
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
return el.textContent ?? "";
|
||||
}
|
||||
|
||||
it("shows partial-win badge for FFA when only some clan members won", async () => {
|
||||
const text = await badgeTextFor(
|
||||
makeGame({
|
||||
mode: "Free For All",
|
||||
playerTeams: null,
|
||||
result: "victory",
|
||||
clanPlayers: [
|
||||
{ publicId: "a", username: "a", won: true },
|
||||
{ publicId: "b", username: "b", won: false },
|
||||
{ publicId: "c", username: "c", won: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_result_partial");
|
||||
});
|
||||
|
||||
it("shows victory for FFA when all clan members won", async () => {
|
||||
const text = await badgeTextFor(
|
||||
makeGame({
|
||||
mode: "Free For All",
|
||||
playerTeams: null,
|
||||
result: "victory",
|
||||
clanPlayers: [
|
||||
{ publicId: "a", username: "a", won: true },
|
||||
{ publicId: "b", username: "b", won: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_result_victory");
|
||||
expect(text).not.toContain("clan_modal.history_result_partial");
|
||||
});
|
||||
|
||||
it("shows defeat for FFA when no clan members won", async () => {
|
||||
const text = await badgeTextFor(
|
||||
makeGame({
|
||||
mode: "Free For All",
|
||||
playerTeams: null,
|
||||
result: "defeat",
|
||||
clanPlayers: [
|
||||
{ publicId: "a", username: "a", won: false },
|
||||
{ publicId: "b", username: "b", won: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_result_defeat");
|
||||
});
|
||||
|
||||
it("does not partial-win team games (clan plays as a unit)", async () => {
|
||||
const text = await badgeTextFor(
|
||||
makeGame({
|
||||
mode: "Team",
|
||||
playerTeams: "Duos",
|
||||
result: "victory",
|
||||
clanPlayers: [
|
||||
{ publicId: "a", username: "a", won: true },
|
||||
{ publicId: "b", username: "b", won: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
// Team games surface plain victory/defeat — never partial.
|
||||
expect(text).toContain("clan_modal.history_result_victory");
|
||||
expect(text).not.toContain("clan_modal.history_result_partial");
|
||||
});
|
||||
|
||||
it("omits the badge when result is absent", async () => {
|
||||
const text = await badgeTextFor(
|
||||
makeGame({
|
||||
result: undefined,
|
||||
}),
|
||||
);
|
||||
expect(text).not.toContain("clan_modal.history_result_victory");
|
||||
expect(text).not.toContain("clan_modal.history_result_defeat");
|
||||
expect(text).not.toContain("clan_modal.history_result_partial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGameType", () => {
|
||||
async function typeLabelFor(game: ClanGame): Promise<string> {
|
||||
mockFetch(() => Promise.resolve(okPage([game])));
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
return el.textContent ?? "";
|
||||
}
|
||||
|
||||
it("labels ranked games with the rankedType variable", async () => {
|
||||
const text = await typeLabelFor(
|
||||
makeGame({ rankedType: "1v1", mode: undefined, playerTeams: null }),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_type_ranked");
|
||||
});
|
||||
|
||||
it("labels FFA via the GameMode.FFA enum literal", async () => {
|
||||
const text = await typeLabelFor(
|
||||
makeGame({
|
||||
mode: "Free For All",
|
||||
playerTeams: null,
|
||||
rankedType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_type_ffa");
|
||||
});
|
||||
|
||||
it("labels FFA when mode is absent and playerTeams is null (no team grouping)", async () => {
|
||||
const text = await typeLabelFor(
|
||||
makeGame({
|
||||
mode: undefined,
|
||||
playerTeams: null,
|
||||
rankedType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_type_ffa");
|
||||
});
|
||||
|
||||
it("labels Humans Vs Nations", async () => {
|
||||
const text = await typeLabelFor(
|
||||
makeGame({
|
||||
mode: "Team",
|
||||
playerTeams: "Humans Vs Nations",
|
||||
rankedType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_type_hvn");
|
||||
});
|
||||
|
||||
it("labels Duos / Trios / Quads via the lowercased key", async () => {
|
||||
for (const team of ["Duos", "Trios", "Quads"] as const) {
|
||||
const text = await typeLabelFor(
|
||||
makeGame({
|
||||
mode: "Team",
|
||||
playerTeams: team,
|
||||
rankedType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
text,
|
||||
`team "${team}" should map to its lowercase label`,
|
||||
).toContain(`clan_modal.history_type_${team.toLowerCase()}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("labels numeric playerTeams as N teams", async () => {
|
||||
const text = await typeLabelFor(
|
||||
makeGame({
|
||||
mode: "Team",
|
||||
playerTeams: "7",
|
||||
rankedType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_type_n_teams");
|
||||
});
|
||||
|
||||
it("falls back to generic Team when playerTeams is an unknown string", async () => {
|
||||
const text = await typeLabelFor(
|
||||
makeGame({
|
||||
mode: "Team",
|
||||
playerTeams: "WeirdMode",
|
||||
rankedType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_type_team");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPlayersField", () => {
|
||||
async function bodyTextFor(game: ClanGame): Promise<string> {
|
||||
mockFetch(() => Promise.resolve(okPage([game])));
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
return el.textContent ?? "";
|
||||
}
|
||||
|
||||
it("shows total only for ranked single-clan-slot games", async () => {
|
||||
const text = await bodyTextFor(
|
||||
makeGame({ rankedType: "1v1", totalPlayers: 2 }),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_players");
|
||||
expect(text).not.toContain("clan_modal.history_clan_players_value");
|
||||
});
|
||||
|
||||
it("shows clan/total breakdown for non-ranked games", async () => {
|
||||
const text = await bodyTextFor(
|
||||
makeGame({ rankedType: undefined, totalPlayers: 50 }),
|
||||
);
|
||||
expect(text).toContain("clan_modal.history_clan_players_value");
|
||||
});
|
||||
|
||||
it('renders "—" when totalPlayers is null and game is ranked', async () => {
|
||||
const text = await bodyTextFor(
|
||||
makeGame({ rankedType: "1v1", totalPlayers: null }),
|
||||
);
|
||||
expect(text).toContain("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("day grouping headers", () => {
|
||||
it("groups consecutive same-day games under one header (Today)", async () => {
|
||||
const now = new Date();
|
||||
const today = (h: number) =>
|
||||
new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
h,
|
||||
).toISOString();
|
||||
mockFetch(() =>
|
||||
Promise.resolve(
|
||||
okPage([
|
||||
makeGame({ gameId: "g1", start: today(10) }),
|
||||
makeGame({ gameId: "g2", start: today(11) }),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
const headers = el.querySelectorAll("h3");
|
||||
expect(headers).toHaveLength(1);
|
||||
expect(headers[0]?.textContent).toContain("clan_modal.history_today");
|
||||
});
|
||||
|
||||
it('labels yesterday under the "yesterday" key', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
mockFetch(() =>
|
||||
Promise.resolve(okPage([makeGame({ start: yesterday.toISOString() })])),
|
||||
);
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
expect(el.textContent).toContain("clan_modal.history_yesterday");
|
||||
});
|
||||
});
|
||||
|
||||
describe("watchReplay", () => {
|
||||
it("pushes a /game/:id URL and emits close-clan-modal + join-changed", async () => {
|
||||
mockFetch(() =>
|
||||
Promise.resolve(okPage([makeGame({ gameId: "abc/xyz" })])),
|
||||
);
|
||||
const el = await mountView();
|
||||
await flushAsync(el);
|
||||
|
||||
const pushSpy = vi.spyOn(history, "pushState");
|
||||
const winEvents: string[] = [];
|
||||
const winHandler = () => winEvents.push("join-changed");
|
||||
window.addEventListener("join-changed", winHandler);
|
||||
const elEvents: string[] = [];
|
||||
el.addEventListener("close-clan-modal", () =>
|
||||
elEvents.push("close-clan-modal"),
|
||||
);
|
||||
|
||||
const watchBtn = Array.from(el.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("clan_modal.history_watch_replay"),
|
||||
)!;
|
||||
watchBtn.click();
|
||||
await flushAsync(el);
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledOnce();
|
||||
const url = pushSpy.mock.calls[0][2] as string;
|
||||
// gameId is URL-encoded into the path
|
||||
expect(url).toContain(encodeURIComponent("abc/xyz"));
|
||||
expect(winEvents).toContain("join-changed");
|
||||
expect(elEvents).toContain("close-clan-modal");
|
||||
|
||||
window.removeEventListener("join-changed", winHandler);
|
||||
pushSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("history-updated event", () => {
|
||||
it("emits with the freshly-loaded games and cursor so the parent can cache", async () => {
|
||||
mockFetch(() =>
|
||||
Promise.resolve(okPage([makeGame({ gameId: "g1" })], "next-1")),
|
||||
);
|
||||
const el = await mountView();
|
||||
const events: ClanGameHistoryCache[] = [];
|
||||
el.addEventListener("history-updated", (e) =>
|
||||
events.push((e as CustomEvent<ClanGameHistoryCache>).detail),
|
||||
);
|
||||
// The first load was already issued in connectedCallback — wait for it.
|
||||
await flushAsync(el);
|
||||
|
||||
// Re-trigger by switching filter to capture the event.
|
||||
mockFetch(() =>
|
||||
Promise.resolve(okPage([makeGame({ gameId: "g2" })], null)),
|
||||
);
|
||||
const ffaTab = Array.from(el.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("clan_modal.history_type_ffa"),
|
||||
)!;
|
||||
ffaTab.click();
|
||||
await flushAsync(el);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const last = events[events.length - 1];
|
||||
expect(last.tag).toBe("TST");
|
||||
expect(last.filter).toBe("ffa");
|
||||
expect(last.games).toHaveLength(1);
|
||||
expect(last.nextCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -589,12 +589,10 @@ describe("ClanModal — handlers", () => {
|
||||
|
||||
describe("handleJoin", () => {
|
||||
beforeEach(async () => {
|
||||
const { fetchClanDetail, fetchClanStats } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
const { fetchClanDetail } = await import("../../../src/client/ClanApi");
|
||||
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
makeClan({ isOpen: true, memberCount: 5 }),
|
||||
);
|
||||
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
|
||||
|
||||
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
|
||||
setState(modal, "myClanRoles" as keyof ClanModal, new Map() as never);
|
||||
@@ -652,7 +650,7 @@ describe("ClanModal — handlers", () => {
|
||||
|
||||
describe("handleLeave", () => {
|
||||
beforeEach(async () => {
|
||||
const { fetchClanDetail, fetchClanMembers, fetchClanStats } =
|
||||
const { fetchClanDetail, fetchClanMembers } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
makeClan(),
|
||||
@@ -670,7 +668,6 @@ describe("ClanModal — handlers", () => {
|
||||
limit: 10,
|
||||
pendingRequests: 0,
|
||||
});
|
||||
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
|
||||
|
||||
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
|
||||
setState(
|
||||
|
||||
@@ -237,19 +237,10 @@ describe("ClanModal — rendering", () => {
|
||||
});
|
||||
|
||||
it("shows 0 in the stats row of the detail view when memberCount is undefined", async () => {
|
||||
const { fetchClanDetail, fetchClanStats } =
|
||||
await import("../../../src/client/ClanApi");
|
||||
const { fetchClanDetail } = await import("../../../src/client/ClanApi");
|
||||
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
makeClan({ memberCount: undefined }),
|
||||
);
|
||||
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
clanTag: "TST",
|
||||
games: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
teamTypeWL: {},
|
||||
teamCountWL: {},
|
||||
});
|
||||
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
|
||||
setState(modal, "view" as keyof ClanModal, "detail" as never);
|
||||
await waitForSubComponent(modal, "clan-detail-view");
|
||||
|
||||
@@ -31,31 +31,6 @@ export function clanApiMockFactory() {
|
||||
limit: 10,
|
||||
pendingRequests: 0,
|
||||
})),
|
||||
fetchClanStats: vi.fn(async () => ({
|
||||
clanTag: "TST",
|
||||
games: 10,
|
||||
wins: 7,
|
||||
losses: 3,
|
||||
stats: {
|
||||
total: { wins: 7, losses: 3 },
|
||||
ffa: { wins: 3, losses: 2 },
|
||||
team: { wins: 2, losses: 1 },
|
||||
hvn: { wins: 1, losses: 0 },
|
||||
duos: { wins: 1, losses: 0 },
|
||||
trios: { wins: 0, losses: 1 },
|
||||
quads: { wins: 1, losses: 0 },
|
||||
"2": { wins: 1, losses: 0 },
|
||||
"3": { wins: 0, losses: 1 },
|
||||
"4": { wins: 1, losses: 0 },
|
||||
"5": { wins: 0, losses: 0 },
|
||||
"6": { wins: 0, losses: 0 },
|
||||
"7": { wins: 0, losses: 0 },
|
||||
ranked: { wins: 1, losses: 0 },
|
||||
"1v1": { wins: 1, losses: 0 },
|
||||
},
|
||||
teamTypeWL: {},
|
||||
teamCountWL: {},
|
||||
})),
|
||||
fetchClans: vi.fn(async () => ({
|
||||
results: [],
|
||||
total: 0,
|
||||
@@ -88,6 +63,10 @@ export function clanApiMockFactory() {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
})),
|
||||
fetchClanGames: vi.fn(async () => ({
|
||||
results: [],
|
||||
nextCursor: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user