Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions frontend/__tests__/eloStatsUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { EXTENDED_MOCK_ELO_DATA } from "@/constants/mockEloData";
import {
computeEloStats,
computeStreak,
computeVolatility,
filterByTimeRange,
getRankTier,
} from "@/lib/eloStatsUtils";
import type { EloDataPoint } from "@/components/profile/EloRatingChart";

const SAMPLE_DATA: EloDataPoint[] = [
{ date: "2026-03-01", elo: 1215, opponent: "GA", change: 15 },
{ date: "2026-03-02", elo: 1205, opponent: "GB", change: -10 },
{ date: "2026-03-03", elo: 1205, opponent: "GC", change: 0 },
{ date: "2026-03-04", elo: 1223, opponent: "GD", change: 18 },
{ date: "2026-03-05", elo: 1239, opponent: "GE", change: 16 },
];

describe("filterByTimeRange", () => {
it("returns the correct subset for each range", () => {
expect(filterByTimeRange(EXTENDED_MOCK_ELO_DATA, "7d")).toHaveLength(8);
expect(filterByTimeRange(EXTENDED_MOCK_ELO_DATA, "30d")).toHaveLength(31);
expect(filterByTimeRange(EXTENDED_MOCK_ELO_DATA, "90d")).toHaveLength(90);
expect(filterByTimeRange(EXTENDED_MOCK_ELO_DATA, "all")).toHaveLength(90);
});

it("returns an empty list for empty data", () => {
expect(filterByTimeRange([], "30d")).toEqual([]);
});
});

describe("computeEloStats", () => {
it("computes aggregate rating metrics", () => {
const stats = computeEloStats(SAMPLE_DATA);

expect(stats.currentElo).toBe(1239);
expect(stats.peakElo).toBe(1239);
expect(stats.lowestElo).toBe(1205);
expect(stats.totalGames).toBe(5);
expect(stats.wins).toBe(3);
expect(stats.losses).toBe(1);
expect(stats.draws).toBe(1);
expect(stats.winRate).toBeCloseTo(60);
expect(stats.currentStreak).toBe(2);
expect(stats.bestStreak).toBe(2);
expect(stats.avgChange).toBeCloseTo(7.8);
expect(stats.volatility).toBeCloseTo(computeVolatility(SAMPLE_DATA));
});

it("handles empty arrays", () => {
expect(computeEloStats([])).toEqual({
currentElo: 0,
peakElo: 0,
lowestElo: 0,
totalGames: 0,
wins: 0,
losses: 0,
draws: 0,
winRate: 0,
currentStreak: 0,
bestStreak: 0,
avgChange: 0,
volatility: 0,
});
});

it("handles a single data point", () => {
const single = computeEloStats([{ date: "2026-03-01", elo: 1120, opponent: "GA", change: 20 }]);

expect(single.currentElo).toBe(1120);
expect(single.totalGames).toBe(1);
expect(single.wins).toBe(1);
expect(single.currentStreak).toBe(1);
expect(single.bestStreak).toBe(1);
});
});

describe("computeStreak", () => {
it("returns best and current streaks for all wins", () => {
const data = SAMPLE_DATA.filter((point) => point.change > 0);
expect(computeStreak(data)).toEqual({ current: 3, best: 3 });
});

it("returns current loss streak for trailing losses", () => {
const data: EloDataPoint[] = [
{ date: "2026-03-01", elo: 1200, opponent: "GA", change: 12 },
{ date: "2026-03-02", elo: 1188, opponent: "GB", change: -12 },
{ date: "2026-03-03", elo: 1171, opponent: "GC", change: -17 },
];

expect(computeStreak(data)).toEqual({ current: -2, best: 1 });
});

it("resets current streak on a draw", () => {
const data: EloDataPoint[] = [
{ date: "2026-03-01", elo: 1200, opponent: "GA", change: 12 },
{ date: "2026-03-02", elo: 1200, opponent: "GB", change: 0 },
];

expect(computeStreak(data)).toEqual({ current: 0, best: 1 });
});
});

describe("computeVolatility", () => {
it("returns zero for uniform changes", () => {
const data = [1, 2, 3].map((index) => ({
date: `2026-03-0${index}`,
elo: 1200 + index * 10,
opponent: `G${index}`,
change: 10,
}));

expect(computeVolatility(data)).toBe(0);
});

it("returns a positive standard deviation for varied changes", () => {
expect(computeVolatility(SAMPLE_DATA)).toBeCloseTo(10.9618, 3);
});
});

describe("getRankTier", () => {
it("handles boundary values", () => {
expect(getRankTier(999).name).toBe("Beginner");
expect(getRankTier(1000).name).toBe("Intermediate");
expect(getRankTier(1200).name).toBe("Advanced");
expect(getRankTier(1400).name).toBe("Expert");
expect(getRankTier(1600).name).toBe("Master");
expect(getRankTier(1800).name).toBe("Grandmaster");
});
});
59 changes: 59 additions & 0 deletions frontend/__tests__/spectatorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { SpectatorMove } from "@/hook/useSpectatorSocket";
import {
formatClock,
getCapturedFromMoves,
getGamePhase,
truncateAddress,
} from "@/lib/spectatorUtils";

describe("spectatorUtils", () => {
describe("formatClock", () => {
it("formats seconds into mm:ss", () => {
expect(formatClock(0)).toBe("0:00");
expect(formatClock(60)).toBe("1:00");
expect(formatClock(599)).toBe("9:59");
expect(formatClock(3600)).toBe("60:00");
});
});

describe("truncateAddress", () => {
it("truncates stellar addresses", () => {
expect(truncateAddress("GABCDEFGHIJKLMNOP")).toBe("GABCDE...LMNOP");
});

it("returns short addresses unchanged", () => {
expect(truncateAddress("GSHORT123")).toBe("GSHORT123");
});
});

describe("getCapturedFromMoves", () => {
it("tracks captured pieces for both colors", () => {
const moves: SpectatorMove[] = [
{ from: "e2", to: "e4", san: "e4", color: "w" },
{ from: "d7", to: "d5", san: "d5", color: "b" },
{ from: "e4", to: "d5", san: "exd5", color: "w" },
{ from: "d8", to: "d5", san: "Qxd5", color: "b" },
{ from: "b1", to: "c3", san: "Nc3", color: "w" },
];

expect(getCapturedFromMoves(moves)).toEqual({
white: ["p"],
black: ["p"],
});
});

it("handles empty move lists", () => {
expect(getCapturedFromMoves([])).toEqual({ white: [], black: [] });
});
});

describe("getGamePhase", () => {
it("returns the correct phase by move count", () => {
expect(getGamePhase(0)).toBe("opening");
expect(getGamePhase(10)).toBe("opening");
expect(getGamePhase(11)).toBe("middlegame");
expect(getGamePhase(30)).toBe("middlegame");
expect(getGamePhase(31)).toBe("endgame");
});
});
});
47 changes: 47 additions & 0 deletions frontend/__tests__/useEloStats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { renderHook } from "@testing-library/react";
import type { EloDataPoint } from "@/components/profile/EloRatingChart";
import { EXTENDED_MOCK_ELO_DATA } from "@/constants/mockEloData";
import { useEloStats } from "@/hook/useEloStats";

const TEST_DATA: EloDataPoint[] = [
{ date: "2026-03-01", elo: 1108, opponent: "GA", change: 8 },
{ date: "2026-03-04", elo: 1094, opponent: "GB", change: -14 },
{ date: "2026-03-09", elo: 1094, opponent: "GC", change: 0 },
{ date: "2026-03-20", elo: 1111, opponent: "GD", change: 17 },
{ date: "2026-03-26", elo: 1127, opponent: "GE", change: 16 },
];

describe("useEloStats", () => {
it("returns computed stats for the selected range", () => {
const { result } = renderHook(() => useEloStats(TEST_DATA, "30d"));

expect(result.current.currentElo).toBe(1127);
expect(result.current.totalGames).toBe(5);
expect(result.current.wins).toBe(3);
expect(result.current.losses).toBe(1);
expect(result.current.draws).toBe(1);
expect(result.current.winRate).toBeCloseTo(60);
});

it("memoizes the result when inputs do not change", () => {
const { result, rerender } = renderHook(
({ data, range }) => useEloStats(data, range),
{
initialProps: { data: TEST_DATA, range: "30d" as const },
},
);
const firstResult = result.current;

rerender({ data: TEST_DATA, range: "30d" as const });

expect(result.current).toBe(firstResult);
});

it("filters by time range", () => {
const { result } = renderHook(() => useEloStats(EXTENDED_MOCK_ELO_DATA, "7d"));

expect(result.current.filteredData).toHaveLength(8);
expect(result.current.filteredData[0]?.date).toBe("2026-03-19");
expect(result.current.filteredData.at(-1)?.date).toBe("2026-03-26");
});
});
96 changes: 96 additions & 0 deletions frontend/__tests__/useSpectatorSocket.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { useSpectatorSocket } from "@/hook/useSpectatorSocket";

class MockWebSocket {
static instances: MockWebSocket[] = [];
static OPEN = 1;
static CONNECTING = 0;
static CLOSED = 3;

url: string;
readyState = MockWebSocket.CONNECTING;
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((event: { code: number; reason: string }) => void) | null = null;

constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}

send = vi.fn();

close(code = 1000, reason = "") {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code, reason });
}

emitOpen() {
this.readyState = MockWebSocket.OPEN;
this.onopen?.();
}

emitMessage(data: unknown) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}

describe("useSpectatorSocket", () => {
const originalWebSocket = globalThis.WebSocket;

beforeEach(() => {
MockWebSocket.instances = [];
vi.stubGlobal("WebSocket", MockWebSocket as unknown as typeof WebSocket);
});

afterEach(() => {
vi.unstubAllGlobals();
globalThis.WebSocket = originalWebSocket;
});

it("initializes with idle status and null game state when no game is selected", () => {
const { result } = renderHook(() => useSpectatorSocket(null));

expect(result.current.status).toBe("idle");
expect(result.current.gameState).toBeNull();
});

it("exposes disconnect and reconnect callbacks", () => {
const { result } = renderHook(() => useSpectatorSocket(null));

expect(typeof result.current.disconnect).toBe("function");
expect(typeof result.current.reconnect).toBe("function");
});

it("stores sync state for live websocket games", async () => {
const { result } = renderHook(() => useSpectatorSocket("live-game-42"));

await waitFor(() => {
expect(MockWebSocket.instances.length).toBeGreaterThan(0);
});

act(() => {
for (const socket of MockWebSocket.instances) {
socket.emitOpen();
socket.emitMessage({
type: "sync",
fen: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
moves: [{ from: "e2", to: "e4", san: "e4", color: "w" }],
whiteTime: 600,
blackTime: 600,
status: "playing",
spectatorCount: 9,
white: { address: "GWHITE123456", elo: 1300 },
black: { address: "GBLACK654321", elo: 1290 },
});
}
});

await waitFor(() => {
expect(MockWebSocket.instances.at(-1)?.url).toContain("/v1/games/live-game-42/spectate");
expect(result.current.gameState?.fen).toContain("4P3");
expect(result.current.gameState?.spectatorCount).toBe(9);
});
});
});
Loading
Loading