@react-chess-tools/react-chess-puzzle 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +570 -0
- package/dist/index.cjs +132 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -5
- package/dist/index.d.ts +19 -5
- package/dist/index.js +118 -93
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/ChessPuzzle/ChessPuzzle.stories.tsx +46 -0
- package/src/components/ChessPuzzle/parts/Hint.tsx +32 -23
- package/src/components/ChessPuzzle/parts/PuzzleBoard.tsx +28 -27
- package/src/components/ChessPuzzle/parts/Reset.tsx +56 -36
- package/src/components/ChessPuzzle/parts/Root.tsx +14 -2
- package/src/components/ChessPuzzle/parts/__tests__/Hint.test.tsx +158 -0
- package/src/components/ChessPuzzle/parts/__tests__/PuzzleBoard.test.tsx +140 -0
- package/src/components/ChessPuzzle/parts/__tests__/Reset.test.tsx +341 -0
- package/src/components/ChessPuzzle/parts/__tests__/Root.test.tsx +42 -0
- package/src/hooks/__tests__/reducer.test.ts +134 -0
- package/src/hooks/reducer.ts +13 -1
- package/src/hooks/useChessPuzzle.ts +2 -0
- package/src/utils/__tests__/index.test.ts +0 -17
- package/src/utils/index.ts +1 -11
- package/README.MD +0 -344
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { ChessPuzzle } from "../..";
|
|
5
|
+
import { Reset } from "../Reset";
|
|
6
|
+
import { Puzzle } from "../../../../utils";
|
|
7
|
+
|
|
8
|
+
describe("ChessPuzzle.Reset", () => {
|
|
9
|
+
const mockPuzzle: Puzzle = {
|
|
10
|
+
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
|
11
|
+
moves: ["e4", "e5"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("displayName", () => {
|
|
15
|
+
it("should have correct displayName", () => {
|
|
16
|
+
expect(Reset.displayName).toBe("ChessPuzzle.Reset");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("ref forwarding", () => {
|
|
21
|
+
it("should forward ref to button element", () => {
|
|
22
|
+
const ref = React.createRef<HTMLElement>();
|
|
23
|
+
|
|
24
|
+
render(
|
|
25
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
26
|
+
<Reset
|
|
27
|
+
ref={ref}
|
|
28
|
+
showOn={["not-started", "in-progress", "failed", "solved"]}
|
|
29
|
+
/>
|
|
30
|
+
</ChessPuzzle.Root>,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should forward ref when using asChild", () => {
|
|
37
|
+
const ref = React.createRef<HTMLElement>();
|
|
38
|
+
|
|
39
|
+
render(
|
|
40
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
41
|
+
<Reset
|
|
42
|
+
ref={ref}
|
|
43
|
+
asChild
|
|
44
|
+
showOn={["not-started", "in-progress", "failed", "solved"]}
|
|
45
|
+
>
|
|
46
|
+
<button>Custom Reset</button>
|
|
47
|
+
</Reset>
|
|
48
|
+
</ChessPuzzle.Root>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should support callback refs", () => {
|
|
55
|
+
let capturedRef: HTMLElement | null = null;
|
|
56
|
+
const callbackRef = (ref: HTMLElement | null) => {
|
|
57
|
+
capturedRef = ref;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
render(
|
|
61
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
62
|
+
<Reset
|
|
63
|
+
ref={callbackRef}
|
|
64
|
+
showOn={["not-started", "in-progress", "failed", "solved"]}
|
|
65
|
+
/>
|
|
66
|
+
</ChessPuzzle.Root>,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(capturedRef).toBeInstanceOf(HTMLButtonElement);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("visibility based on showOn prop", () => {
|
|
74
|
+
it("should be visible when status matches showOn", () => {
|
|
75
|
+
render(
|
|
76
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
77
|
+
<Reset showOn={["not-started"]}>Reset</Reset>
|
|
78
|
+
</ChessPuzzle.Root>,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(screen.getByRole("button")).toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should be hidden when status does not match showOn", () => {
|
|
85
|
+
render(
|
|
86
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
87
|
+
<Reset showOn={["solved"]}>Reset</Reset>
|
|
88
|
+
</ChessPuzzle.Root>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should default to showing on failed and solved statuses", () => {
|
|
95
|
+
// Default showOn is ["failed", "solved"], so it should be hidden at start
|
|
96
|
+
render(
|
|
97
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
98
|
+
<Reset>Reset</Reset>
|
|
99
|
+
</ChessPuzzle.Root>,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should accept multiple statuses in showOn", () => {
|
|
106
|
+
render(
|
|
107
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
108
|
+
<Reset showOn={["not-started", "in-progress", "failed", "solved"]}>
|
|
109
|
+
Reset
|
|
110
|
+
</Reset>
|
|
111
|
+
</ChessPuzzle.Root>,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(screen.getByRole("button")).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("prop spreading", () => {
|
|
119
|
+
it("should apply custom className", () => {
|
|
120
|
+
render(
|
|
121
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
122
|
+
<Reset className="custom-reset-class" showOn={["not-started"]}>
|
|
123
|
+
Reset
|
|
124
|
+
</Reset>
|
|
125
|
+
</ChessPuzzle.Root>,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(screen.getByRole("button")).toHaveClass("custom-reset-class");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should apply custom id", () => {
|
|
132
|
+
render(
|
|
133
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
134
|
+
<Reset id="reset-button" showOn={["not-started"]}>
|
|
135
|
+
Reset
|
|
136
|
+
</Reset>
|
|
137
|
+
</ChessPuzzle.Root>,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(screen.getByRole("button")).toHaveAttribute("id", "reset-button");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should apply data-* attributes", () => {
|
|
144
|
+
render(
|
|
145
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
146
|
+
<Reset
|
|
147
|
+
data-testid="reset"
|
|
148
|
+
data-custom="value"
|
|
149
|
+
showOn={["not-started"]}
|
|
150
|
+
>
|
|
151
|
+
Reset
|
|
152
|
+
</Reset>
|
|
153
|
+
</ChessPuzzle.Root>,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const button = screen.getByTestId("reset");
|
|
157
|
+
expect(button).toHaveAttribute("data-custom", "value");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("should apply aria-* attributes", () => {
|
|
161
|
+
render(
|
|
162
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
163
|
+
<Reset aria-label="Reset puzzle" showOn={["not-started"]}>
|
|
164
|
+
Reset
|
|
165
|
+
</Reset>
|
|
166
|
+
</ChessPuzzle.Root>,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(screen.getByRole("button")).toHaveAttribute(
|
|
170
|
+
"aria-label",
|
|
171
|
+
"Reset puzzle",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should apply disabled attribute", () => {
|
|
176
|
+
render(
|
|
177
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
178
|
+
<Reset disabled showOn={["not-started"]}>
|
|
179
|
+
Reset
|
|
180
|
+
</Reset>
|
|
181
|
+
</ChessPuzzle.Root>,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(screen.getByRole("button")).toBeDisabled();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("asChild pattern", () => {
|
|
189
|
+
it("should render custom element when asChild is true", () => {
|
|
190
|
+
render(
|
|
191
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
192
|
+
<Reset asChild showOn={["not-started"]}>
|
|
193
|
+
<button className="custom-button">Custom Reset</button>
|
|
194
|
+
</Reset>
|
|
195
|
+
</ChessPuzzle.Root>,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const button = screen.getByRole("button");
|
|
199
|
+
expect(button).toHaveTextContent("Custom Reset");
|
|
200
|
+
expect(button).toHaveClass("custom-button");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should merge className with asChild element", () => {
|
|
204
|
+
render(
|
|
205
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
206
|
+
<Reset asChild className="reset-class" showOn={["not-started"]}>
|
|
207
|
+
<button className="child-class">Reset</button>
|
|
208
|
+
</Reset>
|
|
209
|
+
</ChessPuzzle.Root>,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const button = screen.getByRole("button");
|
|
213
|
+
expect(button).toHaveClass("reset-class");
|
|
214
|
+
expect(button).toHaveClass("child-class");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("should compose onClick handlers with asChild", () => {
|
|
218
|
+
const childOnClick = jest.fn();
|
|
219
|
+
|
|
220
|
+
render(
|
|
221
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
222
|
+
<Reset asChild showOn={["not-started"]}>
|
|
223
|
+
<button onClick={childOnClick}>Reset</button>
|
|
224
|
+
</Reset>
|
|
225
|
+
</ChessPuzzle.Root>,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
fireEvent.click(screen.getByRole("button"));
|
|
229
|
+
expect(childOnClick).toHaveBeenCalledTimes(1);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("functionality", () => {
|
|
234
|
+
it("should call onReset callback when clicked", () => {
|
|
235
|
+
const handleReset = jest.fn();
|
|
236
|
+
|
|
237
|
+
render(
|
|
238
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
239
|
+
<Reset onReset={handleReset} showOn={["not-started"]}>
|
|
240
|
+
Reset
|
|
241
|
+
</Reset>
|
|
242
|
+
</ChessPuzzle.Root>,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
fireEvent.click(screen.getByRole("button"));
|
|
246
|
+
expect(handleReset).toHaveBeenCalledTimes(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("should pass puzzle context to onReset callback", () => {
|
|
250
|
+
const handleReset = jest.fn();
|
|
251
|
+
|
|
252
|
+
render(
|
|
253
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
254
|
+
<Reset onReset={handleReset} showOn={["not-started"]}>
|
|
255
|
+
Reset
|
|
256
|
+
</Reset>
|
|
257
|
+
</ChessPuzzle.Root>,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
fireEvent.click(screen.getByRole("button"));
|
|
261
|
+
expect(handleReset).toHaveBeenCalledWith(
|
|
262
|
+
expect.objectContaining({
|
|
263
|
+
puzzle: mockPuzzle,
|
|
264
|
+
status: expect.any(String),
|
|
265
|
+
changePuzzle: expect.any(Function),
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should have type button by default", () => {
|
|
271
|
+
render(
|
|
272
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
273
|
+
<Reset showOn={["not-started"]}>Reset</Reset>
|
|
274
|
+
</ChessPuzzle.Root>,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(screen.getByRole("button")).toHaveAttribute("type", "button");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("context validation", () => {
|
|
282
|
+
it("should throw error when used outside ChessPuzzle.Root", () => {
|
|
283
|
+
const consoleError = jest
|
|
284
|
+
.spyOn(console, "error")
|
|
285
|
+
.mockImplementation(() => {});
|
|
286
|
+
|
|
287
|
+
expect(() => {
|
|
288
|
+
render(<Reset showOn={["not-started"]}>Reset</Reset>);
|
|
289
|
+
}).toThrow();
|
|
290
|
+
|
|
291
|
+
consoleError.mockRestore();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("children", () => {
|
|
296
|
+
it("should render text children", () => {
|
|
297
|
+
render(
|
|
298
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
299
|
+
<Reset showOn={["not-started"]}>Reset Puzzle</Reset>
|
|
300
|
+
</ChessPuzzle.Root>,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(screen.getByRole("button")).toHaveTextContent("Reset Puzzle");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("should render element children", () => {
|
|
307
|
+
render(
|
|
308
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
309
|
+
<Reset showOn={["not-started"]}>
|
|
310
|
+
<span data-testid="child">Reset Icon</span>
|
|
311
|
+
</Reset>
|
|
312
|
+
</ChessPuzzle.Root>,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("should render without children", () => {
|
|
319
|
+
render(
|
|
320
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
321
|
+
<Reset showOn={["not-started"]} aria-label="Reset" />
|
|
322
|
+
</ChessPuzzle.Root>,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
expect(screen.getByRole("button")).toBeInTheDocument();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("backward compatibility", () => {
|
|
330
|
+
it("should work with minimal props when status matches default showOn", () => {
|
|
331
|
+
// Since default showOn is ["failed", "solved"], we need to set showOn to see the button
|
|
332
|
+
render(
|
|
333
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
334
|
+
<Reset showOn={["not-started"]}>Reset</Reset>
|
|
335
|
+
</ChessPuzzle.Root>,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
expect(screen.getByRole("button")).toBeInTheDocument();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { ChessPuzzle } from "../..";
|
|
5
|
+
import { Root } from "../Root";
|
|
6
|
+
import { Puzzle } from "../../../../utils";
|
|
7
|
+
|
|
8
|
+
describe("ChessPuzzle.Root", () => {
|
|
9
|
+
const mockPuzzle: Puzzle = {
|
|
10
|
+
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
|
11
|
+
moves: ["e4", "e5"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
it("should have correct displayName", () => {
|
|
15
|
+
expect(Root.displayName).toBe("ChessPuzzle.Root");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should render children correctly", () => {
|
|
19
|
+
render(
|
|
20
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
21
|
+
<div data-testid="child">Child Component</div>
|
|
22
|
+
</ChessPuzzle.Root>,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText("Child Component")).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should render multiple children", () => {
|
|
30
|
+
render(
|
|
31
|
+
<ChessPuzzle.Root puzzle={mockPuzzle}>
|
|
32
|
+
<div data-testid="child-1">Child 1</div>
|
|
33
|
+
<div data-testid="child-2">Child 2</div>
|
|
34
|
+
<div data-testid="child-3">Child 3</div>
|
|
35
|
+
</ChessPuzzle.Root>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(screen.getByTestId("child-1")).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByTestId("child-2")).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByTestId("child-3")).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -271,4 +271,138 @@ describe("reducer", () => {
|
|
|
271
271
|
expect(newState.status).toBe("failed");
|
|
272
272
|
});
|
|
273
273
|
});
|
|
274
|
+
|
|
275
|
+
describe("solveOnCheckmate feature", () => {
|
|
276
|
+
// Puzzle with multiple checkmate solutions
|
|
277
|
+
const multiMatePuzzle: Puzzle = {
|
|
278
|
+
fen: "7k/R7/1R6/2Q5/4Q3/8/8/7K w - - 0 1",
|
|
279
|
+
moves: ["a7a8"],
|
|
280
|
+
makeFirstMove: false,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const multiMateInitialState: State = {
|
|
284
|
+
puzzle: multiMatePuzzle,
|
|
285
|
+
currentMoveIndex: 0,
|
|
286
|
+
status: "not-started",
|
|
287
|
+
nextMove: "a7a8",
|
|
288
|
+
hint: "none",
|
|
289
|
+
cpuMove: null,
|
|
290
|
+
needCpuMove: false,
|
|
291
|
+
isPlayerTurn: true,
|
|
292
|
+
onSolveInvoked: false,
|
|
293
|
+
onFailInvoked: false,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
it("should solve puzzle when alternative checkmate move is made and solveOnCheckmate=true", () => {
|
|
297
|
+
const game = new Chess(multiMatePuzzle.fen);
|
|
298
|
+
// Alternative mate: Qc8# (queen mate from c5 to c8) instead of canonical Ra8#
|
|
299
|
+
game.move("Qc8");
|
|
300
|
+
const alternativeMateMove = game.history({ verbose: true }).pop() as Move;
|
|
301
|
+
|
|
302
|
+
const action: Action = {
|
|
303
|
+
type: "PLAYER_MOVE",
|
|
304
|
+
payload: {
|
|
305
|
+
move: alternativeMateMove,
|
|
306
|
+
puzzleContext: {} as ChessPuzzleContextType,
|
|
307
|
+
game,
|
|
308
|
+
solveOnCheckmate: true,
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const newState = reducer(multiMateInitialState, action);
|
|
313
|
+
|
|
314
|
+
expect(newState.status).toBe("solved");
|
|
315
|
+
expect(newState.nextMove).toBe(null);
|
|
316
|
+
expect(newState.isPlayerTurn).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("should fail puzzle when alternative checkmate move is made and solveOnCheckmate=false", () => {
|
|
320
|
+
const game = new Chess(multiMatePuzzle.fen);
|
|
321
|
+
// Alternative mate: Qf8# (queen mate from c5 to f8) instead of canonical Ra8#
|
|
322
|
+
game.move("Qf8");
|
|
323
|
+
const alternativeMateMove = game.history({ verbose: true }).pop() as Move;
|
|
324
|
+
|
|
325
|
+
const action: Action = {
|
|
326
|
+
type: "PLAYER_MOVE",
|
|
327
|
+
payload: {
|
|
328
|
+
move: alternativeMateMove,
|
|
329
|
+
puzzleContext: {} as ChessPuzzleContextType,
|
|
330
|
+
game,
|
|
331
|
+
solveOnCheckmate: false,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const newState = reducer(multiMateInitialState, action);
|
|
336
|
+
|
|
337
|
+
expect(newState.status).toBe("failed");
|
|
338
|
+
expect(newState.nextMove).toBe(null);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("should still solve puzzle when canonical solution move is made with solveOnCheckmate=true", () => {
|
|
342
|
+
const game = new Chess(multiMatePuzzle.fen);
|
|
343
|
+
// Make the canonical checkmate move on the game so isCheckmate() returns true
|
|
344
|
+
game.move("a7a8");
|
|
345
|
+
// Canonical move: Ra8# (rook mate from b6 to b8)
|
|
346
|
+
const canonicalMove = game.history({ verbose: true }).pop() as Move;
|
|
347
|
+
|
|
348
|
+
const action: Action = {
|
|
349
|
+
type: "PLAYER_MOVE",
|
|
350
|
+
payload: {
|
|
351
|
+
move: canonicalMove,
|
|
352
|
+
puzzleContext: {} as ChessPuzzleContextType,
|
|
353
|
+
game,
|
|
354
|
+
solveOnCheckmate: true,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const newState = reducer(multiMateInitialState, action);
|
|
359
|
+
|
|
360
|
+
expect(newState.status).toBe("solved");
|
|
361
|
+
expect(newState.nextMove).toBe(null);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("should fail puzzle when non-mate incorrect move is made with solveOnCheckmate=true", () => {
|
|
365
|
+
const game = new Chess(multiMatePuzzle.fen);
|
|
366
|
+
// Make a non-mate incorrect move (Qa3 checks but doesn't mate)
|
|
367
|
+
const incorrectResult = game.move("Qa3");
|
|
368
|
+
const incorrectMove = incorrectResult as Move;
|
|
369
|
+
|
|
370
|
+
const action: Action = {
|
|
371
|
+
type: "PLAYER_MOVE",
|
|
372
|
+
payload: {
|
|
373
|
+
move: incorrectMove,
|
|
374
|
+
puzzleContext: {} as ChessPuzzleContextType,
|
|
375
|
+
game,
|
|
376
|
+
solveOnCheckmate: true,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const newState = reducer(multiMateInitialState, action);
|
|
381
|
+
|
|
382
|
+
// Not a checkmate (Qa3 is not mate), so should fail as incorrect move
|
|
383
|
+
expect(newState.status).toBe("failed");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("should enable checkmate detection when solveOnCheckmate is undefined in payload", () => {
|
|
387
|
+
const game = new Chess(multiMatePuzzle.fen);
|
|
388
|
+
// Make an alternative checkmate move on the game so isCheckmate() returns true
|
|
389
|
+
game.move("Qe8");
|
|
390
|
+
// Alternative mate: Qe8# (queen mate from e4 to e8)
|
|
391
|
+
const alternativeMateMove = game.history({ verbose: true }).pop() as Move;
|
|
392
|
+
|
|
393
|
+
const action: Action = {
|
|
394
|
+
type: "PLAYER_MOVE",
|
|
395
|
+
payload: {
|
|
396
|
+
move: alternativeMateMove,
|
|
397
|
+
puzzleContext: {} as ChessPuzzleContextType,
|
|
398
|
+
game,
|
|
399
|
+
// solveOnCheckmate not provided - should default to true
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const newState = reducer(multiMateInitialState, action);
|
|
404
|
+
|
|
405
|
+
expect(newState.status).toBe("solved");
|
|
406
|
+
});
|
|
407
|
+
});
|
|
274
408
|
});
|
package/src/hooks/reducer.ts
CHANGED
|
@@ -35,6 +35,7 @@ export type Action =
|
|
|
35
35
|
move?: Move | null;
|
|
36
36
|
puzzleContext: ChessPuzzleContextType;
|
|
37
37
|
game: Chess;
|
|
38
|
+
solveOnCheckmate?: boolean;
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
| { type: "MARK_SOLVE_INVOKED" }
|
|
@@ -96,7 +97,18 @@ export const reducer = (state: State, action: Action): State => {
|
|
|
96
97
|
};
|
|
97
98
|
|
|
98
99
|
case "PLAYER_MOVE": {
|
|
99
|
-
const { move } = action.payload;
|
|
100
|
+
const { move, game, solveOnCheckmate } = action.payload;
|
|
101
|
+
|
|
102
|
+
if (move && solveOnCheckmate !== false && game.isCheckmate()) {
|
|
103
|
+
return {
|
|
104
|
+
...state,
|
|
105
|
+
status: "solved",
|
|
106
|
+
nextMove: null,
|
|
107
|
+
hint: "none",
|
|
108
|
+
isPlayerTurn: false,
|
|
109
|
+
onSolveInvoked: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
100
112
|
|
|
101
113
|
const isMoveRight = [move?.san, move?.lan].includes(
|
|
102
114
|
state?.nextMove || "",
|
|
@@ -21,6 +21,7 @@ export const useChessPuzzle = (
|
|
|
21
21
|
puzzle: Puzzle,
|
|
22
22
|
onSolve?: (puzzleContext: ChessPuzzleContextType) => void,
|
|
23
23
|
onFail?: (puzzleContext: ChessPuzzleContextType) => void,
|
|
24
|
+
solveOnCheckmate: boolean = true,
|
|
24
25
|
): ChessPuzzleContextType => {
|
|
25
26
|
const gameContext = useChessGameContext();
|
|
26
27
|
|
|
@@ -111,6 +112,7 @@ export const useChessPuzzle = (
|
|
|
111
112
|
move: gameContext?.game?.history({ verbose: true })?.pop() ?? null,
|
|
112
113
|
puzzleContext,
|
|
113
114
|
game: game,
|
|
115
|
+
solveOnCheckmate,
|
|
114
116
|
},
|
|
115
117
|
});
|
|
116
118
|
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { Chess, Move } from "chess.js";
|
|
2
|
-
import React from "react";
|
|
3
2
|
import {
|
|
4
3
|
getOrientation,
|
|
5
|
-
isClickableElement,
|
|
6
4
|
getCustomSquareStyles,
|
|
7
5
|
stringToMove,
|
|
8
6
|
Puzzle,
|
|
@@ -40,21 +38,6 @@ describe("Puzzle Utilities", () => {
|
|
|
40
38
|
});
|
|
41
39
|
});
|
|
42
40
|
|
|
43
|
-
describe("isClickableElement", () => {
|
|
44
|
-
it("should return true for valid clickable elements", () => {
|
|
45
|
-
const clickableElement = React.createElement("button", {
|
|
46
|
-
onClick: () => {},
|
|
47
|
-
});
|
|
48
|
-
expect(isClickableElement(clickableElement)).toBe(true);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("should return false for non-React elements", () => {
|
|
52
|
-
expect(isClickableElement("not an element")).toBe(false);
|
|
53
|
-
expect(isClickableElement(null)).toBe(false);
|
|
54
|
-
expect(isClickableElement(undefined)).toBe(false);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
41
|
describe("getCustomSquareStyles", () => {
|
|
59
42
|
const game = new Chess();
|
|
60
43
|
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Color, Chess, Move } from "chess.js";
|
|
2
|
-
import
|
|
2
|
+
import { type CSSProperties } from "react";
|
|
3
3
|
import _ from "lodash";
|
|
4
4
|
import type { ChessPuzzleTheme } from "../theme/types";
|
|
5
5
|
import { defaultPuzzleTheme } from "../theme/defaults";
|
|
@@ -24,16 +24,6 @@ export const getOrientation = (puzzle: Puzzle): Color => {
|
|
|
24
24
|
return game.turn();
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
interface ClickableElement extends ReactElement {
|
|
28
|
-
props: {
|
|
29
|
-
onClick?: () => void;
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export const isClickableElement = (
|
|
34
|
-
element: ReactNode,
|
|
35
|
-
): element is ClickableElement => React.isValidElement(element);
|
|
36
|
-
|
|
37
27
|
/**
|
|
38
28
|
* Generates custom square styles for puzzle states based on theme.
|
|
39
29
|
*
|