@react-chess-tools/react-chess-clock 1.0.1

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +697 -0
  3. package/dist/index.cjs +1014 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +528 -0
  6. package/dist/index.d.ts +528 -0
  7. package/dist/index.js +969 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +63 -0
  10. package/src/components/ChessClock/ChessClock.stories.tsx +782 -0
  11. package/src/components/ChessClock/index.ts +44 -0
  12. package/src/components/ChessClock/parts/Display.tsx +69 -0
  13. package/src/components/ChessClock/parts/PlayPause.tsx +190 -0
  14. package/src/components/ChessClock/parts/Reset.tsx +90 -0
  15. package/src/components/ChessClock/parts/Root.tsx +37 -0
  16. package/src/components/ChessClock/parts/Switch.tsx +84 -0
  17. package/src/components/ChessClock/parts/__tests__/Display.test.tsx +149 -0
  18. package/src/components/ChessClock/parts/__tests__/PlayPause.test.tsx +411 -0
  19. package/src/components/ChessClock/parts/__tests__/Reset.test.tsx +160 -0
  20. package/src/components/ChessClock/parts/__tests__/Root.test.tsx +49 -0
  21. package/src/components/ChessClock/parts/__tests__/Switch.test.tsx +204 -0
  22. package/src/hooks/__tests__/clockReducer.test.ts +985 -0
  23. package/src/hooks/__tests__/useChessClock.test.tsx +1080 -0
  24. package/src/hooks/clockReducer.ts +379 -0
  25. package/src/hooks/useChessClock.ts +406 -0
  26. package/src/hooks/useChessClockContext.ts +35 -0
  27. package/src/index.ts +65 -0
  28. package/src/types.ts +217 -0
  29. package/src/utils/__tests__/calculateSwitchTime.test.ts +150 -0
  30. package/src/utils/__tests__/formatTime.test.ts +83 -0
  31. package/src/utils/__tests__/timeControl.test.ts +414 -0
  32. package/src/utils/__tests__/timingMethods.test.ts +170 -0
  33. package/src/utils/calculateSwitchTime.ts +37 -0
  34. package/src/utils/formatTime.ts +59 -0
  35. package/src/utils/presets.ts +47 -0
  36. package/src/utils/timeControl.ts +205 -0
  37. package/src/utils/timingMethods.ts +103 -0
@@ -0,0 +1,1080 @@
1
+ import { renderHook, act } from "@testing-library/react";
2
+ import { useChessClock, useOptionalChessClock } from "../useChessClock";
3
+
4
+ // Mock requestAnimationFrame
5
+ const mockRaf = jest.fn();
6
+ const mockCancelRaf = jest.fn();
7
+
8
+ global.requestAnimationFrame =
9
+ mockRaf as unknown as typeof requestAnimationFrame;
10
+ global.cancelAnimationFrame =
11
+ mockCancelRaf as unknown as typeof cancelAnimationFrame;
12
+
13
+ // Mock Date.now() for deterministic timing
14
+ let mockNow = 1_000_000;
15
+
16
+ describe("useChessClock", () => {
17
+ beforeEach(() => {
18
+ jest.useFakeTimers();
19
+ mockNow = 1_000_000;
20
+ jest.spyOn(Date, "now").mockImplementation(() => mockNow);
21
+ });
22
+
23
+ afterEach(() => {
24
+ jest.useRealTimers();
25
+ jest.restoreAllMocks();
26
+ });
27
+
28
+ describe("initialization", () => {
29
+ it("should initialize with correct time from string", () => {
30
+ const { result } = renderHook(() => useChessClock({ time: "5+3" }));
31
+
32
+ expect(result.current.times.white).toBe(300_000); // 5 minutes
33
+ expect(result.current.times.black).toBe(300_000);
34
+ expect(result.current.status).toBe("delayed"); // default clockStart is "delayed"
35
+ expect(result.current.activePlayer).toBeNull();
36
+ });
37
+
38
+ it("should initialize with correct time from object", () => {
39
+ const { result } = renderHook(() =>
40
+ useChessClock({
41
+ time: { baseTime: 600, increment: 5 },
42
+ }),
43
+ );
44
+
45
+ expect(result.current.times.white).toBe(600_000); // 10 minutes
46
+ expect(result.current.times.black).toBe(600_000);
47
+ });
48
+
49
+ it("should initialize with time odds", () => {
50
+ const { result } = renderHook(() =>
51
+ useChessClock({
52
+ time: "5+0",
53
+ whiteTime: 300, // 5 minutes
54
+ blackTime: 180, // 3 minutes
55
+ }),
56
+ );
57
+
58
+ expect(result.current.times.white).toBe(300_000);
59
+ expect(result.current.times.black).toBe(180_000);
60
+ });
61
+
62
+ it("should start immediately for immediate clock start", () => {
63
+ const { result } = renderHook(() =>
64
+ useChessClock({
65
+ time: "5+0",
66
+ clockStart: "immediate",
67
+ }),
68
+ );
69
+
70
+ expect(result.current.status).toBe("running");
71
+ expect(result.current.activePlayer).toBe("white");
72
+ });
73
+
74
+ it("should be delayed for delayed clock start", () => {
75
+ const { result } = renderHook(() =>
76
+ useChessClock({
77
+ time: "5+0",
78
+ clockStart: "delayed",
79
+ }),
80
+ );
81
+
82
+ expect(result.current.status).toBe("delayed");
83
+ expect(result.current.activePlayer).toBeNull();
84
+ });
85
+
86
+ it("should be idle for manual clock start", () => {
87
+ const { result } = renderHook(() =>
88
+ useChessClock({
89
+ time: "5+0",
90
+ clockStart: "manual",
91
+ }),
92
+ );
93
+
94
+ expect(result.current.status).toBe("idle");
95
+ expect(result.current.activePlayer).toBeNull();
96
+ });
97
+ });
98
+
99
+ describe("info", () => {
100
+ it("should calculate correct info", () => {
101
+ const { result } = renderHook(() => useChessClock({ time: "5+0" }));
102
+
103
+ expect(result.current.info).toEqual({
104
+ isRunning: false,
105
+ isPaused: false,
106
+ isFinished: false,
107
+ isWhiteActive: false,
108
+ isBlackActive: false,
109
+ hasTimeout: false,
110
+ hasTimeOdds: false,
111
+ });
112
+ });
113
+
114
+ it("should detect time odds", () => {
115
+ const { result } = renderHook(() =>
116
+ useChessClock({
117
+ time: "5+0",
118
+ whiteTime: 300,
119
+ blackTime: 180,
120
+ }),
121
+ );
122
+
123
+ expect(result.current.info.hasTimeOdds).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe("methods.start", () => {
128
+ it("should start the clock from manual mode", () => {
129
+ const { result } = renderHook(() =>
130
+ useChessClock({
131
+ time: "5+0",
132
+ clockStart: "manual",
133
+ }),
134
+ );
135
+
136
+ act(() => {
137
+ result.current.methods.start();
138
+ });
139
+
140
+ expect(result.current.status).toBe("running");
141
+ expect(result.current.activePlayer).toBe("white");
142
+ expect(result.current.info.isRunning).toBe(true);
143
+ });
144
+
145
+ it("should not start countdown in delayed mode until after black's first move", () => {
146
+ const { result } = renderHook(() =>
147
+ useChessClock({
148
+ time: "5+0",
149
+ clockStart: "delayed",
150
+ }),
151
+ );
152
+
153
+ expect(result.current.status).toBe("delayed");
154
+
155
+ act(() => {
156
+ result.current.methods.start();
157
+ });
158
+
159
+ // In delayed mode, START doesn't change the status
160
+ expect(result.current.status).toBe("delayed");
161
+ expect(result.current.activePlayer).toBeNull();
162
+ expect(result.current.info.isRunning).toBe(false);
163
+
164
+ // First switch (white moves)
165
+ act(() => {
166
+ result.current.methods.switch();
167
+ });
168
+
169
+ // Still delayed, black is now active
170
+ expect(result.current.status).toBe("delayed");
171
+ expect(result.current.activePlayer).toBe("black");
172
+
173
+ // Second switch (black moves) - clock starts running
174
+ act(() => {
175
+ result.current.methods.switch();
176
+ });
177
+
178
+ // Now the clock is running
179
+ expect(result.current.status).toBe("running");
180
+ expect(result.current.activePlayer).toBe("white");
181
+ expect(result.current.info.isRunning).toBe(true);
182
+ });
183
+
184
+ it("should set active player to white if null", () => {
185
+ const { result } = renderHook(() =>
186
+ useChessClock({
187
+ time: "5+0",
188
+ clockStart: "manual",
189
+ }),
190
+ );
191
+
192
+ act(() => {
193
+ result.current.methods.start();
194
+ });
195
+
196
+ expect(result.current.activePlayer).toBe("white");
197
+ });
198
+ });
199
+
200
+ describe("methods.pause", () => {
201
+ it("should pause the clock", () => {
202
+ const { result } = renderHook(() =>
203
+ useChessClock({
204
+ time: "5+0",
205
+ clockStart: "immediate",
206
+ }),
207
+ );
208
+
209
+ expect(result.current.status).toBe("running");
210
+
211
+ act(() => {
212
+ result.current.methods.pause();
213
+ });
214
+
215
+ expect(result.current.status).toBe("paused");
216
+ expect(result.current.info.isPaused).toBe(true);
217
+ });
218
+
219
+ it("should not pause if not running", () => {
220
+ const { result } = renderHook(() =>
221
+ useChessClock({ time: "5+0", clockStart: "manual" }),
222
+ );
223
+
224
+ act(() => {
225
+ result.current.methods.pause();
226
+ });
227
+
228
+ expect(result.current.status).toBe("idle");
229
+ });
230
+
231
+ it("should maintain correct time when paused", () => {
232
+ const { result } = renderHook(() =>
233
+ useChessClock({
234
+ time: "5+0",
235
+ clockStart: "immediate",
236
+ }),
237
+ );
238
+
239
+ const initialTime = result.current.times.white;
240
+ expect(initialTime).toBe(300_000);
241
+
242
+ // Advance time by 150ms
243
+ mockNow += 150;
244
+ act(() => {
245
+ jest.advanceTimersByTime(150);
246
+ });
247
+
248
+ act(() => {
249
+ result.current.methods.pause();
250
+ });
251
+
252
+ const pausedTime = result.current.times.white;
253
+ // Time should have decreased by exactly 150ms
254
+ expect(pausedTime).toBe(300_000 - 150);
255
+
256
+ // Resume and check time continues from paused time (not from move start)
257
+ act(() => {
258
+ result.current.methods.resume();
259
+ });
260
+
261
+ const resumedTime = result.current.times.white;
262
+ // Time should be the same as paused time (no additional time passed)
263
+ expect(resumedTime).toBe(pausedTime);
264
+ });
265
+ });
266
+
267
+ describe("methods.resume", () => {
268
+ it("should resume paused clock", () => {
269
+ const { result } = renderHook(() =>
270
+ useChessClock({
271
+ time: "5+0",
272
+ clockStart: "immediate",
273
+ }),
274
+ );
275
+
276
+ act(() => {
277
+ result.current.methods.pause();
278
+ });
279
+
280
+ expect(result.current.status).toBe("paused");
281
+
282
+ act(() => {
283
+ result.current.methods.resume();
284
+ });
285
+
286
+ expect(result.current.status).toBe("running");
287
+ });
288
+
289
+ it("should not resume if not paused", () => {
290
+ const { result } = renderHook(() =>
291
+ useChessClock({ time: "5+0", clockStart: "manual" }),
292
+ );
293
+
294
+ act(() => {
295
+ result.current.methods.resume();
296
+ });
297
+
298
+ expect(result.current.status).toBe("idle");
299
+ });
300
+ });
301
+
302
+ describe("methods.switch", () => {
303
+ it("should switch active player", () => {
304
+ const { result } = renderHook(() =>
305
+ useChessClock({
306
+ time: "5+0",
307
+ clockStart: "immediate",
308
+ }),
309
+ );
310
+
311
+ expect(result.current.activePlayer).toBe("white");
312
+
313
+ act(() => {
314
+ result.current.methods.switch();
315
+ });
316
+
317
+ expect(result.current.activePlayer).toBe("black");
318
+ });
319
+
320
+ it("should switch from delayed to running after two switches", () => {
321
+ const { result } = renderHook(() =>
322
+ useChessClock({
323
+ time: "5+0",
324
+ clockStart: "delayed",
325
+ }),
326
+ );
327
+
328
+ expect(result.current.status).toBe("delayed");
329
+
330
+ act(() => {
331
+ result.current.methods.switch();
332
+ });
333
+
334
+ // After first switch, still in delayed mode, black is active
335
+ expect(result.current.status).toBe("delayed");
336
+ expect(result.current.activePlayer).toBe("black");
337
+
338
+ // Advance time past debounce period
339
+ mockNow += 150;
340
+ act(() => {
341
+ jest.advanceTimersByTime(150);
342
+ });
343
+
344
+ act(() => {
345
+ result.current.methods.switch();
346
+ });
347
+
348
+ // After second switch, clock starts running, white is active
349
+ expect(result.current.status).toBe("running");
350
+ expect(result.current.activePlayer).toBe("white");
351
+ });
352
+
353
+ it("should apply Fischer increment", () => {
354
+ const onSwitch = jest.fn();
355
+ const { result } = renderHook(() =>
356
+ useChessClock({
357
+ time: "5+3",
358
+ timingMethod: "fischer",
359
+ clockStart: "immediate",
360
+ onSwitch,
361
+ }),
362
+ );
363
+
364
+ const initialWhiteTime = result.current.times.white;
365
+ expect(initialWhiteTime).toBe(300_000);
366
+
367
+ act(() => {
368
+ result.current.methods.switch();
369
+ });
370
+
371
+ // No real time elapsed (mockNow unchanged), so white gets full increment
372
+ // 300_000 - 0 (time spent) + 3_000 (increment) = 303_000
373
+ expect(result.current.times.white).toBe(303_000);
374
+ expect(onSwitch).toHaveBeenCalledWith("black");
375
+ });
376
+ });
377
+
378
+ describe("methods.reset", () => {
379
+ it("should reset to initial times", () => {
380
+ const { result } = renderHook(() =>
381
+ useChessClock({
382
+ time: "5+0",
383
+ clockStart: "immediate",
384
+ }),
385
+ );
386
+
387
+ // Manually set a different time (simulating time passing)
388
+ act(() => {
389
+ result.current.methods.setTime("white", 250_000);
390
+ });
391
+
392
+ expect(result.current.times.white).toBe(250_000);
393
+
394
+ act(() => {
395
+ result.current.methods.reset();
396
+ });
397
+
398
+ expect(result.current.times.white).toBe(300_000);
399
+ expect(result.current.times.black).toBe(300_000);
400
+ });
401
+
402
+ it("should reset to new time control", () => {
403
+ const { result } = renderHook(() => useChessClock({ time: "5+0" }));
404
+
405
+ act(() => {
406
+ result.current.methods.reset("10+5");
407
+ });
408
+
409
+ expect(result.current.times.white).toBe(600_000); // 10 minutes
410
+ expect(result.current.times.black).toBe(600_000);
411
+ });
412
+
413
+ it("should reset status and active player", () => {
414
+ const { result } = renderHook(() =>
415
+ useChessClock({
416
+ time: "5+0",
417
+ clockStart: "immediate",
418
+ }),
419
+ );
420
+
421
+ act(() => {
422
+ result.current.methods.pause();
423
+ });
424
+
425
+ expect(result.current.status).toBe("paused");
426
+
427
+ act(() => {
428
+ result.current.methods.reset();
429
+ });
430
+
431
+ // Reset restores to initial clockStart mode (immediate = running)
432
+ expect(result.current.status).toBe("running");
433
+ expect(result.current.activePlayer).toBe("white");
434
+ expect(result.current.timeout).toBeNull();
435
+ });
436
+ });
437
+
438
+ describe("methods.addTime", () => {
439
+ it("should add time to a player", () => {
440
+ const { result } = renderHook(() => useChessClock({ time: "5+0" }));
441
+
442
+ act(() => {
443
+ result.current.methods.addTime("white", 30_000); // Add 30 seconds
444
+ });
445
+
446
+ expect(result.current.times.white).toBe(330_000);
447
+ });
448
+
449
+ it("should add time to black player", () => {
450
+ const { result } = renderHook(() => useChessClock({ time: "5+0" }));
451
+
452
+ act(() => {
453
+ result.current.methods.addTime("black", 60_000); // Add 1 minute
454
+ });
455
+
456
+ expect(result.current.times.black).toBe(360_000);
457
+ });
458
+ });
459
+
460
+ describe("methods.setTime", () => {
461
+ it("should set time for a player", () => {
462
+ const { result } = renderHook(() => useChessClock({ time: "5+0" }));
463
+
464
+ act(() => {
465
+ result.current.methods.setTime("white", 120_000);
466
+ });
467
+
468
+ expect(result.current.times.white).toBe(120_000);
469
+ });
470
+
471
+ it("should clamp negative time to zero", () => {
472
+ const { result } = renderHook(() => useChessClock({ time: "5+0" }));
473
+
474
+ act(() => {
475
+ result.current.methods.setTime("white", -10_000);
476
+ });
477
+
478
+ expect(result.current.times.white).toBe(0);
479
+ });
480
+ });
481
+
482
+ describe("callbacks", () => {
483
+ it("should call onSwitch when player switches", () => {
484
+ const onSwitch = jest.fn();
485
+ const { result } = renderHook(() =>
486
+ useChessClock({
487
+ time: "5+0",
488
+ clockStart: "immediate",
489
+ onSwitch,
490
+ }),
491
+ );
492
+
493
+ act(() => {
494
+ result.current.methods.switch();
495
+ });
496
+
497
+ expect(onSwitch).toHaveBeenCalledWith("black");
498
+ });
499
+ });
500
+
501
+ describe("debounce", () => {
502
+ it("should prevent rapid switches within debounce window", () => {
503
+ const onSwitch = jest.fn();
504
+ const { result } = renderHook(() =>
505
+ useChessClock({
506
+ time: "5+0",
507
+ clockStart: "immediate",
508
+ onSwitch,
509
+ }),
510
+ );
511
+
512
+ // First switch should work
513
+ act(() => {
514
+ result.current.methods.switch();
515
+ });
516
+
517
+ expect(result.current.activePlayer).toBe("black");
518
+ expect(onSwitch).toHaveBeenCalledTimes(1);
519
+
520
+ // Immediate switch should be prevented (debounce window: 100ms)
521
+ expect(result.current.activePlayer).not.toBe("white");
522
+ });
523
+ });
524
+
525
+ describe("options changes", () => {
526
+ it("should auto-reset when time control changes", () => {
527
+ const { result, rerender } = renderHook(
528
+ ({ time }) => useChessClock({ time }),
529
+ { initialProps: { time: "5+3" as string } },
530
+ );
531
+
532
+ expect(result.current.times.white).toBe(300_000);
533
+
534
+ rerender({ time: "10+5" });
535
+
536
+ expect(result.current.times.white).toBe(600_000);
537
+ expect(result.current.times.black).toBe(600_000);
538
+ });
539
+
540
+ it("should auto-reset when timingMethod changes", () => {
541
+ const { result, rerender } = renderHook(
542
+ ({ timingMethod }) => useChessClock({ time: "5+3", timingMethod }),
543
+ { initialProps: { timingMethod: "fischer" as string } },
544
+ );
545
+
546
+ expect(result.current.timingMethod).toBe("fischer");
547
+
548
+ rerender({ timingMethod: "delay" });
549
+
550
+ expect(result.current.timingMethod).toBe("delay");
551
+ });
552
+
553
+ it("should auto-reset when clockStart changes", () => {
554
+ const { result, rerender } = renderHook(
555
+ ({ clockStart }) => useChessClock({ time: "5+0", clockStart }),
556
+ { initialProps: { clockStart: "delayed" as string } },
557
+ );
558
+
559
+ expect(result.current.status).toBe("delayed");
560
+
561
+ rerender({ clockStart: "immediate" });
562
+
563
+ expect(result.current.status).toBe("running");
564
+ expect(result.current.activePlayer).toBe("white");
565
+ });
566
+
567
+ it("should auto-reset when whiteTime/blackTime changes (time odds)", () => {
568
+ const { result, rerender } = renderHook(
569
+ ({ whiteTime, blackTime }) =>
570
+ useChessClock({ time: "5+0", whiteTime, blackTime }),
571
+ { initialProps: { whiteTime: 300, blackTime: 300 } },
572
+ );
573
+
574
+ expect(result.current.times.white).toBe(300_000);
575
+ expect(result.current.times.black).toBe(300_000);
576
+
577
+ rerender({ whiteTime: 180, blackTime: 300 });
578
+
579
+ expect(result.current.times.white).toBe(180_000);
580
+ expect(result.current.times.black).toBe(300_000);
581
+ });
582
+
583
+ it("should NOT reset when only callbacks change", () => {
584
+ const onTimeout1 = jest.fn();
585
+ const onTimeout2 = jest.fn();
586
+
587
+ const { result, rerender } = renderHook(
588
+ ({ onTimeout }) => useChessClock({ time: "5+3", onTimeout }),
589
+ { initialProps: { onTimeout: onTimeout1 } },
590
+ );
591
+
592
+ const initialTimes = result.current.times;
593
+
594
+ rerender({ onTimeout: onTimeout2 });
595
+
596
+ // Times should be unchanged
597
+ expect(result.current.times).toEqual(initialTimes);
598
+ });
599
+
600
+ it("should NOT reset when onSwitch callback changes", () => {
601
+ const onSwitch1 = jest.fn();
602
+ const onSwitch2 = jest.fn();
603
+
604
+ const { result, rerender } = renderHook(
605
+ ({ onSwitch }) =>
606
+ useChessClock({ time: "5+3", clockStart: "immediate", onSwitch }),
607
+ { initialProps: { onSwitch: onSwitch1 } },
608
+ );
609
+
610
+ const initialTimes = result.current.times;
611
+
612
+ rerender({ onSwitch: onSwitch2 });
613
+
614
+ // Times should be unchanged (with mocked Date.now, no real time passes)
615
+ expect(result.current.times).toEqual(initialTimes);
616
+
617
+ // New callback should be used
618
+ act(() => {
619
+ result.current.methods.switch();
620
+ });
621
+
622
+ expect(onSwitch2).toHaveBeenCalledWith("black");
623
+ expect(onSwitch1).not.toHaveBeenCalled();
624
+ });
625
+
626
+ it("should NOT reset when onTimeUpdate callback changes", () => {
627
+ const onTimeUpdate1 = jest.fn();
628
+ const onTimeUpdate2 = jest.fn();
629
+
630
+ const { result, rerender } = renderHook(
631
+ ({ onTimeUpdate }) => useChessClock({ time: "5+3", onTimeUpdate }),
632
+ { initialProps: { onTimeUpdate: onTimeUpdate1 } },
633
+ );
634
+
635
+ const initialTimes = result.current.times;
636
+
637
+ rerender({ onTimeUpdate: onTimeUpdate2 });
638
+
639
+ // Times should be unchanged
640
+ expect(result.current.times).toEqual(initialTimes);
641
+ });
642
+
643
+ it("should reset status when time control changes during game", () => {
644
+ const { result, rerender } = renderHook(
645
+ ({ time }) => useChessClock({ time, clockStart: "immediate" }),
646
+ { initialProps: { time: "5+3" as string } },
647
+ );
648
+
649
+ // Start the clock and make a switch
650
+ act(() => {
651
+ result.current.methods.switch();
652
+ });
653
+
654
+ expect(result.current.status).toBe("running");
655
+ expect(result.current.activePlayer).toBe("black");
656
+
657
+ // Change time control - should reset to initial state
658
+ rerender({ time: "10+5" });
659
+
660
+ expect(result.current.status).toBe("running"); // immediate mode
661
+ expect(result.current.activePlayer).toBe("white"); // reset to white
662
+ expect(result.current.times.white).toBe(600_000);
663
+ });
664
+
665
+ it("should handle multiple option changes", () => {
666
+ const { result, rerender } = renderHook(
667
+ ({ time, timingMethod }) => useChessClock({ time, timingMethod }),
668
+ {
669
+ initialProps: {
670
+ time: "5+3" as string,
671
+ timingMethod: "fischer" as string,
672
+ },
673
+ },
674
+ );
675
+
676
+ expect(result.current.times.white).toBe(300_000);
677
+ expect(result.current.timingMethod).toBe("fischer");
678
+
679
+ rerender({ time: "10+5", timingMethod: "delay" });
680
+
681
+ expect(result.current.times.white).toBe(600_000);
682
+ expect(result.current.timingMethod).toBe("delay");
683
+ });
684
+ });
685
+ });
686
+
687
+ describe("useOptionalChessClock", () => {
688
+ beforeEach(() => {
689
+ jest.useFakeTimers();
690
+ mockNow = 1_000_000;
691
+ jest.spyOn(Date, "now").mockImplementation(() => mockNow);
692
+ });
693
+
694
+ afterEach(() => {
695
+ jest.useRealTimers();
696
+ jest.restoreAllMocks();
697
+ });
698
+
699
+ it("should return null when options is undefined", () => {
700
+ const { result } = renderHook(() => useOptionalChessClock(undefined));
701
+
702
+ expect(result.current).toBeNull();
703
+ });
704
+
705
+ it("should return clock state when options is provided", () => {
706
+ const { result } = renderHook(() => useOptionalChessClock({ time: "5+3" }));
707
+
708
+ expect(result.current).not.toBeNull();
709
+ expect(result.current?.times.white).toBe(300_000);
710
+ expect(result.current?.times.black).toBe(300_000);
711
+ });
712
+
713
+ it("should work the same as useChessClock when enabled", () => {
714
+ const config = { time: "10+5" as const };
715
+
716
+ const { result: optionalResult } = renderHook(() =>
717
+ useOptionalChessClock(config),
718
+ );
719
+ const { result: requiredResult } = renderHook(() => useChessClock(config));
720
+
721
+ expect(optionalResult.current?.times).toEqual(requiredResult.current.times);
722
+ expect(optionalResult.current?.status).toEqual(
723
+ requiredResult.current.status,
724
+ );
725
+ expect(optionalResult.current?.activePlayer).toEqual(
726
+ requiredResult.current.activePlayer,
727
+ );
728
+ });
729
+ });
730
+
731
+ describe("useChessClock - multi-period time controls", () => {
732
+ beforeEach(() => {
733
+ jest.useFakeTimers();
734
+ mockNow = 1_000_000;
735
+ jest.spyOn(Date, "now").mockImplementation(() => mockNow);
736
+ });
737
+
738
+ afterEach(() => {
739
+ jest.useRealTimers();
740
+ jest.restoreAllMocks();
741
+ });
742
+
743
+ describe("initialization", () => {
744
+ it("should initialize with multi-period state from array config", () => {
745
+ const { result } = renderHook(() =>
746
+ useChessClock({
747
+ time: [
748
+ { baseTime: 5400, increment: 30, moves: 40 },
749
+ { baseTime: 1800, increment: 30 },
750
+ ],
751
+ }),
752
+ );
753
+
754
+ expect(result.current.totalPeriods).toBe(2);
755
+ expect(result.current.currentPeriodIndex).toEqual({ white: 0, black: 0 });
756
+ expect(result.current.periodMoves).toEqual({ white: 0, black: 0 });
757
+ expect(result.current.currentPeriod.white).toEqual({
758
+ baseTime: 5400,
759
+ increment: 30,
760
+ moves: 40,
761
+ });
762
+ expect(result.current.currentPeriod.black).toEqual({
763
+ baseTime: 5400,
764
+ increment: 30,
765
+ moves: 40,
766
+ });
767
+ });
768
+
769
+ it("should expose total periods correctly", () => {
770
+ const { result } = renderHook(() =>
771
+ useChessClock({
772
+ time: [
773
+ { baseTime: 5400, increment: 30, moves: 40 },
774
+ { baseTime: 3600, increment: 30, moves: 20 },
775
+ { baseTime: 900, increment: 30 },
776
+ ],
777
+ }),
778
+ );
779
+
780
+ expect(result.current.totalPeriods).toBe(3);
781
+ });
782
+
783
+ it("should initialize with single period when not multi-period", () => {
784
+ const { result } = renderHook(() => useChessClock({ time: "5+3" }));
785
+
786
+ expect(result.current.totalPeriods).toBe(1);
787
+ expect(result.current.currentPeriodIndex).toEqual({ white: 0, black: 0 });
788
+ expect(result.current.currentPeriod.white).toMatchObject({
789
+ baseTime: 300,
790
+ increment: 3,
791
+ });
792
+ // Note: delay may be present in the actual object
793
+ });
794
+
795
+ it("should initialize initial times from first period", () => {
796
+ const { result } = renderHook(() =>
797
+ useChessClock({
798
+ time: [
799
+ { baseTime: 5400, increment: 30, moves: 40 }, // 90 minutes
800
+ { baseTime: 1800, increment: 30 },
801
+ ],
802
+ }),
803
+ );
804
+
805
+ expect(result.current.times.white).toBe(5_400_000); // 90 minutes in ms
806
+ expect(result.current.times.black).toBe(5_400_000);
807
+ });
808
+ });
809
+
810
+ describe("period transitions", () => {
811
+ it("should track period moves for each player", () => {
812
+ const { result } = renderHook(() =>
813
+ useChessClock({
814
+ time: [
815
+ { baseTime: 300, increment: 5, moves: 3 }, // 5 min, 3 moves to advance
816
+ { baseTime: 180, increment: 3 },
817
+ ],
818
+ clockStart: "immediate",
819
+ }),
820
+ );
821
+
822
+ // Initial state
823
+ expect(result.current.periodMoves).toEqual({ white: 0, black: 0 });
824
+
825
+ // White's first move
826
+ act(() => {
827
+ result.current.methods.switch();
828
+ });
829
+
830
+ expect(result.current.periodMoves).toEqual({ white: 1, black: 0 });
831
+
832
+ // Black's first move
833
+ act(() => {
834
+ result.current.methods.switch();
835
+ });
836
+
837
+ expect(result.current.periodMoves).toEqual({ white: 1, black: 1 });
838
+
839
+ // White's second move
840
+ act(() => {
841
+ result.current.methods.switch();
842
+ });
843
+
844
+ expect(result.current.periodMoves).toEqual({ white: 2, black: 1 });
845
+ });
846
+
847
+ it("should advance player to next period after completing required moves", () => {
848
+ const { result } = renderHook(() =>
849
+ useChessClock({
850
+ time: [
851
+ { baseTime: 300, increment: 5, moves: 2 }, // 2 moves to advance
852
+ { baseTime: 180, increment: 3 },
853
+ ],
854
+ clockStart: "immediate",
855
+ }),
856
+ );
857
+
858
+ const initialWhiteTime = result.current.times.white;
859
+
860
+ // Make moves: white(1), black(1), white(2) -> white advances
861
+ act(() => {
862
+ result.current.methods.switch(); // white moves
863
+ });
864
+ expect(result.current.currentPeriodIndex.white).toBe(0);
865
+
866
+ act(() => {
867
+ result.current.methods.switch(); // black moves
868
+ });
869
+ expect(result.current.currentPeriodIndex.black).toBe(0);
870
+
871
+ act(() => {
872
+ result.current.methods.switch(); // white moves again (2nd move)
873
+ });
874
+
875
+ // White should advance to period 1
876
+ expect(result.current.currentPeriodIndex.white).toBe(1);
877
+ // Black should still be in period 0
878
+ expect(result.current.currentPeriodIndex.black).toBe(0);
879
+ // White's period moves should reset
880
+ expect(result.current.periodMoves.white).toBe(0);
881
+ // White should receive period 1's base time (180s = 180,000ms) plus increments from switches
882
+ expect(result.current.times.white).toBeGreaterThanOrEqual(
883
+ initialWhiteTime + 180_000,
884
+ );
885
+ expect(result.current.times.white).toBeLessThanOrEqual(
886
+ initialWhiteTime + 180_000 + 20_000, // Allow for increment variance
887
+ );
888
+ // White's current period should reflect new settings
889
+ expect(result.current.currentPeriod.white).toMatchObject({
890
+ baseTime: 180,
891
+ increment: 3,
892
+ });
893
+ });
894
+
895
+ it("should handle both players advancing simultaneously", () => {
896
+ const { result } = renderHook(() =>
897
+ useChessClock({
898
+ time: [
899
+ { baseTime: 300, increment: 5, moves: 2 },
900
+ { baseTime: 180, increment: 3 },
901
+ ],
902
+ clockStart: "immediate",
903
+ }),
904
+ );
905
+
906
+ // Make 2 moves each: both should advance to period 1
907
+ for (let i = 0; i < 4; i++) {
908
+ act(() => {
909
+ result.current.methods.switch();
910
+ });
911
+ }
912
+
913
+ expect(result.current.currentPeriodIndex).toEqual({ white: 1, black: 1 });
914
+ expect(result.current.periodMoves).toEqual({ white: 0, black: 0 });
915
+ });
916
+
917
+ it("should not advance from sudden death period", () => {
918
+ const { result } = renderHook(() =>
919
+ useChessClock({
920
+ time: [
921
+ { baseTime: 300, increment: 5, moves: 2 },
922
+ { baseTime: 180, increment: 3 }, // Sudden death
923
+ ],
924
+ clockStart: "immediate",
925
+ }),
926
+ );
927
+
928
+ // Advance to period 1
929
+ for (let i = 0; i < 4; i++) {
930
+ act(() => {
931
+ result.current.methods.switch();
932
+ });
933
+ }
934
+
935
+ expect(result.current.currentPeriodIndex).toEqual({ white: 1, black: 1 });
936
+
937
+ const whiteTimeAfterAdvance = result.current.times.white;
938
+
939
+ // Make more moves - should not advance further
940
+ for (let i = 0; i < 4; i++) {
941
+ act(() => {
942
+ result.current.methods.switch();
943
+ });
944
+ }
945
+
946
+ // Still in period 1
947
+ expect(result.current.currentPeriodIndex).toEqual({ white: 1, black: 1 });
948
+ // Period moves continue to increment
949
+ expect(result.current.periodMoves.white).toBeGreaterThan(0);
950
+ // No additional base time added
951
+ expect(result.current.times.white).not.toBeGreaterThan(
952
+ whiteTimeAfterAdvance + 10_000, // Allow for small timing variation
953
+ );
954
+ });
955
+ });
956
+
957
+ describe("reset with multi-period", () => {
958
+ it("should reset period state on reset", () => {
959
+ const { result } = renderHook(() =>
960
+ useChessClock({
961
+ time: [
962
+ { baseTime: 300, increment: 5, moves: 2 },
963
+ { baseTime: 180, increment: 3 },
964
+ ],
965
+ clockStart: "immediate",
966
+ }),
967
+ );
968
+
969
+ // Advance to period 1
970
+ for (let i = 0; i < 4; i++) {
971
+ act(() => {
972
+ result.current.methods.switch();
973
+ });
974
+ }
975
+
976
+ expect(result.current.currentPeriodIndex).toEqual({ white: 1, black: 1 });
977
+
978
+ // Reset
979
+ act(() => {
980
+ result.current.methods.reset();
981
+ });
982
+
983
+ // Period state should be reset
984
+ expect(result.current.currentPeriodIndex).toEqual({ white: 0, black: 0 });
985
+ expect(result.current.periodMoves).toEqual({ white: 0, black: 0 });
986
+ });
987
+
988
+ it("should reset to new multi-period time control", () => {
989
+ const { result } = renderHook(() =>
990
+ useChessClock({
991
+ time: [
992
+ { baseTime: 300, increment: 5, moves: 2 },
993
+ { baseTime: 180, increment: 3 },
994
+ ],
995
+ }),
996
+ );
997
+
998
+ expect(result.current.totalPeriods).toBe(2);
999
+
1000
+ // Reset to single period
1001
+ act(() => {
1002
+ result.current.methods.reset("10+5");
1003
+ });
1004
+
1005
+ expect(result.current.totalPeriods).toBe(1);
1006
+ expect(result.current.currentPeriodIndex).toEqual({ white: 0, black: 0 });
1007
+ });
1008
+ });
1009
+
1010
+ describe("three-period time control", () => {
1011
+ it("should handle three-period tournament time control", () => {
1012
+ const { result } = renderHook(() =>
1013
+ useChessClock({
1014
+ time: [
1015
+ { baseTime: 5400, increment: 30, moves: 40 }, // Period 1
1016
+ { baseTime: 3600, increment: 30, moves: 20 }, // Period 2
1017
+ { baseTime: 900, increment: 30 }, // Period 3 (sudden death)
1018
+ ],
1019
+ clockStart: "immediate",
1020
+ }),
1021
+ );
1022
+
1023
+ expect(result.current.totalPeriods).toBe(3);
1024
+
1025
+ // Simulate completing period 1 (40 moves each)
1026
+ // We'll do 4 moves to demonstrate the mechanics
1027
+ const initialWhiteTime = result.current.times.white;
1028
+
1029
+ act(() => {
1030
+ result.current.methods.switch(); // 1
1031
+ });
1032
+ act(() => {
1033
+ result.current.methods.switch(); // 2
1034
+ });
1035
+ act(() => {
1036
+ result.current.methods.switch(); // 3
1037
+ });
1038
+ act(() => {
1039
+ result.current.methods.switch(); // 4
1040
+ });
1041
+
1042
+ // After 4 moves (2 each), still in period 0
1043
+ expect(result.current.currentPeriodIndex.white).toBe(0);
1044
+ expect(result.current.periodMoves.white).toBe(2);
1045
+ // Time should have increased due to increments (30 seconds per white move = 2 moves)
1046
+ expect(result.current.times.white).toBeGreaterThan(initialWhiteTime);
1047
+ expect(result.current.times.white).toBeLessThanOrEqual(
1048
+ initialWhiteTime + 70_000, // Allow some variance
1049
+ );
1050
+ });
1051
+ });
1052
+
1053
+ describe("independent player advancement", () => {
1054
+ it("should handle white advancing while black stays in earlier period", () => {
1055
+ const { result } = renderHook(() =>
1056
+ useChessClock({
1057
+ time: [
1058
+ { baseTime: 300, increment: 5, moves: 2 },
1059
+ { baseTime: 180, increment: 3, moves: 2 },
1060
+ { baseTime: 60, increment: 2 },
1061
+ ],
1062
+ clockStart: "immediate",
1063
+ }),
1064
+ );
1065
+
1066
+ // Make moves to demonstrate independent advancement
1067
+ for (let i = 0; i < 8; i++) {
1068
+ act(() => {
1069
+ result.current.methods.switch();
1070
+ });
1071
+ }
1072
+
1073
+ // After 8 moves (4 each), both players advance twice:
1074
+ expect(result.current.currentPeriodIndex.white).toBe(2);
1075
+ expect(result.current.currentPeriodIndex.black).toBe(2);
1076
+ expect(result.current.periodMoves.white).toBe(0); // Reset after second advancement
1077
+ expect(result.current.periodMoves.black).toBe(0);
1078
+ });
1079
+ });
1080
+ });