@navaramap/core 0.2.0 → 0.3.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.
@@ -0,0 +1,484 @@
1
+ import type { Events } from "@navaramap/engine";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { JobDispatcher, type JobHandler } from "./JobDispatcher";
5
+ import { makeEvent } from "./mock";
6
+
7
+ type Ev = { ind: number; gen: number; free: () => void };
8
+
9
+ const ev = (ind: number, gen = 1): Ev => ({ ind, gen, free: vi.fn() });
10
+
11
+ const flush = () => new Promise((r) => setTimeout(r, 0));
12
+
13
+ type Deferred<T> = {
14
+ promise: Promise<T>;
15
+ resolve: (v: T) => void;
16
+ reject: (e: unknown) => void;
17
+ };
18
+ const deferred = <T>(): Deferred<T> => {
19
+ let resolve!: (v: T) => void;
20
+ let reject!: (e: unknown) => void;
21
+ const promise = new Promise<T>((res, rej) => {
22
+ resolve = res;
23
+ reject = rej;
24
+ });
25
+ return { promise, resolve, reject };
26
+ };
27
+
28
+ type Handler = JobHandler<Ev, Ev, Ev, string>;
29
+
30
+ const makeHandler = (
31
+ start: Handler["start"],
32
+ overrides: Partial<Handler> = {},
33
+ ) => {
34
+ const handler = {
35
+ name: "test",
36
+ events: {
37
+ requested: "mesh_added",
38
+ changed: "mesh_updated",
39
+ removed: "mesh_removed",
40
+ },
41
+ start: vi.fn(start),
42
+ change: vi.fn(),
43
+ teardown: vi.fn(),
44
+ failed: vi.fn(),
45
+ ...overrides,
46
+ } satisfies Handler;
47
+ return handler;
48
+ };
49
+
50
+ const events = (stacks: {
51
+ mesh_added?: Ev[];
52
+ mesh_updated?: Ev[];
53
+ mesh_removed?: Ev[];
54
+ }) => makeEvent(stacks as unknown as Parameters<typeof makeEvent>[0]);
55
+
56
+ describe("JobDispatcher", () => {
57
+ it("starts every requested job in the same tick and tears down a live one on removed", async () => {
58
+ const handler = makeHandler(async () => "product");
59
+ const d = new JobDispatcher();
60
+ d.register(handler);
61
+
62
+ const a = ev(1);
63
+ const b = ev(2);
64
+ d.tick(events({ mesh_added: [a, b] }));
65
+ expect(handler.start).toHaveBeenCalledTimes(2);
66
+ expect(d.inFlight()).toBe(2);
67
+
68
+ await flush();
69
+ expect(d.inFlight()).toBe(0);
70
+ expect(a.free).toHaveBeenCalledTimes(1);
71
+ expect(b.free).toHaveBeenCalledTimes(1);
72
+
73
+ const removed = ev(1);
74
+ d.tick(events({ mesh_removed: [removed] }));
75
+ expect(handler.teardown).toHaveBeenCalledWith("product", removed);
76
+ expect(removed.free).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it("counts in-flight calls per registered kind", async () => {
80
+ const started = deferred<string>();
81
+ const changed = deferred<undefined>();
82
+ const meshes = makeHandler(() => started.promise, {
83
+ change: vi.fn(() => changed.promise),
84
+ });
85
+ const features = makeHandler(async () => "feature", {
86
+ events: {
87
+ requested: "renderable_feature_added",
88
+ removed: "renderable_feature_removed",
89
+ },
90
+ });
91
+ const d = new JobDispatcher();
92
+ const meshKind = d.register(meshes);
93
+ const featureKind = d.register(features);
94
+
95
+ d.tick(
96
+ makeEvent({
97
+ mesh_added: [ev(1), ev(2)],
98
+ renderable_feature_added: [ev(3)],
99
+ } as unknown as Parameters<typeof makeEvent>[0]),
100
+ );
101
+ expect(meshKind.inFlight()).toBe(2);
102
+ expect(featureKind.inFlight()).toBe(1);
103
+
104
+ started.resolve("product");
105
+ await flush();
106
+ expect(meshKind.inFlight()).toBe(0);
107
+ expect(featureKind.inFlight()).toBe(0);
108
+
109
+ // An async change holds its job in flight again.
110
+ d.tick(events({ mesh_updated: [ev(1)] }));
111
+ expect(meshKind.inFlight()).toBe(1);
112
+ changed.resolve(undefined);
113
+ await flush();
114
+ expect(meshKind.inFlight()).toBe(0);
115
+
116
+ d.tick(events({ mesh_updated: [ev(2)] }));
117
+ d.dispose();
118
+ expect(meshKind.inFlight()).toBe(0);
119
+ });
120
+
121
+ it("aborts an in-flight job on removed and tears it down once start settles", async () => {
122
+ const started = deferred<string>();
123
+ let signal: AbortSignal | undefined;
124
+ const handler = makeHandler((_, abort) => {
125
+ signal = abort.signal;
126
+ return started.promise;
127
+ });
128
+ const d = new JobDispatcher();
129
+ d.register(handler);
130
+
131
+ const added = ev(1);
132
+ d.tick(events({ mesh_added: [added] }));
133
+ const removed = ev(1);
134
+ d.tick(events({ mesh_removed: [removed] }));
135
+
136
+ expect(signal?.aborted).toBe(true);
137
+ expect(handler.teardown).not.toHaveBeenCalled();
138
+ expect(removed.free).not.toHaveBeenCalled();
139
+
140
+ started.resolve("late product");
141
+ await flush();
142
+ expect(handler.teardown).toHaveBeenCalledTimes(1);
143
+ expect(handler.teardown).toHaveBeenCalledWith("late product", removed);
144
+ expect(added.free).toHaveBeenCalledTimes(1);
145
+ expect(removed.free).toHaveBeenCalledTimes(1);
146
+ expect(d.inFlight()).toBe(0);
147
+ });
148
+
149
+ it("drops an add whose remove arrives in the same tick", () => {
150
+ const handler = makeHandler(async () => "product");
151
+ const d = new JobDispatcher();
152
+ d.register(handler);
153
+
154
+ const added = ev(1);
155
+ const removed = ev(1);
156
+ d.tick(events({ mesh_added: [added], mesh_removed: [removed] }));
157
+
158
+ expect(handler.start).not.toHaveBeenCalled();
159
+ expect(handler.teardown).toHaveBeenCalledWith(undefined, removed);
160
+ expect(added.free).toHaveBeenCalledTimes(1);
161
+ expect(removed.free).toHaveBeenCalledTimes(1);
162
+ });
163
+
164
+ it("tears down an unknown id with no product", () => {
165
+ const handler = makeHandler(async () => "product");
166
+ const d = new JobDispatcher();
167
+ d.register(handler);
168
+
169
+ const removed = ev(9);
170
+ d.tick(events({ mesh_removed: [removed] }));
171
+ expect(handler.teardown).toHaveBeenCalledWith(undefined, removed);
172
+ expect(removed.free).toHaveBeenCalledTimes(1);
173
+ });
174
+
175
+ it("holds the latest change while start is in flight and applies it once", async () => {
176
+ const started = deferred<string>();
177
+ const handler = makeHandler(() => started.promise);
178
+ const d = new JobDispatcher();
179
+ d.register(handler);
180
+
181
+ d.tick(events({ mesh_added: [ev(1)] }));
182
+ const c1 = ev(1);
183
+ const c2 = ev(1);
184
+ d.tick(events({ mesh_updated: [c1] }));
185
+ d.tick(events({ mesh_updated: [c2] }));
186
+ expect(handler.change).not.toHaveBeenCalled();
187
+ expect(c1.free).toHaveBeenCalledTimes(1);
188
+ expect(c2.free).not.toHaveBeenCalled();
189
+
190
+ started.resolve("product");
191
+ await flush();
192
+ expect(handler.change).toHaveBeenCalledTimes(1);
193
+ expect(handler.change).toHaveBeenCalledWith("product", c2);
194
+ expect(c2.free).toHaveBeenCalledTimes(1);
195
+ });
196
+
197
+ it("applies a change directly to a live product and frees it after an async change", async () => {
198
+ const changing = deferred<undefined>();
199
+ const handler = makeHandler(async () => "product", {
200
+ change: vi.fn(() => changing.promise),
201
+ });
202
+ const d = new JobDispatcher();
203
+ d.register(handler);
204
+
205
+ d.tick(events({ mesh_added: [ev(1)] }));
206
+ await flush();
207
+
208
+ const c = ev(1);
209
+ d.tick(events({ mesh_updated: [c] }));
210
+ expect(handler.change).toHaveBeenCalledWith("product", c);
211
+ expect(c.free).not.toHaveBeenCalled();
212
+ expect(d.inFlight()).toBe(1);
213
+
214
+ // A remove during the async change waits for it.
215
+ const removed = ev(1);
216
+ d.tick(events({ mesh_removed: [removed] }));
217
+ expect(handler.teardown).not.toHaveBeenCalled();
218
+
219
+ changing.resolve(undefined);
220
+ await flush();
221
+ expect(c.free).toHaveBeenCalledTimes(1);
222
+ expect(handler.teardown).toHaveBeenCalledWith("product", removed);
223
+ expect(d.inFlight()).toBe(0);
224
+ });
225
+
226
+ it("settles a synchronous change in the same tick without holding the job in flight", async () => {
227
+ const handler = makeHandler(async () => "product", {
228
+ change: vi.fn(() => undefined),
229
+ });
230
+ const d = new JobDispatcher();
231
+ d.register(handler);
232
+
233
+ d.tick(events({ mesh_added: [ev(1)] }));
234
+ await flush();
235
+
236
+ const c = ev(1);
237
+ d.tick(events({ mesh_updated: [c] }));
238
+ expect(handler.change).toHaveBeenCalledWith("product", c);
239
+ expect(c.free).toHaveBeenCalledTimes(1);
240
+ expect(d.inFlight()).toBe(0);
241
+
242
+ // A remove right after it tears down at once.
243
+ const removed = ev(1);
244
+ d.tick(events({ mesh_removed: [removed] }));
245
+ expect(handler.teardown).toHaveBeenCalledWith("product", removed);
246
+ });
247
+
248
+ it("contains a synchronous throw from change", async () => {
249
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
250
+ const handler = makeHandler(async () => "product", {
251
+ change: vi.fn(() => {
252
+ throw new Error("boom");
253
+ }),
254
+ });
255
+ const d = new JobDispatcher();
256
+ d.register(handler);
257
+
258
+ d.tick(events({ mesh_added: [ev(1)] }));
259
+ await flush();
260
+
261
+ const c = ev(1);
262
+ d.tick(events({ mesh_updated: [c] }));
263
+ expect(error).toHaveBeenCalled();
264
+ expect(c.free).toHaveBeenCalledTimes(1);
265
+ expect(d.inFlight()).toBe(0);
266
+ error.mockRestore();
267
+ });
268
+
269
+ it("never overlaps changes on one job and applies only the latest waiting one", async () => {
270
+ const first = deferred<undefined>();
271
+ const handler = makeHandler(async () => "product", {
272
+ change: vi.fn((_: string, c: Ev) =>
273
+ c.ind === 1 && c.gen === 1 ? first.promise : undefined,
274
+ ),
275
+ });
276
+ const d = new JobDispatcher();
277
+ d.register(handler);
278
+ d.tick(events({ mesh_added: [ev(1)] }));
279
+ await flush();
280
+
281
+ const c1 = ev(1, 1);
282
+ const c2 = ev(1, 1);
283
+ const c3 = ev(1, 1);
284
+ d.tick(events({ mesh_updated: [c1] }));
285
+ d.tick(events({ mesh_updated: [c2] }));
286
+ d.tick(events({ mesh_updated: [c3] }));
287
+ expect(handler.change).toHaveBeenCalledTimes(1);
288
+ expect(c2.free).toHaveBeenCalledTimes(1);
289
+
290
+ first.resolve(undefined);
291
+ await flush();
292
+ expect(handler.change).toHaveBeenCalledTimes(2);
293
+ expect(handler.change).toHaveBeenLastCalledWith("product", c3);
294
+ expect(c1.free).toHaveBeenCalledTimes(1);
295
+ expect(c3.free).toHaveBeenCalledTimes(1);
296
+ expect(d.inFlight()).toBe(0);
297
+ });
298
+
299
+ it("fires the abort signal when a live job is removed", async () => {
300
+ let signal: AbortSignal | undefined;
301
+ const handler = makeHandler(async (_, abort) => {
302
+ signal = abort.signal;
303
+ return "product";
304
+ });
305
+ const d = new JobDispatcher();
306
+ d.register(handler);
307
+ d.tick(events({ mesh_added: [ev(1)] }));
308
+ await flush();
309
+ expect(signal?.aborted).toBe(false);
310
+
311
+ d.tick(events({ mesh_removed: [ev(1)] }));
312
+ expect(signal?.aborted).toBe(true);
313
+ expect(handler.teardown).toHaveBeenCalledWith("product", expect.anything());
314
+ });
315
+
316
+ it("drops a change for a job that resolved without a product", async () => {
317
+ const handler = makeHandler(async () => undefined);
318
+ const d = new JobDispatcher();
319
+ d.register(handler);
320
+
321
+ d.tick(events({ mesh_added: [ev(1)] }));
322
+ await flush();
323
+ const c = ev(1);
324
+ d.tick(events({ mesh_updated: [c] }));
325
+ expect(handler.change).not.toHaveBeenCalled();
326
+ expect(c.free).toHaveBeenCalledTimes(1);
327
+ });
328
+
329
+ it("forgets a job that settled without a product", async () => {
330
+ const handler = makeHandler(async () => undefined);
331
+ const d = new JobDispatcher();
332
+ d.register(handler);
333
+
334
+ d.tick(events({ mesh_added: [ev(1)] }));
335
+ await flush();
336
+ // A repeated request starts again instead of being deduped.
337
+ d.tick(events({ mesh_added: [ev(1)] }));
338
+ expect(handler.start).toHaveBeenCalledTimes(2);
339
+
340
+ await flush();
341
+ const removed = ev(1);
342
+ d.tick(events({ mesh_removed: [removed] }));
343
+ expect(handler.teardown).toHaveBeenCalledWith(undefined, removed);
344
+ });
345
+
346
+ it("reports a rejected start through failed, but not after an abort", async () => {
347
+ const consoleError = vi
348
+ .spyOn(console, "error")
349
+ .mockImplementation(() => undefined);
350
+ const rejecting = deferred<string>();
351
+ const handler = makeHandler((_, abort) =>
352
+ abort.signal.aborted ? Promise.reject(new Error("x")) : rejecting.promise,
353
+ );
354
+ const d = new JobDispatcher();
355
+ d.register(handler);
356
+
357
+ const a = ev(1);
358
+ d.tick(events({ mesh_added: [a] }));
359
+ const error = new Error("boom");
360
+ rejecting.reject(error);
361
+ await flush();
362
+ expect(handler.failed).toHaveBeenCalledWith(a, error);
363
+ expect(a.free).toHaveBeenCalledTimes(1);
364
+
365
+ // Aborted: rejection is expected, no report.
366
+ const pending = deferred<string>();
367
+ (handler.start as ReturnType<typeof vi.fn>).mockImplementation(
368
+ () => pending.promise,
369
+ );
370
+ d.tick(events({ mesh_added: [ev(2)] }));
371
+ d.tick(events({ mesh_removed: [ev(2)] }));
372
+ pending.reject(new Error("aborted"));
373
+ await flush();
374
+ expect(handler.failed).toHaveBeenCalledTimes(1);
375
+ expect(handler.teardown).toHaveBeenCalledWith(undefined, expect.anything());
376
+ consoleError.mockRestore();
377
+ });
378
+
379
+ it("reports a start that rejects after aborting its own signal", async () => {
380
+ const handler = makeHandler((_, abort) => {
381
+ abort.abort();
382
+ return Promise.reject(new Error("self-aborted"));
383
+ });
384
+ const d = new JobDispatcher();
385
+ d.register(handler);
386
+
387
+ const a = ev(1);
388
+ d.tick(events({ mesh_added: [a] }));
389
+ await flush();
390
+ expect(handler.failed).toHaveBeenCalledWith(a, expect.any(Error));
391
+ expect(handler.teardown).not.toHaveBeenCalled();
392
+ });
393
+
394
+ it("contains a synchronous throw from start", async () => {
395
+ const consoleError = vi
396
+ .spyOn(console, "error")
397
+ .mockImplementation(() => undefined);
398
+ const handler = makeHandler(() => {
399
+ throw new Error("sync");
400
+ });
401
+ const d = new JobDispatcher();
402
+ d.register(handler);
403
+
404
+ const a = ev(1);
405
+ expect(() => d.tick(events({ mesh_added: [a] }))).not.toThrow();
406
+ await flush();
407
+ expect(handler.failed).toHaveBeenCalledTimes(1);
408
+ expect(a.free).toHaveBeenCalledTimes(1);
409
+ expect(d.inFlight()).toBe(0);
410
+ consoleError.mockRestore();
411
+ });
412
+
413
+ it("frees exactly what it took", async () => {
414
+ const handler = makeHandler(async () => "product");
415
+ const d = new JobDispatcher();
416
+ d.register(handler);
417
+ const sync = vi.fn();
418
+ d.registerSync("mesh_geometry_replaced", sync);
419
+
420
+ const taken = [ev(1), ev(2), ev(1), ev(3), ev(2), ev(7)];
421
+ const [a1, a2, c1, r3, r2, g7] = taken;
422
+ d.tick(
423
+ makeEvent({
424
+ mesh_added: [a1, a2],
425
+ mesh_updated: [c1],
426
+ mesh_removed: [r3, r2],
427
+ mesh_geometry_replaced: [g7],
428
+ } as unknown as Parameters<typeof makeEvent>[0]),
429
+ );
430
+ await flush();
431
+
432
+ expect(sync).toHaveBeenCalledWith(g7);
433
+ for (const e of taken) {
434
+ expect(e.free).toHaveBeenCalledTimes(1);
435
+ }
436
+ expect(d.inFlight()).toBe(0);
437
+ });
438
+
439
+ it("calls onSettled only when a product landed", async () => {
440
+ const onSettled = vi.fn();
441
+ const handler = makeHandler(async (e: Ev) =>
442
+ e.ind === 1 ? "product" : undefined,
443
+ );
444
+ const d = new JobDispatcher({ onSettled });
445
+ d.register(handler);
446
+
447
+ d.tick(events({ mesh_added: [ev(1), ev(2)] }));
448
+ await flush();
449
+ expect(onSettled).toHaveBeenCalledTimes(1);
450
+ });
451
+
452
+ it("aborts everything on dispose and tears down late products without an event", async () => {
453
+ const started = deferred<string>();
454
+ let signal: AbortSignal | undefined;
455
+ const handler = makeHandler((_, abort) => {
456
+ signal = abort.signal;
457
+ return started.promise;
458
+ });
459
+ const d = new JobDispatcher();
460
+ d.register(handler);
461
+
462
+ d.tick(events({ mesh_added: [ev(1)] }));
463
+ d.dispose();
464
+ expect(signal?.aborted).toBe(true);
465
+
466
+ started.resolve("late");
467
+ await flush();
468
+ expect(handler.teardown).toHaveBeenCalledWith("late", undefined);
469
+
470
+ d.tick(events({ mesh_added: [ev(2)] }));
471
+ expect(handler.start).toHaveBeenCalledTimes(1);
472
+ });
473
+
474
+ it("ignores stacks it has no handler for", () => {
475
+ const d = new JobDispatcher();
476
+ const untouched = ev(1);
477
+ expect(() =>
478
+ d.tick({
479
+ take_mesh_added: () => [untouched],
480
+ } as unknown as Events),
481
+ ).not.toThrow();
482
+ expect(untouched.free).not.toHaveBeenCalled();
483
+ });
484
+ });