Skip to content

Commit 2cdf2c2

Browse files
committed
Add round stale-move validation race tests
1 parent 63a783c commit 2cdf2c2

1 file changed

Lines changed: 133 additions & 0 deletions

File tree

tests/roundMoveValidation.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { beforeEach, describe, expect, jest, test } from "@jest/globals";
2+
import { MsgMove } from "../client/messages";
3+
import { pendingMoveStorageKey } from "../client/pendingMove";
4+
5+
type MethodMap = Record<string, (...args: any[]) => any>;
6+
type SentMessage = { type: string; [key: string]: unknown };
7+
8+
jest.unstable_mockModule("chessgroundx", () => ({
9+
Chessground: jest.fn(),
10+
}));
11+
12+
const { RoundController } = await import("../client/roundCtrl");
13+
const roundProto = RoundController.prototype as unknown as MethodMap;
14+
15+
function callRoundMethod(ctx: Record<string, unknown>, name: string, ...args: unknown[]): unknown {
16+
return roundProto[name].call(ctx, ...args);
17+
}
18+
19+
beforeEach(() => {
20+
localStorage.clear();
21+
});
22+
23+
describe("round move validation", () => {
24+
test("rejects stale move only after clock calculation, then requests board sync", () => {
25+
const callOrder: string[] = [];
26+
const sent: SentMessage[] = [];
27+
const persistPendingMove = jest.fn();
28+
29+
const ctrl = {
30+
gameId: "game-stale",
31+
clearDialog: jest.fn(),
32+
corr: false,
33+
flipped: () => false,
34+
base: 5,
35+
ply: 12,
36+
clocks: [
37+
{ duration: 180000, pause: jest.fn(), setTime: jest.fn(), start: jest.fn() },
38+
{
39+
duration: 179500,
40+
pause: jest.fn(() => callOrder.push("pause")),
41+
setTime: jest.fn(),
42+
start: jest.fn(),
43+
},
44+
],
45+
mycolor: "white",
46+
berserked: { wberserk: false, bberserk: false },
47+
inc: 2,
48+
byoyomi: false,
49+
preaction: false,
50+
clocktimes: [180000, 179500],
51+
ffishBoard: {
52+
legalMoves: jest.fn(() => {
53+
callOrder.push("legalMoves");
54+
return "a2a3 b2b3";
55+
}),
56+
},
57+
clearLocalMoveQueueState: jest.fn(),
58+
doSend: jest.fn((msg: SentMessage) => sent.push(msg)),
59+
persistPendingMove,
60+
clockOn: false,
61+
oppcolor: "black",
62+
} as unknown as Record<string, unknown>;
63+
64+
callRoundMethod(ctrl, "doSendMove", "f7f8k");
65+
66+
expect(callOrder).toEqual(["pause", "legalMoves"]);
67+
expect(ctrl.clearLocalMoveQueueState).toHaveBeenCalledTimes(1);
68+
expect(persistPendingMove).not.toHaveBeenCalled();
69+
expect(sent).toEqual([{ type: "board", gameId: "game-stale" }]);
70+
});
71+
72+
test("takeback-style local queue clear removes stale premove state and blocks stale resend", () => {
73+
const gameId = "game-antichess";
74+
const key = pendingMoveStorageKey(gameId);
75+
const pending: MsgMove = {
76+
type: "move",
77+
gameId,
78+
move: "f7f8k",
79+
clocks: [12000, 11000],
80+
ply: 32,
81+
};
82+
localStorage.setItem(key, JSON.stringify(pending));
83+
84+
const sent: SentMessage[] = [];
85+
const persistPendingMove = jest.fn();
86+
const cancelPremove = jest.fn();
87+
const unsetPremove = jest.fn();
88+
89+
const ctrl = {
90+
gameId,
91+
lastMaybeSentMsgMove: pending,
92+
chessground: { cancelPremove },
93+
unsetPremove,
94+
pendingMoveStorageKey: roundProto.pendingMoveStorageKey,
95+
clearPendingMoveCache: roundProto.clearPendingMoveCache,
96+
clearLocalMoveQueueState: roundProto.clearLocalMoveQueueState,
97+
clearDialog: jest.fn(),
98+
corr: false,
99+
flipped: () => false,
100+
base: 0,
101+
ply: 31,
102+
clocks: [
103+
{ duration: 12000, pause: jest.fn(), setTime: jest.fn(), start: jest.fn() },
104+
{ duration: 11000, pause: jest.fn(), setTime: jest.fn(), start: jest.fn() },
105+
],
106+
mycolor: "white",
107+
berserked: { wberserk: false, bberserk: false },
108+
inc: 3,
109+
byoyomi: false,
110+
preaction: false,
111+
clocktimes: [12000, 11000],
112+
ffishBoard: { legalMoves: jest.fn(() => "a2a3 h2h3") },
113+
doSend: jest.fn((msg: SentMessage) => sent.push(msg)),
114+
persistPendingMove,
115+
clockOn: false,
116+
oppcolor: "black",
117+
} as unknown as Record<string, unknown>;
118+
119+
// takeback and board-takeback paths both use this helper to invalidate local queued move intent.
120+
callRoundMethod(ctrl, "clearLocalMoveQueueState");
121+
122+
expect(cancelPremove).toHaveBeenCalledTimes(1);
123+
expect(unsetPremove).toHaveBeenCalledTimes(1);
124+
expect(ctrl.lastMaybeSentMsgMove).toBeUndefined();
125+
expect(localStorage.getItem(key)).toBeNull();
126+
127+
callRoundMethod(ctrl, "doSendMove", "f7f8k");
128+
129+
expect(sent).toEqual([{ type: "board", gameId }]);
130+
expect(sent.some((msg) => msg.type === "move")).toBe(false);
131+
expect(persistPendingMove).not.toHaveBeenCalled();
132+
});
133+
});

0 commit comments

Comments
 (0)