Skip to content
Open
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
31 changes: 31 additions & 0 deletions frontend/__tests__/spectatorUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { SpectatorMove } from "@/hook/useSpectatorSocket";
import {
formatClock,
getCapturedFromMoves,
getCapturedPieceSymbols,
getGamePhase,
truncateAddress,
} from "@/lib/spectatorUtils";
Expand All @@ -14,6 +15,11 @@ describe("spectatorUtils", () => {
expect(formatClock(599)).toBe("9:59");
expect(formatClock(3600)).toBe("60:00");
});

it("normalizes unsafe clock values", () => {
expect(formatClock(-12)).toBe("0:00");
expect(formatClock(61.9)).toBe("1:01");
});
});

describe("truncateAddress", () => {
Expand Down Expand Up @@ -45,6 +51,20 @@ describe("spectatorUtils", () => {
it("handles empty move lists", () => {
expect(getCapturedFromMoves([])).toEqual({ white: [], black: [] });
});

it("ignores malformed move payloads without dropping later valid moves", () => {
const moves: SpectatorMove[] = [
{ from: "e2", to: "e5", san: "e5", color: "w" },
{ from: "e2", to: "e4", san: "e4", color: "w" },
{ from: "d7", to: "d5", san: "d5", color: "b" },
{ from: "e4", to: "d5", san: "exd5", color: "w" },
];

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

describe("getGamePhase", () => {
Expand All @@ -56,4 +76,15 @@ describe("spectatorUtils", () => {
expect(getGamePhase(31)).toBe("endgame");
});
});

describe("getCapturedPieceSymbols", () => {
it("maps captured pieces from the opponent perspective", () => {
expect(getCapturedPieceSymbols(["p", "q"], "white")).toEqual(["♟", "♛"]);
expect(getCapturedPieceSymbols(["n", "r"], "black")).toEqual(["♘", "♖"]);
});

it("falls back for unknown piece identifiers", () => {
expect(getCapturedPieceSymbols(["x"], "white")).toEqual(["X"]);
});
});
});
55 changes: 55 additions & 0 deletions frontend/__tests__/useSpectatorSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,59 @@ describe("useSpectatorSocket", () => {
expect(result.current.gameState?.spectatorCount).toBe(9);
});
});

it("applies incremental move, clock, spectator, and game-over updates", async () => {
const { result } = renderHook(() => useSpectatorSocket("live-game-99"));

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

act(() => {
const socket = MockWebSocket.instances.at(-1);
socket?.emitOpen();
socket?.emitMessage({
type: "sync",
fen: "start",
moves: [],
whiteTime: 600,
blackTime: 600,
status: "playing",
spectatorCount: 2,
white: { address: "GWHITE123456", elo: 1300 },
black: { address: "GBLACK654321", elo: 1290 },
});
socket?.emitMessage({ type: "move", from: "e2", to: "e4", san: "e4", color: "w" });
socket?.emitMessage({ type: "clock", whiteTime: 589, blackTime: 600 });
socket?.emitMessage({ type: "spectator_count", count: 5 });
socket?.emitMessage({ type: "game_over", result: "1-0", reason: "white checkmate" });
});

await waitFor(() => {
expect(result.current.gameState?.moves).toHaveLength(1);
expect(result.current.gameState?.moves[0].san).toBe("e4");
expect(result.current.gameState?.whiteTime).toBe(589);
expect(result.current.gameState?.spectatorCount).toBe(5);
expect(result.current.gameState?.status).toBe("checkmate");
expect(result.current.gameState?.result).toBe("1-0");
});
});

it("moves to error status on malformed socket messages", async () => {
const { result } = renderHook(() => useSpectatorSocket("live-game-bad-payload"));

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

act(() => {
const socket = MockWebSocket.instances.at(-1);
socket?.emitOpen();
socket?.onmessage?.({ data: "not json" });
});

await waitFor(() => {
expect(result.current.status).toBe("error");
});
});
});
6 changes: 3 additions & 3 deletions frontend/components/watch/SpectatorMoveList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useMemo, useRef } from "react";
import { Fragment, useEffect, useMemo, useRef } from "react";
import { Chess } from "chess.js";
import type { SpectatorMove } from "@/hook/useSpectatorSocket";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -63,7 +63,7 @@ export function SpectatorMoveList({ moves, currentFen }: SpectatorMoveListProps)
const latestIndex = moves.length - 1;

return (
<>
<Fragment key={`move-row-${pair.moveNumber}`}>
<div
key={`move-number-${pair.moveNumber}`}
className="py-2 text-xs font-semibold text-gray-500"
Expand All @@ -88,7 +88,7 @@ export function SpectatorMoveList({ moves, currentFen }: SpectatorMoveListProps)
>
{pair.black?.san ?? "—"}
</div>
</>
</Fragment>
);
})}
<div ref={endRef} />
Expand Down
44 changes: 44 additions & 0 deletions frontend/types/react-icons.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
declare module "react-icons/fa" {
import type { ComponentType, SVGProps } from "react";

export type IconType = ComponentType<SVGProps<SVGSVGElement>>;

export const FaBolt: IconType;
export const FaArrowLeft: IconType;
export const FaBrain: IconType;
export const FaCheck: IconType;
export const FaChartLine: IconType;
export const FaCheckCircle: IconType;
export const FaChess: IconType;
export const FaChessBoard: IconType;
export const FaClock: IconType;
export const FaCrown: IconType;
export const FaExclamationTriangle: IconType;
export const FaEye: IconType;
export const FaFire: IconType;
export const FaGamepad: IconType;
export const FaHistory: IconType;
export const FaPlay: IconType;
export const FaRedo: IconType;
export const FaRobot: IconType;
export const FaSearch: IconType;
export const FaShieldAlt: IconType;
export const FaSignal: IconType;
export const FaSpinner: IconType;
export const FaStar: IconType;
export const FaTimes: IconType;
export const FaTrophy: IconType;
export const FaUser: IconType;
export const FaUsers: IconType;
export const FaWallet: IconType;
}

declare module "react-icons/ri" {
import type { ComponentType, SVGProps } from "react";

export type IconType = ComponentType<SVGProps<SVGSVGElement>>;

export const RiAiGenerate: IconType;
export const RiAliensFill: IconType;
export const RiRobot2Line: IconType;
}