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 (
- <>
+