@viewscript/renderer 0.1.0-202605140721 → 0.1.0-202605141229

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,650 @@
1
+ /**
2
+ * FFI Integration Tests (Layer 1)
3
+ *
4
+ * Tests the JS-side pipeline integration:
5
+ * Vite Plugin (Manifest) -> FfiDispatcher -> WasmSolverBridge -> render-loop
6
+ *
7
+ * WASM engine is mocked to isolate JS-side behavior.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
11
+ import {
12
+ FfiDispatcher,
13
+ createFfiDispatcher,
14
+ type FfiManifest,
15
+ type PendingFfiCall,
16
+ } from '../ffi-dispatcher.js';
17
+ import {
18
+ WasmSolverBridge,
19
+ createWasmSolverBridge,
20
+ type WasmEngine,
21
+ type TStateMutation,
22
+ } from '../wasm-solver-bridge.js';
23
+ import { generateManifest, type ManifestContext } from '../../../../vite-plugin/src/manifest.js';
24
+ import type { VsParseResult } from '../../../../vite-plugin/src/vs-parser.js';
25
+ import type { EsmAnalysisResult } from '../../../../vite-plugin/src/esm-analyzer.js';
26
+
27
+ // =============================================================================
28
+ // Mock WASM Engine
29
+ // =============================================================================
30
+
31
+ interface TickResult {
32
+ pending_ffi_calls: PendingFfiCall[];
33
+ }
34
+
35
+ interface QSnapshot {
36
+ values: Record<string, { type: string; value: number | boolean }>;
37
+ mutations: unknown[];
38
+ }
39
+
40
+ class MockWasmEngine implements WasmEngine {
41
+ private tickInputs: string[] = [];
42
+ private tickResponses: string[] = [];
43
+ private shouldThrow = false;
44
+ private throwError: Error | null = null;
45
+
46
+ /**
47
+ * Set the next response(s) for tick().
48
+ */
49
+ setNextResponse(result: TickResult): void {
50
+ this.tickResponses.push(JSON.stringify(result));
51
+ }
52
+
53
+ /**
54
+ * Configure tick() to throw on next call.
55
+ */
56
+ setThrowOnNextTick(error: Error): void {
57
+ this.shouldThrow = true;
58
+ this.throwError = error;
59
+ }
60
+
61
+ /**
62
+ * Get the last QSnapshot input to tick().
63
+ */
64
+ getLastInput(): QSnapshot | null {
65
+ if (this.tickInputs.length === 0) return null;
66
+ return JSON.parse(this.tickInputs[this.tickInputs.length - 1]) as QSnapshot;
67
+ }
68
+
69
+ /**
70
+ * Get all tick() inputs.
71
+ */
72
+ getAllInputs(): QSnapshot[] {
73
+ return this.tickInputs.map((json) => JSON.parse(json) as QSnapshot);
74
+ }
75
+
76
+ /**
77
+ * Get tick() call count.
78
+ */
79
+ getTickCount(): number {
80
+ return this.tickInputs.length;
81
+ }
82
+
83
+ /**
84
+ * Reset all recorded state.
85
+ */
86
+ reset(): void {
87
+ this.tickInputs = [];
88
+ this.tickResponses = [];
89
+ this.shouldThrow = false;
90
+ this.throwError = null;
91
+ }
92
+
93
+ tick(inputJson: string): string {
94
+ this.tickInputs.push(inputJson);
95
+
96
+ if (this.shouldThrow) {
97
+ this.shouldThrow = false;
98
+ throw this.throwError ?? new Error('Mock tick error');
99
+ }
100
+
101
+ return this.tickResponses.shift() ?? JSON.stringify({ pending_ffi_calls: [] });
102
+ }
103
+ }
104
+
105
+ // =============================================================================
106
+ // Test Fixtures
107
+ // =============================================================================
108
+
109
+ function createMockAnalysis(exports: string[]): EsmAnalysisResult {
110
+ return {
111
+ exports: exports.map((name) => ({ name, isReExport: false })),
112
+ hasDefaultExport: false,
113
+ };
114
+ }
115
+
116
+ function createBindManifestContext(bindings: {
117
+ bindName: string;
118
+ functionName: string;
119
+ args: string[];
120
+ }[]): ManifestContext {
121
+ const vsParseResult: VsParseResult = {
122
+ imports: [{ names: bindings.map((b) => b.functionName), modulePath: './math.ts', line: 1 }],
123
+ binds: bindings.map((b, i) => ({
124
+ bindName: b.bindName,
125
+ functionName: b.functionName,
126
+ args: b.args,
127
+ line: i + 2,
128
+ })),
129
+ triggers: [],
130
+ errors: [],
131
+ };
132
+
133
+ const resolvedModules = new Map([
134
+ [
135
+ './math.ts',
136
+ {
137
+ originalPath: './math.ts',
138
+ resolvedPath: '/project/src/math.ts',
139
+ analysis: createMockAnalysis(bindings.map((b) => b.functionName)),
140
+ },
141
+ ],
142
+ ]);
143
+
144
+ const entityMap = new Map([
145
+ ['button', 1],
146
+ ['cursor', 2],
147
+ ['player', 3],
148
+ ]);
149
+
150
+ return { vsParseResult, resolvedModules, entityMap };
151
+ }
152
+
153
+ function createTriggerManifestContext(triggers: {
154
+ triggerName: string;
155
+ conditionKind: string;
156
+ conditionArgs: string[];
157
+ functionName: string;
158
+ functionArgs: string[];
159
+ }[]): ManifestContext {
160
+ const vsParseResult: VsParseResult = {
161
+ imports: [{ names: triggers.map((t) => t.functionName), modulePath: './events.ts', line: 1 }],
162
+ binds: [],
163
+ triggers: triggers.map((t, i) => ({
164
+ triggerName: t.triggerName,
165
+ conditionKind: t.conditionKind,
166
+ conditionArgs: t.conditionArgs,
167
+ functionName: t.functionName,
168
+ functionArgs: t.functionArgs,
169
+ line: i + 2,
170
+ })),
171
+ errors: [],
172
+ };
173
+
174
+ const resolvedModules = new Map([
175
+ [
176
+ './events.ts',
177
+ {
178
+ originalPath: './events.ts',
179
+ resolvedPath: '/project/src/events.ts',
180
+ analysis: createMockAnalysis(triggers.map((t) => t.functionName)),
181
+ },
182
+ ],
183
+ ]);
184
+
185
+ const entityMap = new Map([
186
+ ['button', 1],
187
+ ['cursor', 2],
188
+ ['player', 3],
189
+ ]);
190
+
191
+ return { vsParseResult, resolvedModules, entityMap };
192
+ }
193
+
194
+ // =============================================================================
195
+ // E1: Manifest to Dispatcher Integration
196
+ // =============================================================================
197
+
198
+ describe('E1: manifest_to_dispatcher_integration', () => {
199
+ it('loads manifest and builds function registry', () => {
200
+ // 1. Generate manifest via Vite plugin
201
+ const context = createBindManifestContext([
202
+ { bindName: 'doubled', functionName: 'double', args: ['input'] },
203
+ { bindName: 'clamped', functionName: 'clamp', args: ['value', '0', '1'] },
204
+ ]);
205
+ const { manifest } = generateManifest(context);
206
+ expect(manifest).not.toBeNull();
207
+
208
+ // 2. Load manifest into dispatcher
209
+ const dispatcher = createFfiDispatcher();
210
+ dispatcher.loadManifest(manifest!);
211
+
212
+ // 3. Verify pending modules
213
+ expect(dispatcher.getPendingModules()).toContain('/project/src/math.ts');
214
+ expect(dispatcher.isReady()).toBe(false);
215
+
216
+ // 4. Register module
217
+ const mathModule = {
218
+ double: (x: number) => x * 2,
219
+ clamp: (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)),
220
+ };
221
+ dispatcher.registerModule('/project/src/math.ts', mathModule);
222
+
223
+ // 5. Verify ready state
224
+ expect(dispatcher.isReady()).toBe(true);
225
+ expect(dispatcher.getPendingModules()).toHaveLength(0);
226
+ });
227
+
228
+ it('preserves binding metadata from manifest', () => {
229
+ const context = createBindManifestContext([
230
+ { bindName: 'result', functionName: 'compute', args: ['42'] },
231
+ ]);
232
+ const { manifest } = generateManifest(context);
233
+
234
+ expect(manifest!.bindings).toHaveLength(1);
235
+ expect(manifest!.bindings[0]).toMatchObject({
236
+ bind_name: 'result',
237
+ export_name: 'compute',
238
+ module_path: '/project/src/math.ts',
239
+ });
240
+ });
241
+ });
242
+
243
+ // =============================================================================
244
+ // E2: Sync FFI Bind Full Cycle
245
+ // =============================================================================
246
+
247
+ describe('E2: sync_ffi_bind_full_cycle', () => {
248
+ let mockEngine: MockWasmEngine;
249
+ let bridge: WasmSolverBridge;
250
+ let dispatcher: FfiDispatcher;
251
+
252
+ beforeEach(() => {
253
+ mockEngine = new MockWasmEngine();
254
+ bridge = createWasmSolverBridge(mockEngine);
255
+ dispatcher = createFfiDispatcher();
256
+ });
257
+
258
+ it('completes full sync FFI cycle across frames', () => {
259
+ // 1. Generate manifest with bind
260
+ const context = createBindManifestContext([
261
+ { bindName: 'result', functionName: 'double', args: ['42'] },
262
+ ]);
263
+ const { manifest } = generateManifest(context);
264
+
265
+ // 2. Load manifest and register module
266
+ dispatcher.loadManifest(manifest!);
267
+ dispatcher.registerModule('/project/src/math.ts', {
268
+ double: (x: number) => x * 2,
269
+ });
270
+
271
+ // --- Frame N ---
272
+
273
+ // 3. First tick - no FFI results yet
274
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
275
+ const result1 = bridge.evaluate([]);
276
+
277
+ // 4. Verify no FFI values in first QSnapshot
278
+ const input1 = mockEngine.getLastInput();
279
+ expect(input1?.values).not.toHaveProperty('result');
280
+
281
+ // 5. Second tick returns pending FFI call
282
+ mockEngine.setNextResponse({
283
+ pending_ffi_calls: [{ ffi_id: 1, args: [42] }],
284
+ });
285
+ const result2 = bridge.evaluate([]);
286
+
287
+ // 6. Dispatch FFI call
288
+ expect(result2.pendingFfiCalls).toHaveLength(1);
289
+ dispatcher.dispatch(result2.pendingFfiCalls);
290
+
291
+ // 7. Drain results (sync function returns immediately)
292
+ const ffiResults = dispatcher.drainResults();
293
+ expect(ffiResults).toHaveLength(1);
294
+ expect(ffiResults[0].name).toBe('result');
295
+ expect(ffiResults[0].value).toBe(84); // 42 * 2
296
+
297
+ // --- Frame N+1 ---
298
+
299
+ // 8. Inject FFI results for next frame
300
+ bridge.injectFfiResults(ffiResults);
301
+
302
+ // 9. Next tick should have FFI result in QSnapshot
303
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
304
+ bridge.evaluate([]);
305
+
306
+ // 10. Verify FFI result was included in QSnapshot
307
+ const input3 = mockEngine.getLastInput();
308
+ expect(input3?.values['result']).toEqual({ type: 'float', value: 84 });
309
+ });
310
+ });
311
+
312
+ // =============================================================================
313
+ // E3: Async FFI Bind Multi-Frame
314
+ // =============================================================================
315
+
316
+ describe('E3: async_ffi_bind_multi_frame', () => {
317
+ let mockEngine: MockWasmEngine;
318
+ let bridge: WasmSolverBridge;
319
+ let dispatcher: FfiDispatcher;
320
+
321
+ beforeEach(() => {
322
+ mockEngine = new MockWasmEngine();
323
+ bridge = createWasmSolverBridge(mockEngine);
324
+ dispatcher = createFfiDispatcher();
325
+ });
326
+
327
+ it('buffers async result for later frame', async () => {
328
+ // 1. Setup manifest with async function
329
+ const context = createBindManifestContext([
330
+ { bindName: 'async_result', functionName: 'fetchValue', args: [] },
331
+ ]);
332
+ const { manifest } = generateManifest(context);
333
+
334
+ // 2. Register async function
335
+ dispatcher.loadManifest(manifest!);
336
+ dispatcher.registerModule('/project/src/math.ts', {
337
+ fetchValue: async () => {
338
+ await new Promise((r) => setTimeout(r, 50));
339
+ return 999;
340
+ },
341
+ });
342
+
343
+ // --- Frame N: Dispatch async call ---
344
+ mockEngine.setNextResponse({
345
+ pending_ffi_calls: [{ ffi_id: 1, args: [] }],
346
+ });
347
+ const result = bridge.evaluate([]);
348
+ dispatcher.dispatch(result.pendingFfiCalls);
349
+
350
+ // 3. Immediately after dispatch, no result yet
351
+ expect(dispatcher.drainResults()).toHaveLength(0);
352
+ expect(dispatcher.getInflightCount()).toBe(1);
353
+
354
+ // --- Frame N+1: Still waiting ---
355
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
356
+ bridge.evaluate([]);
357
+ expect(dispatcher.drainResults()).toHaveLength(0);
358
+
359
+ // --- Wait for async completion ---
360
+ await new Promise((r) => setTimeout(r, 100));
361
+
362
+ // --- Frame N+2: Result available ---
363
+ expect(dispatcher.getInflightCount()).toBe(0);
364
+ const asyncResults = dispatcher.drainResults();
365
+ expect(asyncResults).toHaveLength(1);
366
+ expect(asyncResults[0].name).toBe('async_result');
367
+ expect(asyncResults[0].value).toBe(999);
368
+
369
+ // --- Frame N+3: Inject and verify ---
370
+ bridge.injectFfiResults(asyncResults);
371
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
372
+ bridge.evaluate([]);
373
+
374
+ const finalInput = mockEngine.getLastInput();
375
+ expect(finalInput?.values['async_result']).toEqual({ type: 'float', value: 999 });
376
+ });
377
+ });
378
+
379
+ // =============================================================================
380
+ // E4: Trigger Edge Detection Cycle
381
+ // =============================================================================
382
+
383
+ describe('E4: trigger_edge_detection_cycle', () => {
384
+ let mockEngine: MockWasmEngine;
385
+ let bridge: WasmSolverBridge;
386
+ let dispatcher: FfiDispatcher;
387
+
388
+ beforeEach(() => {
389
+ mockEngine = new MockWasmEngine();
390
+ bridge = createWasmSolverBridge(mockEngine);
391
+ dispatcher = createFfiDispatcher();
392
+ });
393
+
394
+ it('fires trigger only on false->true transition (rising edge)', () => {
395
+ // Setup: Trigger on bounds_overlap(button, cursor)
396
+ const context = createTriggerManifestContext([
397
+ {
398
+ triggerName: 'on_click',
399
+ conditionKind: 'bounds_overlap',
400
+ conditionArgs: ['button', 'cursor'],
401
+ functionName: 'handleClick',
402
+ functionArgs: [],
403
+ },
404
+ ]);
405
+ const { manifest } = generateManifest(context);
406
+
407
+ dispatcher.loadManifest(manifest!);
408
+ let clickCount = 0;
409
+ dispatcher.registerModule('/project/src/events.ts', {
410
+ handleClick: () => {
411
+ clickCount++;
412
+ return clickCount;
413
+ },
414
+ });
415
+
416
+ // Frame 1: Condition not met -> no trigger
417
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
418
+ const result1 = bridge.evaluate([]);
419
+ expect(result1.pendingFfiCalls).toHaveLength(0);
420
+
421
+ // Frame 2: Condition becomes true -> TRIGGER FIRES (rising edge)
422
+ // Note: With 0 bindings, trigger gets ffi_id=1
423
+ mockEngine.setNextResponse({
424
+ pending_ffi_calls: [{ ffi_id: 1, trigger_id: 1, args: [] }],
425
+ });
426
+ const result2 = bridge.evaluate([]);
427
+ expect(result2.pendingFfiCalls).toHaveLength(1);
428
+ dispatcher.dispatch(result2.pendingFfiCalls);
429
+ expect(clickCount).toBe(1);
430
+
431
+ // Frame 3: Condition still true -> NO TRIGGER (already triggered)
432
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
433
+ const result3 = bridge.evaluate([]);
434
+ expect(result3.pendingFfiCalls).toHaveLength(0);
435
+ // clickCount remains 1
436
+
437
+ // Frame 4: Condition becomes false (no trigger)
438
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
439
+ bridge.evaluate([]);
440
+
441
+ // Frame 5: Condition becomes true again -> TRIGGER FIRES
442
+ mockEngine.setNextResponse({
443
+ pending_ffi_calls: [{ ffi_id: 1, trigger_id: 1, args: [] }],
444
+ });
445
+ const result5 = bridge.evaluate([]);
446
+ dispatcher.dispatch(result5.pendingFfiCalls);
447
+ expect(clickCount).toBe(2);
448
+ });
449
+ });
450
+
451
+ // =============================================================================
452
+ // E5: Trigger Threshold Crossing
453
+ // =============================================================================
454
+
455
+ describe('E5: trigger_threshold_crossing', () => {
456
+ let mockEngine: MockWasmEngine;
457
+ let bridge: WasmSolverBridge;
458
+ let dispatcher: FfiDispatcher;
459
+
460
+ beforeEach(() => {
461
+ mockEngine = new MockWasmEngine();
462
+ bridge = createWasmSolverBridge(mockEngine);
463
+ dispatcher = createFfiDispatcher();
464
+ });
465
+
466
+ it('fires only when value crosses threshold in rising direction', () => {
467
+ // Setup: threshold_crossing(player.y, 100, rising)
468
+ const context = createTriggerManifestContext([
469
+ {
470
+ triggerName: 'ground_touch',
471
+ conditionKind: 'threshold_crossing',
472
+ conditionArgs: ['player.y', '100', 'rising'],
473
+ functionName: 'onGroundTouch',
474
+ functionArgs: [],
475
+ },
476
+ ]);
477
+ const { manifest } = generateManifest(context);
478
+
479
+ dispatcher.loadManifest(manifest!);
480
+ let touchCount = 0;
481
+ dispatcher.registerModule('/project/src/events.ts', {
482
+ onGroundTouch: () => ++touchCount,
483
+ });
484
+
485
+ // Simulate value progression: 50 -> 80 -> 110 -> 120 -> 90 -> 105
486
+
487
+ // Frame 1: player.y = 50 (below threshold, no trigger)
488
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
489
+ bridge.evaluate([]);
490
+ expect(touchCount).toBe(0);
491
+
492
+ // Frame 2: player.y = 80 (still below, no trigger)
493
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
494
+ bridge.evaluate([]);
495
+ expect(touchCount).toBe(0);
496
+
497
+ // Frame 3: player.y = 110 (CROSSES threshold rising) -> TRIGGER
498
+ // Note: With 0 bindings, trigger gets ffi_id=1
499
+ mockEngine.setNextResponse({
500
+ pending_ffi_calls: [{ ffi_id: 1, trigger_id: 1, args: [] }],
501
+ });
502
+ const result3 = bridge.evaluate([]);
503
+ dispatcher.dispatch(result3.pendingFfiCalls);
504
+ expect(touchCount).toBe(1);
505
+
506
+ // Frame 4: player.y = 120 (above, but already triggered)
507
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
508
+ bridge.evaluate([]);
509
+ expect(touchCount).toBe(1);
510
+
511
+ // Frame 5: player.y = 90 (back below threshold)
512
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
513
+ bridge.evaluate([]);
514
+ expect(touchCount).toBe(1);
515
+
516
+ // Frame 6: player.y = 105 (CROSSES again rising) -> TRIGGER
517
+ mockEngine.setNextResponse({
518
+ pending_ffi_calls: [{ ffi_id: 1, trigger_id: 1, args: [] }],
519
+ });
520
+ const result6 = bridge.evaluate([]);
521
+ dispatcher.dispatch(result6.pendingFfiCalls);
522
+ expect(touchCount).toBe(2);
523
+ });
524
+ });
525
+
526
+ // =============================================================================
527
+ // E6: FFI Result Survives Tick Failure
528
+ // =============================================================================
529
+
530
+ describe('E6: ffi_result_survives_tick_failure', () => {
531
+ let mockEngine: MockWasmEngine;
532
+ let bridge: WasmSolverBridge;
533
+ let dispatcher: FfiDispatcher;
534
+
535
+ beforeEach(() => {
536
+ mockEngine = new MockWasmEngine();
537
+ bridge = createWasmSolverBridge(mockEngine);
538
+ dispatcher = createFfiDispatcher();
539
+
540
+ // Setup basic manifest
541
+ const context = createBindManifestContext([
542
+ { bindName: 'computed', functionName: 'compute', args: ['10'] },
543
+ ]);
544
+ const { manifest } = generateManifest(context);
545
+ dispatcher.loadManifest(manifest!);
546
+ dispatcher.registerModule('/project/src/math.ts', {
547
+ compute: (x: number) => x * 3,
548
+ });
549
+ });
550
+
551
+ it('preserves FFI results when tick() throws', () => {
552
+ // Frame 1: Generate FFI result
553
+ mockEngine.setNextResponse({
554
+ pending_ffi_calls: [{ ffi_id: 1, args: [10] }],
555
+ });
556
+ const result1 = bridge.evaluate([]);
557
+ dispatcher.dispatch(result1.pendingFfiCalls);
558
+ const ffiResults = dispatcher.drainResults();
559
+ expect(ffiResults[0].value).toBe(30);
560
+
561
+ // Inject results for next frame
562
+ bridge.injectFfiResults(ffiResults);
563
+
564
+ // Frame 2: tick() throws
565
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
566
+ mockEngine.setThrowOnNextTick(new Error('WASM panic'));
567
+
568
+ const result2 = bridge.evaluate([]);
569
+
570
+ // Should return empty result but not crash
571
+ expect(result2.pendingFfiCalls).toHaveLength(0);
572
+ expect(result2.bounds.size).toBe(0);
573
+ errorSpy.mockRestore();
574
+
575
+ // Frame 3: tick() recovers - FFI results should still be in QSnapshot
576
+ // Because tick() failed, results were NOT cleared
577
+ mockEngine.setNextResponse({ pending_ffi_calls: [] });
578
+ bridge.evaluate([]);
579
+
580
+ // BUG CHECK: After tick failure, results should be preserved
581
+ // Current implementation: Results are injected into pendingFfiResults,
582
+ // and only cleared AFTER successful tick()
583
+ const input3 = mockEngine.getLastInput();
584
+
585
+ // The FFI result should still be present because the failed tick()
586
+ // did NOT clear pendingFfiResults
587
+ expect(input3?.values['computed']).toEqual({ type: 'float', value: 30 });
588
+ });
589
+ });
590
+
591
+ // =============================================================================
592
+ // E7: Async Inflight Guard
593
+ // =============================================================================
594
+
595
+ describe('E7: async_inflight_guard', () => {
596
+ let dispatcher: FfiDispatcher;
597
+
598
+ beforeEach(() => {
599
+ dispatcher = createFfiDispatcher();
600
+ });
601
+
602
+ it('prevents duplicate dispatch while async call is in-flight', async () => {
603
+ // Setup manifest
604
+ const context = createBindManifestContext([
605
+ { bindName: 'slow_result', functionName: 'slowCompute', args: [] },
606
+ ]);
607
+ const { manifest } = generateManifest(context);
608
+
609
+ // Register slow async function
610
+ let callCount = 0;
611
+ dispatcher.loadManifest(manifest!);
612
+ dispatcher.registerModule('/project/src/math.ts', {
613
+ slowCompute: async () => {
614
+ callCount++;
615
+ await new Promise((r) => setTimeout(r, 100));
616
+ return callCount * 100;
617
+ },
618
+ });
619
+
620
+ // First dispatch starts async call
621
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
622
+ expect(dispatcher.isInflight(1)).toBe(true);
623
+ expect(callCount).toBe(1);
624
+
625
+ // Subsequent dispatches while in-flight are ignored
626
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
627
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
628
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
629
+ expect(callCount).toBe(1); // Still only 1 call
630
+
631
+ // Wait for completion
632
+ await new Promise((r) => setTimeout(r, 150));
633
+
634
+ expect(dispatcher.isInflight(1)).toBe(false);
635
+ expect(dispatcher.getBufferedCount()).toBe(1);
636
+
637
+ // Now can dispatch again
638
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
639
+ expect(callCount).toBe(2);
640
+
641
+ // Wait and verify
642
+ await new Promise((r) => setTimeout(r, 150));
643
+ expect(dispatcher.getBufferedCount()).toBe(2);
644
+
645
+ const results = dispatcher.drainResults();
646
+ expect(results).toHaveLength(2);
647
+ expect(results[0].value).toBe(100);
648
+ expect(results[1].value).toBe(200);
649
+ });
650
+ });