@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.
- package/CHANGELOG.md +7 -0
- package/README.md +697 -0
- package/dist/index.cjs +1014 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +528 -0
- package/dist/index.d.ts +528 -0
- package/dist/index.js +969 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/src/components/ChessClock/ChessClock.stories.tsx +782 -0
- package/src/components/ChessClock/index.ts +44 -0
- package/src/components/ChessClock/parts/Display.tsx +69 -0
- package/src/components/ChessClock/parts/PlayPause.tsx +190 -0
- package/src/components/ChessClock/parts/Reset.tsx +90 -0
- package/src/components/ChessClock/parts/Root.tsx +37 -0
- package/src/components/ChessClock/parts/Switch.tsx +84 -0
- package/src/components/ChessClock/parts/__tests__/Display.test.tsx +149 -0
- package/src/components/ChessClock/parts/__tests__/PlayPause.test.tsx +411 -0
- package/src/components/ChessClock/parts/__tests__/Reset.test.tsx +160 -0
- package/src/components/ChessClock/parts/__tests__/Root.test.tsx +49 -0
- package/src/components/ChessClock/parts/__tests__/Switch.test.tsx +204 -0
- package/src/hooks/__tests__/clockReducer.test.ts +985 -0
- package/src/hooks/__tests__/useChessClock.test.tsx +1080 -0
- package/src/hooks/clockReducer.ts +379 -0
- package/src/hooks/useChessClock.ts +406 -0
- package/src/hooks/useChessClockContext.ts +35 -0
- package/src/index.ts +65 -0
- package/src/types.ts +217 -0
- package/src/utils/__tests__/calculateSwitchTime.test.ts +150 -0
- package/src/utils/__tests__/formatTime.test.ts +83 -0
- package/src/utils/__tests__/timeControl.test.ts +414 -0
- package/src/utils/__tests__/timingMethods.test.ts +170 -0
- package/src/utils/calculateSwitchTime.ts +37 -0
- package/src/utils/formatTime.ts +59 -0
- package/src/utils/presets.ts +47 -0
- package/src/utils/timeControl.ts +205 -0
- package/src/utils/timingMethods.ts +103 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseTimeControlString,
|
|
3
|
+
normalizeTimeControl,
|
|
4
|
+
parseTimeControlConfig,
|
|
5
|
+
getInitialTimes,
|
|
6
|
+
parseMultiPeriodTimeControl,
|
|
7
|
+
} from "../timeControl";
|
|
8
|
+
|
|
9
|
+
describe("parseTimeControlString", () => {
|
|
10
|
+
it("should parse simple time control (no increment)", () => {
|
|
11
|
+
const result = parseTimeControlString("10");
|
|
12
|
+
expect(result).toEqual({
|
|
13
|
+
baseTime: 600, // 10 minutes in seconds
|
|
14
|
+
increment: 0,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should parse time control with increment", () => {
|
|
19
|
+
const result = parseTimeControlString("5+3");
|
|
20
|
+
expect(result).toEqual({
|
|
21
|
+
baseTime: 300,
|
|
22
|
+
increment: 3,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should parse various time controls", () => {
|
|
27
|
+
expect(parseTimeControlString("1+0")).toEqual({
|
|
28
|
+
baseTime: 60,
|
|
29
|
+
increment: 0,
|
|
30
|
+
});
|
|
31
|
+
expect(parseTimeControlString("1+1")).toEqual({
|
|
32
|
+
baseTime: 60,
|
|
33
|
+
increment: 1,
|
|
34
|
+
});
|
|
35
|
+
expect(parseTimeControlString("3+2")).toEqual({
|
|
36
|
+
baseTime: 180,
|
|
37
|
+
increment: 2,
|
|
38
|
+
});
|
|
39
|
+
expect(parseTimeControlString("10+5")).toEqual({
|
|
40
|
+
baseTime: 600,
|
|
41
|
+
increment: 5,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should throw error for invalid format", () => {
|
|
46
|
+
// @ts-expect-error - parseTimeControlString throws for invalid input
|
|
47
|
+
expect(() => parseTimeControlString("invalid")).toThrow(
|
|
48
|
+
"Invalid time control",
|
|
49
|
+
);
|
|
50
|
+
// @ts-expect-error - parseTimeControlString throws for invalid input
|
|
51
|
+
expect(() => parseTimeControlString("5-3")).toThrow("Invalid time control");
|
|
52
|
+
// @ts-expect-error - parseTimeControlString throws for invalid input
|
|
53
|
+
expect(() => parseTimeControlString("")).toThrow("Invalid time control");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("normalizeTimeControl", () => {
|
|
58
|
+
it("should normalize string time control to milliseconds", () => {
|
|
59
|
+
const result = normalizeTimeControl("5+3", "fischer", "delayed");
|
|
60
|
+
expect(result).toEqual({
|
|
61
|
+
baseTime: 300_000, // 5 minutes in ms
|
|
62
|
+
increment: 3_000, // 3 seconds in ms
|
|
63
|
+
delay: 0,
|
|
64
|
+
timingMethod: "fischer",
|
|
65
|
+
clockStart: "delayed",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should normalize object time control to milliseconds", () => {
|
|
70
|
+
const result = normalizeTimeControl(
|
|
71
|
+
{
|
|
72
|
+
baseTime: 300, // 5 minutes
|
|
73
|
+
increment: 3,
|
|
74
|
+
},
|
|
75
|
+
"fischer",
|
|
76
|
+
"delayed",
|
|
77
|
+
);
|
|
78
|
+
expect(result).toEqual({
|
|
79
|
+
baseTime: 300_000,
|
|
80
|
+
increment: 3_000,
|
|
81
|
+
delay: 0,
|
|
82
|
+
timingMethod: "fischer",
|
|
83
|
+
clockStart: "delayed",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should include delay if specified", () => {
|
|
88
|
+
const result = normalizeTimeControl(
|
|
89
|
+
{
|
|
90
|
+
baseTime: 300,
|
|
91
|
+
increment: 0,
|
|
92
|
+
delay: 5, // 5 second delay
|
|
93
|
+
},
|
|
94
|
+
"fischer",
|
|
95
|
+
"delayed",
|
|
96
|
+
);
|
|
97
|
+
expect(result.delay).toBe(5_000); // 5 seconds in ms
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("parseTimeControlConfig", () => {
|
|
102
|
+
it("should parse config with string time control", () => {
|
|
103
|
+
const result = parseTimeControlConfig({
|
|
104
|
+
time: "5+3",
|
|
105
|
+
});
|
|
106
|
+
expect(result.baseTime).toBe(300_000);
|
|
107
|
+
expect(result.increment).toBe(3_000);
|
|
108
|
+
expect(result.timingMethod).toBe("fischer");
|
|
109
|
+
expect(result.clockStart).toBe("delayed");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should parse config with timing method", () => {
|
|
113
|
+
const result = parseTimeControlConfig({
|
|
114
|
+
time: "5+0",
|
|
115
|
+
timingMethod: "delay",
|
|
116
|
+
});
|
|
117
|
+
expect(result.timingMethod).toBe("delay");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should parse config with clock start mode", () => {
|
|
121
|
+
const result = parseTimeControlConfig({
|
|
122
|
+
time: "5+0",
|
|
123
|
+
clockStart: "immediate",
|
|
124
|
+
});
|
|
125
|
+
expect(result.clockStart).toBe("immediate");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should parse config with time odds", () => {
|
|
129
|
+
const result = parseTimeControlConfig({
|
|
130
|
+
time: "5+0",
|
|
131
|
+
whiteTime: 300, // 5 minutes
|
|
132
|
+
blackTime: 240, // 4 minutes
|
|
133
|
+
});
|
|
134
|
+
expect(result.whiteTimeOverride).toBe(300_000);
|
|
135
|
+
expect(result.blackTimeOverride).toBe(240_000);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should parse config with all options", () => {
|
|
139
|
+
const result = parseTimeControlConfig({
|
|
140
|
+
time: "10+5",
|
|
141
|
+
timingMethod: "bronstein",
|
|
142
|
+
clockStart: "manual",
|
|
143
|
+
whiteTime: 600,
|
|
144
|
+
blackTime: 600,
|
|
145
|
+
});
|
|
146
|
+
expect(result.baseTime).toBe(600_000);
|
|
147
|
+
expect(result.increment).toBe(5_000);
|
|
148
|
+
expect(result.timingMethod).toBe("bronstein");
|
|
149
|
+
expect(result.clockStart).toBe("manual");
|
|
150
|
+
expect(result.whiteTimeOverride).toBe(600_000);
|
|
151
|
+
expect(result.blackTimeOverride).toBe(600_000);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("getInitialTimes", () => {
|
|
156
|
+
it("should return base time for both players without overrides", () => {
|
|
157
|
+
const config = normalizeTimeControl("5+3", "fischer", "delayed");
|
|
158
|
+
const result = getInitialTimes(config);
|
|
159
|
+
expect(result).toEqual({
|
|
160
|
+
white: 300_000,
|
|
161
|
+
black: 300_000,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("should apply time odds when overrides are present", () => {
|
|
166
|
+
const config = parseTimeControlConfig({
|
|
167
|
+
time: "5+0",
|
|
168
|
+
whiteTime: 300, // 5 minutes
|
|
169
|
+
blackTime: 180, // 3 minutes
|
|
170
|
+
});
|
|
171
|
+
const result = getInitialTimes(config);
|
|
172
|
+
expect(result).toEqual({
|
|
173
|
+
white: 300_000,
|
|
174
|
+
black: 180_000,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should apply only white override", () => {
|
|
179
|
+
const config = parseTimeControlConfig({
|
|
180
|
+
time: "5+0",
|
|
181
|
+
whiteTime: 300,
|
|
182
|
+
});
|
|
183
|
+
const result = getInitialTimes(config);
|
|
184
|
+
expect(result).toEqual({
|
|
185
|
+
white: 300_000,
|
|
186
|
+
black: 300_000, // Falls back to base time
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("should apply only black override", () => {
|
|
191
|
+
const config = parseTimeControlConfig({
|
|
192
|
+
time: "5+0",
|
|
193
|
+
blackTime: 180,
|
|
194
|
+
});
|
|
195
|
+
const result = getInitialTimes(config);
|
|
196
|
+
expect(result).toEqual({
|
|
197
|
+
white: 300_000, // Falls back to base time
|
|
198
|
+
black: 180_000,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("parseMultiPeriodTimeControl", () => {
|
|
204
|
+
describe("valid formats", () => {
|
|
205
|
+
it("should parse simple two-period time control", () => {
|
|
206
|
+
const result = parseMultiPeriodTimeControl("40/90+30,sd/30+30");
|
|
207
|
+
expect(result).toEqual([
|
|
208
|
+
{ baseTime: 5400, increment: 30, moves: 40 },
|
|
209
|
+
{ baseTime: 1800, increment: 30 },
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should parse three-period time control", () => {
|
|
214
|
+
const result = parseMultiPeriodTimeControl("40/90+30,20/60+30,g/15+30");
|
|
215
|
+
expect(result).toEqual([
|
|
216
|
+
{ baseTime: 5400, increment: 30, moves: 40 },
|
|
217
|
+
{ baseTime: 3600, increment: 30, moves: 20 },
|
|
218
|
+
{ baseTime: 900, increment: 30 },
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should parse with SD prefix (uppercase)", () => {
|
|
223
|
+
const result = parseMultiPeriodTimeControl("40/90,SD/30");
|
|
224
|
+
expect(result).toMatchObject([
|
|
225
|
+
{ baseTime: 5400, moves: 40 },
|
|
226
|
+
{ baseTime: 1800 },
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("should parse with sd prefix (lowercase)", () => {
|
|
231
|
+
const result = parseMultiPeriodTimeControl("40/90,sd/30");
|
|
232
|
+
expect(result).toMatchObject([
|
|
233
|
+
{ baseTime: 5400, moves: 40 },
|
|
234
|
+
{ baseTime: 1800 },
|
|
235
|
+
]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should parse with G prefix", () => {
|
|
239
|
+
const result = parseMultiPeriodTimeControl("40/90,G/30");
|
|
240
|
+
expect(result).toMatchObject([
|
|
241
|
+
{ baseTime: 5400, moves: 40 },
|
|
242
|
+
{ baseTime: 1800 },
|
|
243
|
+
]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should parse with g prefix (lowercase)", () => {
|
|
247
|
+
const result = parseMultiPeriodTimeControl("40/90,g/30");
|
|
248
|
+
expect(result).toMatchObject([
|
|
249
|
+
{ baseTime: 5400, moves: 40 },
|
|
250
|
+
{ baseTime: 1800 },
|
|
251
|
+
]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("should handle whitespace around commas", () => {
|
|
255
|
+
const result = parseMultiPeriodTimeControl("40/90+30 , sd/30+30");
|
|
256
|
+
expect(result).toEqual([
|
|
257
|
+
{ baseTime: 5400, increment: 30, moves: 40 },
|
|
258
|
+
{ baseTime: 1800, increment: 30 },
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should parse period without increment", () => {
|
|
263
|
+
const result = parseMultiPeriodTimeControl("40/90,sd/30");
|
|
264
|
+
expect(result).toMatchObject([
|
|
265
|
+
{ baseTime: 5400, moves: 40 },
|
|
266
|
+
{ baseTime: 1800 },
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should parse period with decimal minutes", () => {
|
|
271
|
+
const result = parseMultiPeriodTimeControl("40/90.5,sd/30");
|
|
272
|
+
expect(result).toMatchObject([
|
|
273
|
+
{ baseTime: 5430, moves: 40 },
|
|
274
|
+
{ baseTime: 1800 },
|
|
275
|
+
]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("should parse decimal increment", () => {
|
|
279
|
+
const result = parseMultiPeriodTimeControl("40/90+2.5,sd/30");
|
|
280
|
+
expect(result).toMatchObject([
|
|
281
|
+
{ baseTime: 5400, increment: 3, moves: 40 }, // Note: 2.5 gets rounded to 3
|
|
282
|
+
{ baseTime: 1800 },
|
|
283
|
+
]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("should parse simple sudden death format without moves prefix", () => {
|
|
287
|
+
const result = parseMultiPeriodTimeControl("sd/30+10");
|
|
288
|
+
expect(result).toEqual([{ baseTime: 1800, increment: 10 }]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("should parse G/ format without moves prefix", () => {
|
|
292
|
+
const result = parseMultiPeriodTimeControl("G/30+10");
|
|
293
|
+
expect(result).toEqual([{ baseTime: 1800, increment: 10 }]);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("edge cases", () => {
|
|
298
|
+
it("should handle single period (sudden death only)", () => {
|
|
299
|
+
const result = parseMultiPeriodTimeControl("sd/30+10");
|
|
300
|
+
expect(result).toHaveLength(1);
|
|
301
|
+
expect(result[0]).toEqual({ baseTime: 1800, increment: 10 });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("should handle single period with moves", () => {
|
|
305
|
+
const result = parseMultiPeriodTimeControl("40/90+30");
|
|
306
|
+
expect(result).toHaveLength(1);
|
|
307
|
+
expect(result[0]).toEqual({ baseTime: 5400, increment: 30, moves: 40 });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should handle very large move counts", () => {
|
|
311
|
+
const result = parseMultiPeriodTimeControl("100/120,sd/30");
|
|
312
|
+
expect(result).toEqual([
|
|
313
|
+
{ baseTime: 7200, moves: 100 },
|
|
314
|
+
{ baseTime: 1800 },
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("should handle small move counts", () => {
|
|
319
|
+
const result = parseMultiPeriodTimeControl("5/10+3,sd/5");
|
|
320
|
+
expect(result).toEqual([
|
|
321
|
+
{ baseTime: 600, increment: 3, moves: 5 },
|
|
322
|
+
{ baseTime: 300 },
|
|
323
|
+
]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("should handle multiple consecutive sudden death markers", () => {
|
|
327
|
+
const result = parseMultiPeriodTimeControl("sd/30+10,sd/15+5");
|
|
328
|
+
expect(result).toEqual([
|
|
329
|
+
{ baseTime: 1800, increment: 10 },
|
|
330
|
+
{ baseTime: 900, increment: 5 },
|
|
331
|
+
]);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("error handling", () => {
|
|
336
|
+
it("should throw on empty string", () => {
|
|
337
|
+
expect(() => parseMultiPeriodTimeControl("")).toThrow();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("should throw on invalid period format", () => {
|
|
341
|
+
expect(() => parseMultiPeriodTimeControl("invalid,sd/30")).toThrow(
|
|
342
|
+
"Invalid period format",
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("should throw on period with negative time", () => {
|
|
347
|
+
expect(() => parseMultiPeriodTimeControl("40/-10,sd/30")).toThrow();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("should throw on empty period between commas", () => {
|
|
351
|
+
expect(() => parseMultiPeriodTimeControl("40/90,,sd/30")).toThrow();
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("normalizeTimeControl with multi-period", () => {
|
|
357
|
+
it("should normalize multi-period array to milliseconds", () => {
|
|
358
|
+
const input = [
|
|
359
|
+
{ baseTime: 5400, increment: 30, moves: 40 },
|
|
360
|
+
{ baseTime: 1800, increment: 30 },
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
const result = normalizeTimeControl(input, "fischer", "delayed");
|
|
364
|
+
|
|
365
|
+
expect(result).toEqual({
|
|
366
|
+
baseTime: 5_400_000, // First period base time in ms
|
|
367
|
+
increment: 30_000, // First period increment in ms
|
|
368
|
+
delay: 0,
|
|
369
|
+
timingMethod: "fischer",
|
|
370
|
+
clockStart: "delayed",
|
|
371
|
+
periods: [
|
|
372
|
+
{ baseTime: 5_400_000, increment: 30_000, moves: 40 },
|
|
373
|
+
{ baseTime: 1_800_000, increment: 30_000 },
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("should preserve periods in normalized output with ms values", () => {
|
|
379
|
+
const input = [
|
|
380
|
+
{ baseTime: 5400, increment: 30, moves: 40 },
|
|
381
|
+
{ baseTime: 3600, increment: 30, moves: 20 },
|
|
382
|
+
{ baseTime: 900, increment: 30 },
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
const result = normalizeTimeControl(input, "fischer", "delayed");
|
|
386
|
+
|
|
387
|
+
expect(result.periods).toEqual([
|
|
388
|
+
{ baseTime: 5_400_000, increment: 30_000, moves: 40 },
|
|
389
|
+
{ baseTime: 3_600_000, increment: 30_000, moves: 20 },
|
|
390
|
+
{ baseTime: 900_000, increment: 30_000 },
|
|
391
|
+
]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("should throw on empty array", () => {
|
|
395
|
+
expect(() => normalizeTimeControl([], "fischer", "delayed")).toThrow(
|
|
396
|
+
"Multi-period time control must have at least one period",
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("should handle single period array", () => {
|
|
401
|
+
const input = [{ baseTime: 300, increment: 3 }];
|
|
402
|
+
|
|
403
|
+
const result = normalizeTimeControl(input, "fischer", "delayed");
|
|
404
|
+
|
|
405
|
+
expect(result).toEqual({
|
|
406
|
+
baseTime: 300_000,
|
|
407
|
+
increment: 3_000,
|
|
408
|
+
delay: 0,
|
|
409
|
+
timingMethod: "fischer",
|
|
410
|
+
clockStart: "delayed",
|
|
411
|
+
periods: [{ baseTime: 300_000, increment: 3_000 }],
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyFischerIncrement,
|
|
3
|
+
applySimpleDelay,
|
|
4
|
+
applyBronsteinDelay,
|
|
5
|
+
calculateSwitchAdjustment,
|
|
6
|
+
getInitialActivePlayer,
|
|
7
|
+
getInitialStatus,
|
|
8
|
+
} from "../timingMethods";
|
|
9
|
+
|
|
10
|
+
describe("applyFischerIncrement", () => {
|
|
11
|
+
it("should add increment to current time", () => {
|
|
12
|
+
expect(applyFischerIncrement(300_000, 3_000)).toBe(303_000);
|
|
13
|
+
expect(applyFischerIncrement(60_000, 1_000)).toBe(61_000);
|
|
14
|
+
expect(applyFischerIncrement(120_000, 2_000)).toBe(122_000);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should handle zero increment", () => {
|
|
18
|
+
expect(applyFischerIncrement(300_000, 0)).toBe(300_000);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should handle low time values", () => {
|
|
22
|
+
expect(applyFischerIncrement(100, 3_000)).toBe(3_100);
|
|
23
|
+
expect(applyFischerIncrement(0, 3_000)).toBe(3_000);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("applySimpleDelay", () => {
|
|
28
|
+
it("should return 0 when time spent is within delay period", () => {
|
|
29
|
+
const delay = 5_000; // 5 seconds
|
|
30
|
+
expect(applySimpleDelay(1_000, delay)).toBe(0);
|
|
31
|
+
expect(applySimpleDelay(3_000, delay)).toBe(0);
|
|
32
|
+
expect(applySimpleDelay(5_000, delay)).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should return time spent minus delay when over delay period", () => {
|
|
36
|
+
const delay = 5_000; // 5 seconds
|
|
37
|
+
expect(applySimpleDelay(6_000, delay)).toBe(1_000);
|
|
38
|
+
expect(applySimpleDelay(10_000, delay)).toBe(5_000);
|
|
39
|
+
expect(applySimpleDelay(15_500, delay)).toBe(10_500);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should handle zero delay", () => {
|
|
43
|
+
expect(applySimpleDelay(5_000, 0)).toBe(5_000);
|
|
44
|
+
expect(applySimpleDelay(0, 0)).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("applyBronsteinDelay", () => {
|
|
49
|
+
it("should add back full time spent when within delay", () => {
|
|
50
|
+
const delay = 5_000; // 5 seconds
|
|
51
|
+
const currentTime = 300_000;
|
|
52
|
+
|
|
53
|
+
// Spent 3 seconds, add back 3 seconds
|
|
54
|
+
expect(applyBronsteinDelay(currentTime, 3_000, delay)).toBe(303_000);
|
|
55
|
+
|
|
56
|
+
// Spent exactly delay, add back delay
|
|
57
|
+
expect(applyBronsteinDelay(currentTime, 5_000, delay)).toBe(305_000);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should add back only delay amount when over delay", () => {
|
|
61
|
+
const delay = 5_000; // 5 seconds
|
|
62
|
+
const currentTime = 300_000;
|
|
63
|
+
|
|
64
|
+
// Spent 10 seconds, add back only 5 (delay amount)
|
|
65
|
+
expect(applyBronsteinDelay(currentTime, 10_000, delay)).toBe(305_000);
|
|
66
|
+
|
|
67
|
+
// Spent 15 seconds, add back only 5
|
|
68
|
+
expect(applyBronsteinDelay(currentTime, 15_000, delay)).toBe(305_000);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should handle zero delay", () => {
|
|
72
|
+
expect(applyBronsteinDelay(300_000, 5_000, 0)).toBe(300_000);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should handle low time spent", () => {
|
|
76
|
+
expect(applyBronsteinDelay(300_000, 500, 5_000)).toBe(300_500);
|
|
77
|
+
expect(applyBronsteinDelay(300_000, 0, 5_000)).toBe(300_000);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("calculateSwitchAdjustment", () => {
|
|
82
|
+
const baseConfig = {
|
|
83
|
+
baseTime: 300_000,
|
|
84
|
+
increment: 3_000,
|
|
85
|
+
delay: 5_000,
|
|
86
|
+
timingMethod: "fischer" as const,
|
|
87
|
+
clockStart: "delayed" as const,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
describe("Fischer timing method", () => {
|
|
91
|
+
it("should add increment after switch", () => {
|
|
92
|
+
const result = calculateSwitchAdjustment("fischer", 295_000, 5_000, {
|
|
93
|
+
...baseConfig,
|
|
94
|
+
timingMethod: "fischer",
|
|
95
|
+
});
|
|
96
|
+
expect(result).toBe(298_000); // 295000 + 3000
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should handle zero increment", () => {
|
|
100
|
+
const config = { ...baseConfig, increment: 0 };
|
|
101
|
+
const result = calculateSwitchAdjustment(
|
|
102
|
+
"fischer",
|
|
103
|
+
295_000,
|
|
104
|
+
5_000,
|
|
105
|
+
config,
|
|
106
|
+
);
|
|
107
|
+
expect(result).toBe(295_000); // No increment
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("Delay timing method", () => {
|
|
112
|
+
it("should not adjust time (handled in tick)", () => {
|
|
113
|
+
const result = calculateSwitchAdjustment("delay", 295_000, 5_000, {
|
|
114
|
+
...baseConfig,
|
|
115
|
+
timingMethod: "delay",
|
|
116
|
+
});
|
|
117
|
+
expect(result).toBe(295_000); // No change on switch
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("Bronstein timing method", () => {
|
|
122
|
+
it("should add back time spent up to delay", () => {
|
|
123
|
+
// Spent 3 seconds, delay is 5 seconds
|
|
124
|
+
const result = calculateSwitchAdjustment("bronstein", 297_000, 3_000, {
|
|
125
|
+
...baseConfig,
|
|
126
|
+
timingMethod: "bronstein",
|
|
127
|
+
delay: 5_000,
|
|
128
|
+
});
|
|
129
|
+
expect(result).toBe(300_000); // 297000 + 3000
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should add back full delay when over delay", () => {
|
|
133
|
+
// Spent 10 seconds, delay is 5 seconds
|
|
134
|
+
const result = calculateSwitchAdjustment("bronstein", 290_000, 10_000, {
|
|
135
|
+
...baseConfig,
|
|
136
|
+
timingMethod: "bronstein",
|
|
137
|
+
delay: 5_000,
|
|
138
|
+
});
|
|
139
|
+
expect(result).toBe(295_000); // 290000 + 5000
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("getInitialActivePlayer", () => {
|
|
145
|
+
it("should return white for immediate start", () => {
|
|
146
|
+
expect(getInitialActivePlayer("immediate")).toBe("white");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should return null for delayed start", () => {
|
|
150
|
+
expect(getInitialActivePlayer("delayed")).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should return null for manual start", () => {
|
|
154
|
+
expect(getInitialActivePlayer("manual")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("getInitialStatus", () => {
|
|
159
|
+
it("should return running for immediate start", () => {
|
|
160
|
+
expect(getInitialStatus("immediate")).toBe("running");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should return delayed for delayed start", () => {
|
|
164
|
+
expect(getInitialStatus("delayed")).toBe("delayed");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should return idle for manual start", () => {
|
|
168
|
+
expect(getInitialStatus("manual")).toBe("idle");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NormalizedTimeControl } from "../types";
|
|
2
|
+
import { calculateSwitchAdjustment } from "./timingMethods";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculate the new time for a player after their move ends.
|
|
6
|
+
*
|
|
7
|
+
* This combines:
|
|
8
|
+
* 1. Delay method logic (time doesn't decrement during delay period)
|
|
9
|
+
* 2. Timing method adjustments (Fischer increment, Bronstein)
|
|
10
|
+
*
|
|
11
|
+
* @param currentTime - Current time in milliseconds for the player
|
|
12
|
+
* @param timeSpent - Time spent on the move in milliseconds
|
|
13
|
+
* @param config - Normalized time control configuration
|
|
14
|
+
* @returns New time in milliseconds for the player
|
|
15
|
+
*/
|
|
16
|
+
export function calculateSwitchTime(
|
|
17
|
+
currentTime: number,
|
|
18
|
+
timeSpent: number,
|
|
19
|
+
config: NormalizedTimeControl,
|
|
20
|
+
): number {
|
|
21
|
+
// Apply delay method logic: reduce effective elapsed by delay amount
|
|
22
|
+
let effectiveElapsed = timeSpent;
|
|
23
|
+
if (config.timingMethod === "delay") {
|
|
24
|
+
effectiveElapsed = Math.max(0, timeSpent - config.delay);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Decrement time, ensuring it doesn't go below zero
|
|
28
|
+
const newTime = Math.max(0, currentTime - effectiveElapsed);
|
|
29
|
+
|
|
30
|
+
// Apply timing method adjustments (Fischer increment, Bronstein)
|
|
31
|
+
return calculateSwitchAdjustment(
|
|
32
|
+
config.timingMethod,
|
|
33
|
+
newTime,
|
|
34
|
+
timeSpent,
|
|
35
|
+
config,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { TimeFormat } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Threshold for auto-format switching (in seconds)
|
|
5
|
+
* Below this, show seconds with decimal; above, show mm:ss
|
|
6
|
+
*/
|
|
7
|
+
const AUTO_FORMAT_THRESHOLD = 20;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format milliseconds into a display string
|
|
11
|
+
* @param milliseconds - Time in milliseconds
|
|
12
|
+
* @param format - Format type
|
|
13
|
+
* @returns Formatted time string
|
|
14
|
+
*/
|
|
15
|
+
export function formatClockTime(
|
|
16
|
+
milliseconds: number,
|
|
17
|
+
format: TimeFormat = "auto",
|
|
18
|
+
): string {
|
|
19
|
+
// Clamp to zero (no negative times)
|
|
20
|
+
const clampedMs = Math.max(0, milliseconds);
|
|
21
|
+
|
|
22
|
+
// Auto format: switch to ss.d when time is low
|
|
23
|
+
if (format === "auto") {
|
|
24
|
+
// Use milliseconds for comparison to avoid rounding issues
|
|
25
|
+
format = clampedMs < AUTO_FORMAT_THRESHOLD * 1000 ? "ss.d" : "mm:ss";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const totalSeconds = Math.ceil(clampedMs / 1000);
|
|
29
|
+
|
|
30
|
+
switch (format) {
|
|
31
|
+
case "ss.d": {
|
|
32
|
+
// Show seconds with one decimal place
|
|
33
|
+
const secondsWithDecimal = clampedMs / 1000;
|
|
34
|
+
return secondsWithDecimal.toFixed(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
case "mm:ss": {
|
|
38
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
39
|
+
const seconds = totalSeconds % 60;
|
|
40
|
+
// Show hours if time exceeds 60 minutes
|
|
41
|
+
if (totalMinutes >= 60) {
|
|
42
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
43
|
+
const minutes = totalMinutes % 60;
|
|
44
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
45
|
+
}
|
|
46
|
+
return `${totalMinutes}:${seconds.toString().padStart(2, "0")}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
case "hh:mm:ss": {
|
|
50
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
51
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
52
|
+
const seconds = totalSeconds % 60;
|
|
53
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
default:
|
|
57
|
+
return String(totalSeconds);
|
|
58
|
+
}
|
|
59
|
+
}
|