@buenos-nachos/time-sync 0.1.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.
@@ -0,0 +1,1071 @@
1
+ import { afterEach, beforeEach, describe, it, vi } from "vitest";
2
+ import { ReadonlyDate } from "./ReadonlyDate";
3
+ import {
4
+ type Configuration,
5
+ refreshRates,
6
+ type Snapshot,
7
+ TimeSync,
8
+ } from "./TimeSync";
9
+
10
+ const invalidIntervals: readonly number[] = [
11
+ Number.NaN,
12
+ Number.NEGATIVE_INFINITY,
13
+ 0,
14
+ -42,
15
+ 470.53,
16
+ ];
17
+
18
+ function setInitialTime(dateString: string): ReadonlyDate {
19
+ const sourceDate = new ReadonlyDate(dateString);
20
+ vi.setSystemTime(sourceDate);
21
+ return sourceDate;
22
+ }
23
+
24
+ /**
25
+ * Unfortunately, because the tests make extensive use of vi's mocking, these
26
+ * tests are a bad candidate for concurrent running. There's a very high risk of
27
+ * all the fake timer setup and teardown calls getting in each other's way.
28
+ *
29
+ * For example:
30
+ * 1. Test A sets up fake timers
31
+ * 2. Test B sets up fake timers around the same time
32
+ * 3. Test A finishes and clears out all fake timers (for A and B) before B has
33
+ * a chance to do anything
34
+ * 4. Test B runs and expects fake timers to be used, but they no longer exist
35
+ *
36
+ * Especially with there being so many test cases, the risk of flakes goes up a
37
+ * lot.
38
+ *
39
+ * We could redefine TimeSync to accept setInterval and clearInterval callbacks
40
+ * manually during instantiation, which would give us the needed test isolation
41
+ * to avoid the vi mocks and enable concurrency. But then you'd have to do one
42
+ * of two things:
43
+ *
44
+ * 1. Pollute the API with extra properties that are only ever relevant for
45
+ * internal testing
46
+ * 2. Create two versions of TimeSync – an internal one used for core logic and
47
+ * testing, and a public wrapper that embeds setInterval and clearInterval,
48
+ * and then prevents them from being set afterwards.
49
+ *
50
+ * (1) is always going to be bad, and (2) feels like it'll only make sense if
51
+ * this project grows to a size where we have +200 tests and we need concurrency
52
+ * to help with feedback loops in dev and CI. Since this package is expected to
53
+ * stay small, and since Vitest is pretty fast already, we're just going to use
54
+ * serial tests for now.
55
+ */
56
+ describe(TimeSync, () => {
57
+ beforeEach(() => {
58
+ // Date doesn't actually matter. Just choosing personally meaningful one
59
+ vi.useFakeTimers({ now: new Date("October 27, 2025") });
60
+ });
61
+
62
+ afterEach(() => {
63
+ vi.useRealTimers();
64
+ vi.restoreAllMocks();
65
+ });
66
+
67
+ describe("General initialization", () => {
68
+ it("Lets users specify custom initial date", ({ expect }) => {
69
+ const dates: readonly Date[] = [
70
+ new Date("March 14, 2022"),
71
+ new ReadonlyDate("August 14, 2014"),
72
+ ];
73
+ for (const initialDate of dates) {
74
+ const sync = new TimeSync({ initialDate });
75
+ const snap = sync.getStateSnapshot().date;
76
+ expect(snap).toEqual(initialDate);
77
+ }
78
+ });
79
+
80
+ it("Throws if instantiated with invalid refresh interval", ({ expect }) => {
81
+ for (const i of invalidIntervals) {
82
+ expect(() => {
83
+ new TimeSync({ minimumRefreshIntervalMs: i });
84
+ }).toThrow(RangeError);
85
+ }
86
+ });
87
+ });
88
+
89
+ describe("Subscriptions: general behavior", () => {
90
+ it("Never auto-updates state while there are zero subscribers", async ({
91
+ expect,
92
+ }) => {
93
+ const initialDate = setInitialTime("November 5, 2025");
94
+ const sync = new TimeSync({ initialDate });
95
+ const initialSnap = sync.getStateSnapshot().date;
96
+ expect(initialSnap).toEqual(initialDate);
97
+
98
+ await vi.advanceTimersByTimeAsync(5 * refreshRates.oneSecond);
99
+ const newSnap1 = sync.getStateSnapshot().date;
100
+ expect(newSnap1).toEqual(initialSnap);
101
+
102
+ await vi.advanceTimersByTimeAsync(500 * refreshRates.oneSecond);
103
+ const newSnap2 = sync.getStateSnapshot().date;
104
+ expect(newSnap2).toEqual(initialSnap);
105
+ });
106
+
107
+ it("Lets a single system subscribe to updates", async ({ expect }) => {
108
+ const rates: readonly number[] = [
109
+ refreshRates.oneSecond,
110
+ refreshRates.oneMinute,
111
+ refreshRates.oneHour,
112
+ ];
113
+ for (const rate of rates) {
114
+ // Duplicating all of these calls per iteration to maximize
115
+ // test isolation
116
+ const sync = new TimeSync();
117
+ const onUpdate = vi.fn();
118
+
119
+ void sync.subscribe({
120
+ onUpdate,
121
+ targetRefreshIntervalMs: rate,
122
+ });
123
+ expect(onUpdate).not.toHaveBeenCalled();
124
+
125
+ const dateBefore = sync.getStateSnapshot().date;
126
+ await vi.advanceTimersByTimeAsync(rate);
127
+ const dateAfter = sync.getStateSnapshot().date;
128
+ expect(onUpdate).toHaveBeenCalledTimes(1);
129
+ expect(onUpdate).toHaveBeenCalledWith(dateAfter);
130
+
131
+ const diff = dateAfter.getTime() - dateBefore.getTime();
132
+ expect(diff).toBe(rate);
133
+ }
134
+ });
135
+
136
+ it("Throws an error if provided subscription interval is not a positive integer", ({
137
+ expect,
138
+ }) => {
139
+ const sync = new TimeSync();
140
+ const dummyFunction = vi.fn();
141
+
142
+ for (const i of invalidIntervals) {
143
+ expect(() => {
144
+ void sync.subscribe({
145
+ targetRefreshIntervalMs: i,
146
+ onUpdate: dummyFunction,
147
+ });
148
+ }).toThrow(
149
+ `Target refresh interval must be positive infinity or a positive integer (received ${i} ms)`,
150
+ );
151
+ }
152
+ });
153
+
154
+ it("Lets multiple subscribers subscribe to updates", ({ expect }) => {
155
+ const sync = new TimeSync();
156
+ const dummyOnUpdate = vi.fn();
157
+
158
+ void sync.subscribe({
159
+ targetRefreshIntervalMs: refreshRates.oneMinute,
160
+ onUpdate: dummyOnUpdate,
161
+ });
162
+ void sync.subscribe({
163
+ targetRefreshIntervalMs: refreshRates.oneMinute,
164
+ onUpdate: dummyOnUpdate,
165
+ });
166
+
167
+ const snap = sync.getStateSnapshot();
168
+ expect(snap.subscriberCount).toBe(2);
169
+ });
170
+
171
+ it("Always dispatches updates in the order that callbacks were first registered", async ({
172
+ expect,
173
+ }) => {
174
+ const sync = new TimeSync();
175
+ const callOrder: number[] = [];
176
+
177
+ const onUpdate1 = vi.fn(() => {
178
+ callOrder.push(1);
179
+ });
180
+ void sync.subscribe({
181
+ onUpdate: onUpdate1,
182
+ targetRefreshIntervalMs: refreshRates.oneMinute,
183
+ });
184
+
185
+ const onUpdate2 = vi.fn(() => {
186
+ callOrder.push(2);
187
+ });
188
+ void sync.subscribe({
189
+ onUpdate: onUpdate2,
190
+ targetRefreshIntervalMs: refreshRates.oneMinute,
191
+ });
192
+
193
+ // Register callbacks that was already registered, to make sure that
194
+ // doesn't change dispatch order
195
+ void sync.subscribe({
196
+ onUpdate: onUpdate2,
197
+ targetRefreshIntervalMs: refreshRates.oneMinute,
198
+ });
199
+ void sync.subscribe({
200
+ onUpdate: onUpdate1,
201
+ targetRefreshIntervalMs: refreshRates.oneMinute,
202
+ });
203
+
204
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
205
+ expect(onUpdate1).toHaveBeenCalledTimes(1);
206
+ expect(onUpdate2).toHaveBeenCalledTimes(1);
207
+ expect(callOrder).toEqual([1, 2]);
208
+ });
209
+
210
+ it("Dispatches the same date object (by reference) to all subscribers on update", async ({
211
+ expect,
212
+ }) => {
213
+ const testCount = 10;
214
+ const sync = new TimeSync();
215
+
216
+ // We use .every later in the test, and it automatically skips over
217
+ // elements that haven't been explicitly initialized with a value
218
+ const dateTracker = new Array<Date | null>(testCount).fill(null);
219
+ for (let i = 0; i < testCount; i++) {
220
+ void sync.subscribe({
221
+ targetRefreshIntervalMs: refreshRates.oneSecond,
222
+ onUpdate: (date) => {
223
+ dateTracker[i] = date;
224
+ },
225
+ });
226
+ }
227
+
228
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
229
+ expect(dateTracker[0]).not.toBeNull();
230
+ const allMatch = dateTracker.every((d) => d === dateTracker[0]);
231
+ expect(allMatch).toBe(true);
232
+ });
233
+
234
+ it("Dispatches updates to all subscribers based on fastest interval specified", async ({
235
+ expect,
236
+ }) => {
237
+ const sync = new TimeSync();
238
+
239
+ const hourOnUpdate = vi.fn();
240
+ void sync.subscribe({
241
+ onUpdate: hourOnUpdate,
242
+ targetRefreshIntervalMs: refreshRates.oneHour,
243
+ });
244
+
245
+ const minuteOnUpdate = vi.fn();
246
+ void sync.subscribe({
247
+ onUpdate: minuteOnUpdate,
248
+ targetRefreshIntervalMs: refreshRates.oneMinute,
249
+ });
250
+
251
+ const secondOnUpdate = vi.fn();
252
+ void sync.subscribe({
253
+ onUpdate: secondOnUpdate,
254
+ targetRefreshIntervalMs: refreshRates.oneSecond,
255
+ });
256
+
257
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
258
+ expect(hourOnUpdate).toHaveBeenCalledTimes(1);
259
+ expect(minuteOnUpdate).toHaveBeenCalledTimes(1);
260
+ expect(secondOnUpdate).toHaveBeenCalledTimes(1);
261
+ });
262
+
263
+ it("Calls onUpdate callback one time total if callback is registered multiple times for the same time interval", async ({
264
+ expect,
265
+ }) => {
266
+ const sync = new TimeSync();
267
+ const sharedOnUpdate = vi.fn();
268
+
269
+ for (let i = 1; i <= 3; i++) {
270
+ void sync.subscribe({
271
+ onUpdate: sharedOnUpdate,
272
+ targetRefreshIntervalMs: refreshRates.oneMinute,
273
+ });
274
+ }
275
+
276
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
277
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(1);
278
+ });
279
+
280
+ it("Calls onUpdate callback one time total if callback is registered multiple times for different time intervals", async ({
281
+ expect,
282
+ }) => {
283
+ const sync = new TimeSync();
284
+ const sharedOnUpdate = vi.fn();
285
+
286
+ void sync.subscribe({
287
+ onUpdate: sharedOnUpdate,
288
+ targetRefreshIntervalMs: refreshRates.oneHour,
289
+ });
290
+ void sync.subscribe({
291
+ onUpdate: sharedOnUpdate,
292
+ targetRefreshIntervalMs: refreshRates.oneMinute,
293
+ });
294
+ void sync.subscribe({
295
+ onUpdate: sharedOnUpdate,
296
+ targetRefreshIntervalMs: refreshRates.oneSecond,
297
+ });
298
+
299
+ // Testing like this to ensure that for really, really long spans of
300
+ // time, the no duplicated calls logic still holds up
301
+ await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
302
+ const secondsInOneHour = 3600;
303
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
304
+ });
305
+
306
+ it("Calls onUpdate callback one time total if callback is registered multiple times with a mix of redundant/different intervals", async ({
307
+ expect,
308
+ }) => {
309
+ const sync = new TimeSync();
310
+ const sharedOnUpdate = vi.fn();
311
+
312
+ for (let i = 0; i < 10; i++) {
313
+ void sync.subscribe({
314
+ onUpdate: sharedOnUpdate,
315
+ targetRefreshIntervalMs: refreshRates.oneHour,
316
+ });
317
+ void sync.subscribe({
318
+ onUpdate: sharedOnUpdate,
319
+ targetRefreshIntervalMs: refreshRates.oneMinute,
320
+ });
321
+ void sync.subscribe({
322
+ onUpdate: sharedOnUpdate,
323
+ targetRefreshIntervalMs: refreshRates.oneSecond,
324
+ });
325
+ }
326
+
327
+ await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
328
+ const secondsInOneHour = 3600;
329
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
330
+ });
331
+
332
+ it("Lets an external system unsubscribe", async ({ expect }) => {
333
+ const sync = new TimeSync();
334
+ const onUpdate = vi.fn();
335
+ const unsub = sync.subscribe({
336
+ onUpdate,
337
+ targetRefreshIntervalMs: refreshRates.oneSecond,
338
+ });
339
+
340
+ unsub();
341
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
342
+ expect(onUpdate).not.toHaveBeenCalled();
343
+ });
344
+
345
+ it("Turns unsubscribe callback into no-op if called more than once", async ({
346
+ expect,
347
+ }) => {
348
+ const sync = new TimeSync();
349
+ const onUpdate = vi.fn();
350
+ const unsub = sync.subscribe({
351
+ onUpdate,
352
+ targetRefreshIntervalMs: refreshRates.oneSecond,
353
+ });
354
+
355
+ // Also adding extra dummy subscription to make sure internal state
356
+ // still works properly and isn't messed with from extra unsub calls
357
+ void sync.subscribe({
358
+ onUpdate: vi.fn(),
359
+ targetRefreshIntervalMs: refreshRates.oneSecond,
360
+ });
361
+ const initialSnap = sync.getStateSnapshot();
362
+ expect(initialSnap.subscriberCount).toBe(2);
363
+
364
+ for (let i = 0; i < 10; i++) {
365
+ unsub();
366
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
367
+ expect(onUpdate).not.toHaveBeenCalled();
368
+
369
+ const newSnap = sync.getStateSnapshot();
370
+ expect(newSnap.subscriberCount).toBe(1);
371
+ }
372
+ });
373
+
374
+ it("Does not fully remove an onUpdate callback if multiple systems use it to subscribe, and only one system unsubscribes", async ({
375
+ expect,
376
+ }) => {
377
+ const sync = new TimeSync();
378
+ const sharedOnUpdate = vi.fn();
379
+
380
+ for (let i = 0; i < 10; i++) {
381
+ void sync.subscribe({
382
+ onUpdate: sharedOnUpdate,
383
+ targetRefreshIntervalMs: refreshRates.oneHour,
384
+ });
385
+ void sync.subscribe({
386
+ onUpdate: sharedOnUpdate,
387
+ targetRefreshIntervalMs: refreshRates.oneMinute,
388
+ });
389
+ void sync.subscribe({
390
+ onUpdate: sharedOnUpdate,
391
+ targetRefreshIntervalMs: refreshRates.oneSecond,
392
+ });
393
+ }
394
+
395
+ const extraOnUpdate = vi.fn();
396
+ const extraUnsub = sync.subscribe({
397
+ onUpdate: extraOnUpdate,
398
+ targetRefreshIntervalMs: refreshRates.oneSecond,
399
+ });
400
+
401
+ const snap1 = sync.getStateSnapshot();
402
+ expect(snap1.subscriberCount).toBe(31);
403
+
404
+ extraUnsub();
405
+ const snap2 = sync.getStateSnapshot();
406
+ expect(snap2.subscriberCount).toBe(30);
407
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
408
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(1);
409
+ });
410
+
411
+ it("Speeds up interval when new subscriber is added that is faster than all other subscribers", async ({
412
+ expect,
413
+ }) => {
414
+ const sync = new TimeSync();
415
+ const onUpdate1 = vi.fn();
416
+ void sync.subscribe({
417
+ onUpdate: onUpdate1,
418
+ targetRefreshIntervalMs: refreshRates.oneSecond,
419
+ });
420
+
421
+ const onUpdate2 = vi.fn();
422
+ void sync.subscribe({
423
+ onUpdate: onUpdate2,
424
+ targetRefreshIntervalMs: refreshRates.oneSecond,
425
+ });
426
+
427
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
428
+ expect(onUpdate1).toHaveBeenCalledTimes(1);
429
+ expect(onUpdate2).toHaveBeenCalledTimes(1);
430
+
431
+ const onUpdate3 = vi.fn();
432
+ void sync.subscribe({
433
+ onUpdate: onUpdate3,
434
+ targetRefreshIntervalMs: refreshRates.halfSecond,
435
+ });
436
+
437
+ await vi.advanceTimersByTimeAsync(refreshRates.halfSecond);
438
+ expect(onUpdate1).toHaveBeenCalledTimes(2);
439
+ expect(onUpdate2).toHaveBeenCalledTimes(2);
440
+ expect(onUpdate3).toHaveBeenCalledTimes(1);
441
+ });
442
+
443
+ it("Slows updates down to the second-fastest interval when the all subscribers for the fastest interval unsubscribe", async ({
444
+ expect,
445
+ }) => {
446
+ const sync = new TimeSync();
447
+ const onUpdate1 = vi.fn();
448
+ const unsub1 = sync.subscribe({
449
+ onUpdate: onUpdate1,
450
+ targetRefreshIntervalMs: refreshRates.oneSecond,
451
+ });
452
+
453
+ const onUpdate2 = vi.fn();
454
+ const unsub2 = sync.subscribe({
455
+ onUpdate: onUpdate2,
456
+ targetRefreshIntervalMs: refreshRates.oneSecond,
457
+ });
458
+
459
+ const onUpdate3 = vi.fn();
460
+ void sync.subscribe({
461
+ onUpdate: onUpdate3,
462
+ targetRefreshIntervalMs: refreshRates.oneMinute,
463
+ });
464
+
465
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
466
+ expect(onUpdate1).toHaveBeenCalledTimes(1);
467
+ expect(onUpdate2).toHaveBeenCalledTimes(1);
468
+ expect(onUpdate3).toHaveBeenCalledTimes(1);
469
+
470
+ unsub1();
471
+ unsub2();
472
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
473
+ expect(onUpdate1).toHaveBeenCalledTimes(1);
474
+ expect(onUpdate2).toHaveBeenCalledTimes(1);
475
+ expect(onUpdate3).toHaveBeenCalledTimes(1);
476
+
477
+ await vi.advanceTimersByTimeAsync(
478
+ refreshRates.oneMinute - refreshRates.oneSecond,
479
+ );
480
+ expect(onUpdate1).toHaveBeenCalledTimes(1);
481
+ expect(onUpdate2).toHaveBeenCalledTimes(1);
482
+ expect(onUpdate3).toHaveBeenCalledTimes(2);
483
+ });
484
+
485
+ /**
486
+ * Was really hard to describe this in a single sentence, but basically:
487
+ * 1. Let's say that we have subscribers A and B. A subscribes for 500ms
488
+ * and B subscribes for 1000ms.
489
+ * 2. At 450ms, A unsubscribes.
490
+ * 3. Rather than starting the timer over, a one-time 'pseudo-timeout'
491
+ * is kicked off for the delta between the elapsed time and B (550ms)
492
+ * 4. After the timeout resolves, updates go back to happening on an
493
+ * interval of 1000ms.
494
+ *
495
+ * Because of unfortunate limitations with JavaScript's macrotask queue,
496
+ * there is a risk that there will be small delays introduced between
497
+ * starting and stopping intervals, but any attempts to minimize them
498
+ * (you can't completely remove them) might make the library a nightmare
499
+ * to maintain.
500
+ */
501
+ it("Does not completely start next interval over from scratch if fastest subscription is removed halfway through update", async ({
502
+ expect,
503
+ }) => {
504
+ const sync = new TimeSync();
505
+ const onUpdate1 = vi.fn();
506
+ const unsub1 = sync.subscribe({
507
+ onUpdate: onUpdate1,
508
+ targetRefreshIntervalMs: refreshRates.halfSecond,
509
+ });
510
+
511
+ const onUpdate2 = vi.fn();
512
+ void sync.subscribe({
513
+ onUpdate: onUpdate2,
514
+ targetRefreshIntervalMs: refreshRates.oneSecond,
515
+ });
516
+
517
+ await vi.advanceTimersByTimeAsync(450);
518
+ unsub1();
519
+
520
+ await vi.advanceTimersByTimeAsync(50);
521
+ expect(onUpdate1).not.toHaveBeenCalled();
522
+ expect(onUpdate2).not.toHaveBeenCalled();
523
+
524
+ await vi.advanceTimersByTimeAsync(500);
525
+ expect(onUpdate1).not.toHaveBeenCalled();
526
+ expect(onUpdate2).toHaveBeenCalledTimes(1);
527
+
528
+ // Verify that updates go back to normal after pseudo-timeout
529
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
530
+ expect(onUpdate1).not.toHaveBeenCalled();
531
+ expect(onUpdate2).toHaveBeenCalledTimes(2);
532
+ });
533
+
534
+ it("Immediately notifies subscribers if new refresh interval is added that is less than or equal to the time since the last update", async ({
535
+ expect,
536
+ }) => {
537
+ const sync = new TimeSync();
538
+ const onUpdate1 = vi.fn();
539
+ void sync.subscribe({
540
+ onUpdate: onUpdate1,
541
+ targetRefreshIntervalMs: refreshRates.oneMinute,
542
+ });
543
+
544
+ await vi.advanceTimersByTimeAsync(refreshRates.thirtySeconds);
545
+ const onUpdate2 = vi.fn();
546
+ void sync.subscribe({
547
+ onUpdate: onUpdate2,
548
+ targetRefreshIntervalMs: refreshRates.thirtySeconds,
549
+ });
550
+
551
+ expect(onUpdate1).toHaveBeenCalledTimes(1);
552
+ expect(onUpdate2).toHaveBeenCalledTimes(1);
553
+ });
554
+
555
+ it("Does not ever dispatch updates if all subscribers specify an update interval of positive infinity", async ({
556
+ expect,
557
+ }) => {
558
+ const sync = new TimeSync();
559
+ const sharedOnUpdate = vi.fn();
560
+ for (let i = 0; i < 100; i++) {
561
+ void sync.subscribe({
562
+ onUpdate: sharedOnUpdate,
563
+ targetRefreshIntervalMs: refreshRates.idle,
564
+ });
565
+ }
566
+
567
+ const jumps: readonly number[] = [
568
+ refreshRates.halfSecond,
569
+ refreshRates.oneSecond,
570
+ refreshRates.thirtySeconds,
571
+ refreshRates.oneMinute,
572
+ refreshRates.fiveMinutes,
573
+ refreshRates.oneHour,
574
+ ];
575
+ for (const j of jumps) {
576
+ await vi.advanceTimersByTimeAsync(j);
577
+ expect(sharedOnUpdate).not.toHaveBeenCalled();
578
+ }
579
+ });
580
+
581
+ it("Auto-updates date snapshot if new active subscriber gets added while previous subscribers were all idle", async ({
582
+ expect,
583
+ }) => {
584
+ const sync = new TimeSync();
585
+ const dummyOnUpdate = vi.fn();
586
+
587
+ for (let i = 0; i < 100; i++) {
588
+ void sync.subscribe({
589
+ onUpdate: dummyOnUpdate,
590
+ targetRefreshIntervalMs: refreshRates.idle,
591
+ });
592
+ }
593
+ expect(dummyOnUpdate).not.toHaveBeenCalled();
594
+
595
+ await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
596
+ expect(dummyOnUpdate).not.toHaveBeenCalled();
597
+
598
+ const dateBefore = sync.getStateSnapshot().date;
599
+ void sync.subscribe({
600
+ onUpdate: dummyOnUpdate,
601
+ targetRefreshIntervalMs: refreshRates.oneMinute,
602
+ });
603
+
604
+ const dateAfter = sync.getStateSnapshot().date;
605
+ expect(dateAfter).not.toEqual(dateBefore);
606
+ });
607
+ });
608
+
609
+ describe("Subscriptions: custom `minimumRefreshIntervalMs` value", () => {
610
+ it("Rounds up target intervals to custom min interval", async ({
611
+ expect,
612
+ }) => {
613
+ const sync = new TimeSync({
614
+ minimumRefreshIntervalMs: refreshRates.oneHour,
615
+ });
616
+
617
+ const onUpdate = vi.fn();
618
+ void sync.subscribe({
619
+ onUpdate,
620
+ targetRefreshIntervalMs: refreshRates.oneMinute,
621
+ });
622
+
623
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
624
+ expect(onUpdate).not.toHaveBeenCalled();
625
+ await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
626
+ expect(onUpdate).toHaveBeenCalledTimes(1);
627
+ });
628
+
629
+ it("Throws if custom min interval is not a positive integer", ({
630
+ expect,
631
+ }) => {
632
+ const intervals: readonly number[] = [
633
+ Number.NaN,
634
+ Number.NEGATIVE_INFINITY,
635
+ 0,
636
+ -42,
637
+ 470.53,
638
+ ];
639
+ for (const i of intervals) {
640
+ expect(() => {
641
+ void new TimeSync({ minimumRefreshIntervalMs: i });
642
+ }).toThrow(
643
+ `Minimum refresh interval must be a positive integer (received ${i} ms)`,
644
+ );
645
+ }
646
+ });
647
+ });
648
+
649
+ describe("Subscriptions: duplicating function calls", () => {
650
+ it("Defaults to de-duplicating", async ({ expect }) => {
651
+ const sync = new TimeSync();
652
+ const sharedOnUpdate = vi.fn();
653
+ for (let i = 0; i < 100; i++) {
654
+ void sync.subscribe({
655
+ onUpdate: sharedOnUpdate,
656
+ targetRefreshIntervalMs: refreshRates.oneMinute,
657
+ });
658
+ }
659
+
660
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
661
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(1);
662
+ });
663
+
664
+ it("Lets user turn on duplication", async ({ expect }) => {
665
+ const sync = new TimeSync({
666
+ allowDuplicateOnUpdateCalls: true,
667
+ });
668
+
669
+ const sharedOnUpdate = vi.fn();
670
+ for (let i = 0; i < 100; i++) {
671
+ void sync.subscribe({
672
+ onUpdate: sharedOnUpdate,
673
+ targetRefreshIntervalMs: refreshRates.oneMinute,
674
+ });
675
+ }
676
+
677
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
678
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(100);
679
+ });
680
+ });
681
+
682
+ describe("State snapshots", () => {
683
+ it("Lets external system pull snapshot without subscribing", ({
684
+ expect,
685
+ }) => {
686
+ const initialDate = setInitialTime("July 4, 1999");
687
+ const minimumRefreshIntervalMs = 5_000_000;
688
+ const sync = new TimeSync({ initialDate, minimumRefreshIntervalMs });
689
+
690
+ const snap = sync.getStateSnapshot();
691
+ expect(snap).toEqual<Snapshot>({
692
+ date: initialDate,
693
+ subscriberCount: 0,
694
+ config: {
695
+ freezeUpdates: false,
696
+ minimumRefreshIntervalMs,
697
+ allowDuplicateOnUpdateCalls: false,
698
+ },
699
+ });
700
+ });
701
+
702
+ it("Reflects custom initial date if provided", ({ expect }) => {
703
+ void setInitialTime("June 8, 1900");
704
+ const override = new Date("April 1, 1000");
705
+ const sync = new TimeSync({ initialDate: override });
706
+
707
+ const snap = sync.getStateSnapshot();
708
+ expect(snap.date).toEqual(override);
709
+ });
710
+
711
+ it("Reflects the minimum refresh interval used on init", ({ expect }) => {
712
+ const sync = new TimeSync({
713
+ minimumRefreshIntervalMs: refreshRates.oneHour,
714
+ });
715
+ const snap = sync.getStateSnapshot();
716
+ expect(snap.config.minimumRefreshIntervalMs).toBe(refreshRates.oneHour);
717
+ });
718
+
719
+ // This behavior is super, super important for the React bindings. The
720
+ // bindings rely on useSyncExternalStore, which will pull from whatever
721
+ // is bound to it multiple times in dev mode. That ensures that React
722
+ // can fudge the rules and treat it like a pure value, but if it gets
723
+ // back different references, it will keep pulling over and over until
724
+ // it gives up, throws a rendering error, and blows up the entire app.
725
+ it("Always gives back the same snapshot by reference if it's pulled synchronously multiple times", ({
726
+ expect,
727
+ }) => {
728
+ const sync = new TimeSync();
729
+ const initialSnap = sync.getStateSnapshot();
730
+
731
+ for (let i = 0; i < 100; i++) {
732
+ const newSnap = sync.getStateSnapshot();
733
+ expect(newSnap).toEqual(initialSnap);
734
+ }
735
+ });
736
+
737
+ it("Does not mutate old snapshots when a new update is queued for subscribers", async ({
738
+ expect,
739
+ }) => {
740
+ const sync = new TimeSync();
741
+ const initialSnap = sync.getStateSnapshot();
742
+
743
+ const onUpdate = vi.fn();
744
+ void sync.subscribe({
745
+ onUpdate,
746
+ targetRefreshIntervalMs: refreshRates.oneHour,
747
+ });
748
+ await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
749
+
750
+ expect(onUpdate).toHaveBeenCalledTimes(1);
751
+ expect(onUpdate).toHaveBeenCalledWith(expect.any(Date));
752
+
753
+ const newSnap = sync.getStateSnapshot();
754
+ expect(newSnap).not.toEqual(initialSnap);
755
+ });
756
+
757
+ it("Does not mutate old snapshots when new subscription is added or removed", ({
758
+ expect,
759
+ }) => {
760
+ const sync = new TimeSync();
761
+ const initialSnap = sync.getStateSnapshot();
762
+
763
+ const unsub = sync.subscribe({
764
+ targetRefreshIntervalMs: refreshRates.oneHour,
765
+ onUpdate: vi.fn(),
766
+ });
767
+ const afterAdd = sync.getStateSnapshot();
768
+ expect(afterAdd.subscriberCount).toBe(1);
769
+ expect(afterAdd).not.toBe(initialSnap);
770
+ expect(afterAdd).not.toEqual(initialSnap);
771
+
772
+ unsub();
773
+ const afterRemove = sync.getStateSnapshot();
774
+ expect(afterRemove.subscriberCount).toBe(0);
775
+ expect(afterRemove).not.toBe(afterAdd);
776
+ expect(afterRemove).not.toEqual(afterAdd);
777
+ expect(afterRemove).not.toBe(initialSnap);
778
+ expect(afterRemove).toEqual(initialSnap);
779
+ });
780
+
781
+ it("Provides accurate count of active subscriptions as it changes over time", ({
782
+ expect,
783
+ }) => {
784
+ const sync = new TimeSync();
785
+ const snap = sync.getStateSnapshot();
786
+ expect(snap.subscriberCount).toBe(0);
787
+
788
+ const dummyOnUpdate = vi.fn();
789
+ for (let i = 1; i <= 10; i++) {
790
+ void sync.subscribe({
791
+ onUpdate: dummyOnUpdate,
792
+ targetRefreshIntervalMs: refreshRates.oneHour,
793
+ });
794
+
795
+ const newSnap = sync.getStateSnapshot();
796
+ expect(newSnap.subscriberCount).toBe(i);
797
+ }
798
+ });
799
+
800
+ it("Indicates frozen status", ({ expect }) => {
801
+ const normalSync = new TimeSync({ freezeUpdates: false });
802
+ const normalSnap = normalSync.getStateSnapshot();
803
+ expect(normalSnap.config.freezeUpdates).toBe(false);
804
+
805
+ const frozenSync = new TimeSync({ freezeUpdates: true });
806
+ const frozenSnap = frozenSync.getStateSnapshot();
807
+ expect(frozenSnap.config.freezeUpdates).toBe(true);
808
+ });
809
+
810
+ it("Indicates deduplicated functions status", ({ expect }) => {
811
+ const normalSync = new TimeSync({ allowDuplicateOnUpdateCalls: false });
812
+ const normalSnap = normalSync.getStateSnapshot();
813
+ expect(normalSnap.config.allowDuplicateOnUpdateCalls).toBe(false);
814
+
815
+ const dupeSync = new TimeSync({ allowDuplicateOnUpdateCalls: true });
816
+ const dupeSnap = dupeSync.getStateSnapshot();
817
+ expect(dupeSnap.config.allowDuplicateOnUpdateCalls).toBe(true);
818
+ });
819
+
820
+ it("Prevents mutating properties at runtime", ({ expect }) => {
821
+ type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };
822
+ const sync = new TimeSync();
823
+
824
+ // We have readonly modifiers on the types, but we need to make sure
825
+ // nothing can break at runtime
826
+ const snap = sync.getStateSnapshot() as Writeable<Snapshot>;
827
+ const config = snap.config as Writeable<Configuration>;
828
+ const copyBeforeMutations = { ...snap, config: { ...config } };
829
+
830
+ const mutationSource: Snapshot = {
831
+ date: new ReadonlyDate("April 1, 1970"),
832
+ subscriberCount: Number.POSITIVE_INFINITY,
833
+ config: {
834
+ freezeUpdates: true,
835
+ minimumRefreshIntervalMs: Number.POSITIVE_INFINITY,
836
+ allowDuplicateOnUpdateCalls: true,
837
+ },
838
+ };
839
+
840
+ const mutations: readonly (() => void)[] = [
841
+ () => {
842
+ snap.date = mutationSource.date;
843
+ },
844
+ () => {
845
+ snap.subscriberCount = mutationSource.subscriberCount;
846
+ },
847
+ () => {
848
+ config.freezeUpdates = mutationSource.config.freezeUpdates;
849
+ },
850
+ () => {
851
+ config.minimumRefreshIntervalMs =
852
+ mutationSource.config.minimumRefreshIntervalMs;
853
+ },
854
+ () => {
855
+ config.allowDuplicateOnUpdateCalls =
856
+ mutationSource.config.allowDuplicateOnUpdateCalls;
857
+ },
858
+ ];
859
+ for (const m of mutations) {
860
+ expect(m).toThrow(TypeError);
861
+ }
862
+
863
+ expect(snap).toEqual(copyBeforeMutations);
864
+ });
865
+ });
866
+
867
+ describe("Resetting a TimeSync instance", () => {
868
+ it("Clears active interval", async ({ expect }) => {
869
+ const setSpy = vi.spyOn(globalThis, "setInterval");
870
+ const clearSpy = vi.spyOn(globalThis, "clearInterval");
871
+ const sync = new TimeSync();
872
+
873
+ const onUpdate = vi.fn();
874
+ void sync.subscribe({
875
+ onUpdate,
876
+ targetRefreshIntervalMs: refreshRates.oneMinute,
877
+ });
878
+
879
+ // We call clearInterval a lot in the library code to be on the
880
+ // defensive side, and limit the risk of bugs creeping through.
881
+ // Trying to tie the test to a specific number of calls felt like
882
+ // tying it to implementation details too much. So, instead we're
883
+ // going to assume that if the clear was called at least once, and
884
+ // the number of set calls hasn't changed from before the reset
885
+ // step, we're good
886
+ expect(setSpy).toHaveBeenCalledTimes(1);
887
+ sync.clearAll();
888
+ expect(clearSpy).toHaveBeenCalled();
889
+ expect(setSpy).toHaveBeenCalledTimes(1);
890
+
891
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
892
+ expect(onUpdate).not.toHaveBeenCalled();
893
+ });
894
+
895
+ it("Automatically unsubscribes everything", async ({ expect }) => {
896
+ const sync = new TimeSync();
897
+ const sharedOnUpdate = vi.fn();
898
+
899
+ for (let i = 0; i < 100; i++) {
900
+ void sync.subscribe({
901
+ onUpdate: sharedOnUpdate,
902
+ targetRefreshIntervalMs: refreshRates.oneMinute,
903
+ });
904
+ }
905
+
906
+ const oldSnap = sync.getStateSnapshot();
907
+ expect(oldSnap.subscriberCount).toBe(100);
908
+
909
+ sync.clearAll();
910
+ const newSnap = sync.getStateSnapshot();
911
+ expect(newSnap.subscriberCount).toBe(0);
912
+
913
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
914
+ expect(sharedOnUpdate).not.toHaveBeenCalled();
915
+ });
916
+ });
917
+
918
+ /**
919
+ * The intention with the frozen status is that once set on init, there
920
+ * should be no way to make it un-frozen – a consumer would need to create a
921
+ * fresh instance from scratch.
922
+ *
923
+ * Not sure how to codify that in tests yet, but ideally it should be.
924
+ */
925
+ describe("Freezing updates on init", () => {
926
+ it("Never updates internal state, no matter how many subscribers subscribe", ({
927
+ expect,
928
+ }) => {
929
+ const initialDate = new Date("August 25, 1832");
930
+ const sync = new TimeSync({ initialDate, freezeUpdates: true });
931
+ const dummyOnUpdate = vi.fn();
932
+
933
+ for (let i = 0; i < 1000; i++) {
934
+ void sync.subscribe({
935
+ onUpdate: dummyOnUpdate,
936
+ targetRefreshIntervalMs: refreshRates.oneMinute,
937
+ });
938
+ }
939
+
940
+ const snap = sync.getStateSnapshot();
941
+ expect(snap.subscriberCount).toBe(0);
942
+ expect(snap.date).toEqual(initialDate);
943
+ });
944
+ });
945
+
946
+ describe("Nuanced interactions", () => {
947
+ it("It always updates public snapshot state before update round", async ({
948
+ expect,
949
+ }) => {
950
+ const sync = new TimeSync();
951
+ const snapBefore = sync.getStateSnapshot().date;
952
+
953
+ let dateFromUpdate = snapBefore;
954
+ const onUpdate = vi.fn((newDate) => {
955
+ dateFromUpdate = newDate;
956
+ });
957
+ void sync.subscribe({
958
+ onUpdate: onUpdate,
959
+ targetRefreshIntervalMs: refreshRates.oneMinute,
960
+ });
961
+
962
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
963
+ expect(snapBefore).not.toEqual(dateFromUpdate);
964
+ const diff = dateFromUpdate.getTime() - snapBefore.getTime();
965
+ expect(diff).toBe(refreshRates.oneMinute);
966
+ });
967
+
968
+ it("Stops dispatching to remaining subscribers the moment one resets all state", async ({
969
+ expect,
970
+ }) => {
971
+ const sync = new TimeSync();
972
+
973
+ const onUpdate1 = vi.fn(() => {
974
+ sync.clearAll();
975
+ });
976
+ void sync.subscribe({
977
+ onUpdate: onUpdate1,
978
+ targetRefreshIntervalMs: refreshRates.oneMinute,
979
+ });
980
+
981
+ const onUpdate2 = vi.fn();
982
+ void sync.subscribe({
983
+ onUpdate: onUpdate2,
984
+ targetRefreshIntervalMs: refreshRates.oneMinute,
985
+ });
986
+
987
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
988
+ expect(onUpdate1).toHaveBeenCalledTimes(1);
989
+ expect(onUpdate2).not.toHaveBeenCalled();
990
+ });
991
+
992
+ it("Skips over new subscribers if they get added in the middle of an update round", async ({
993
+ expect,
994
+ }) => {
995
+ const dupeOptions: readonly boolean[] = [false, true];
996
+ for (const d of dupeOptions) {
997
+ const sync = new TimeSync({ allowDuplicateOnUpdateCalls: d });
998
+ const onUpdate = vi.fn(() => {
999
+ // Adding this check in the off chance that the logic is broken.
1000
+ // Don't want to cause infinite loops in the test environment
1001
+ if (onUpdate.mock.calls.length > 1) {
1002
+ return;
1003
+ }
1004
+
1005
+ void sync.subscribe({
1006
+ onUpdate,
1007
+ targetRefreshIntervalMs: refreshRates.oneMinute,
1008
+ });
1009
+ });
1010
+
1011
+ void sync.subscribe({
1012
+ onUpdate,
1013
+ targetRefreshIntervalMs: refreshRates.oneMinute,
1014
+ });
1015
+
1016
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
1017
+ expect(onUpdate).toHaveBeenCalledTimes(1);
1018
+ }
1019
+ });
1020
+
1021
+ it("Provides easy-ish way to create a TimeSync copy from an existing instance", ({
1022
+ expect,
1023
+ }) => {
1024
+ const sync1 = new TimeSync({
1025
+ initialDate: new ReadonlyDate("September 1, 1374"),
1026
+ allowDuplicateOnUpdateCalls: true,
1027
+ freezeUpdates: true,
1028
+ minimumRefreshIntervalMs: refreshRates.oneHour,
1029
+ });
1030
+
1031
+ const snap1 = sync1.getStateSnapshot();
1032
+ const sync2 = new TimeSync({
1033
+ ...snap1.config,
1034
+ initialDate: snap1.date,
1035
+ });
1036
+
1037
+ const snap2 = sync2.getStateSnapshot();
1038
+ expect(snap2).toEqual(snap1);
1039
+ });
1040
+
1041
+ it("Does not let stale unsubscribe callbacks remove data after clear", ({
1042
+ expect,
1043
+ }) => {
1044
+ const sync = new TimeSync();
1045
+ const sharedOnUpdate = vi.fn();
1046
+
1047
+ const initialUnsub = sync.subscribe({
1048
+ onUpdate: sharedOnUpdate,
1049
+ targetRefreshIntervalMs: refreshRates.oneMinute,
1050
+ });
1051
+
1052
+ sync.clearAll();
1053
+
1054
+ const newUnsub = sync.subscribe({
1055
+ onUpdate: sharedOnUpdate,
1056
+ targetRefreshIntervalMs: refreshRates.oneMinute,
1057
+ });
1058
+
1059
+ const snap1 = sync.getStateSnapshot().subscriberCount;
1060
+ expect(snap1).toBe(1);
1061
+
1062
+ initialUnsub();
1063
+ const snap2 = sync.getStateSnapshot().subscriberCount;
1064
+ expect(snap2).toBe(1);
1065
+
1066
+ newUnsub();
1067
+ const snap3 = sync.getStateSnapshot().subscriberCount;
1068
+ expect(snap3).toBe(0);
1069
+ });
1070
+ });
1071
+ });