@buenos-nachos/time-sync 0.5.4 → 0.6.0

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