diff --git a/frontend/__tests__/spectatorUtils.test.ts b/frontend/__tests__/spectatorUtils.test.ts index f84ae4ed..a783855e 100644 --- a/frontend/__tests__/spectatorUtils.test.ts +++ b/frontend/__tests__/spectatorUtils.test.ts @@ -2,6 +2,7 @@ import type { SpectatorMove } from "@/hook/useSpectatorSocket"; import { formatClock, getCapturedFromMoves, + getCapturedPieceSymbols, getGamePhase, truncateAddress, } from "@/lib/spectatorUtils"; @@ -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", () => { @@ -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", () => { @@ -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"]); + }); + }); }); diff --git a/frontend/__tests__/useSpectatorSocket.test.ts b/frontend/__tests__/useSpectatorSocket.test.ts index 1cc8cc1d..f64ee5fd 100644 --- a/frontend/__tests__/useSpectatorSocket.test.ts +++ b/frontend/__tests__/useSpectatorSocket.test.ts @@ -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"); + }); + }); }); diff --git a/frontend/components/watch/SpectatorMoveList.tsx b/frontend/components/watch/SpectatorMoveList.tsx index 3e81526e..508bd887 100644 --- a/frontend/components/watch/SpectatorMoveList.tsx +++ b/frontend/components/watch/SpectatorMoveList.tsx @@ -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"; @@ -63,7 +63,7 @@ export function SpectatorMoveList({ moves, currentFen }: SpectatorMoveListProps) const latestIndex = moves.length - 1; return ( - <> +
{pair.black?.san ?? "—"}
- +
); })}
diff --git a/frontend/types/react-icons.d.ts b/frontend/types/react-icons.d.ts new file mode 100644 index 00000000..43fc403d --- /dev/null +++ b/frontend/types/react-icons.d.ts @@ -0,0 +1,44 @@ +declare module "react-icons/fa" { + import type { ComponentType, SVGProps } from "react"; + + export type IconType = ComponentType>; + + 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>; + + export const RiAiGenerate: IconType; + export const RiAliensFill: IconType; + export const RiRobot2Line: IconType; +}