@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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +9 -0
- package/dist/index.d.mts +275 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.d.ts +275 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +409 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +406 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +20 -0
- package/src/ReadonlyDate.test.ts +171 -0
- package/src/ReadonlyDate.ts +312 -0
- package/src/TimeSync.test.ts +1071 -0
- package/src/TimeSync.ts +583 -0
- package/src/index.ts +11 -0
- package/tsconfig.json +3 -0
- package/tsdown.config.ts +9 -0
|
@@ -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
|
+
});
|