@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,168 @@
1
+ /**
2
+ * WASM Solver Bridge
3
+ *
4
+ * Implements ConstraintSolver by delegating to WasmViewScriptEngine.tick().
5
+ * Handles QSnapshot construction and TickResult parsing.
6
+ *
7
+ * ## Responsibilities
8
+ *
9
+ * 1. Convert TStateMutation[] to QSnapshot JSON format
10
+ * 2. Inject FFI results into QSnapshot.values (via injectFfiResults)
11
+ * 3. Call WasmViewScriptEngine.tick() with JSON payload
12
+ * 4. Parse TickResult and extract bounds + pendingFfiCalls
13
+ *
14
+ * ## Data Flow
15
+ *
16
+ * ```
17
+ * TStateMutation[] + FfiResult[]
18
+ * │
19
+ * ▼
20
+ * buildQSnapshot()
21
+ * │
22
+ * ▼
23
+ * QSnapshot JSON ─────► WasmViewScriptEngine.tick()
24
+ * │
25
+ * ▼
26
+ * TickResult JSON
27
+ * │
28
+ * ▼
29
+ * SolverEvaluationResult
30
+ * ```
31
+ */
32
+ // =============================================================================
33
+ // WASM Solver Bridge
34
+ // =============================================================================
35
+ export class WasmSolverBridge {
36
+ engine;
37
+ /** Pending FFI results to inject into next QSnapshot */
38
+ pendingFfiResults = [];
39
+ /** Entity name to ID mapping (for mutation conversion) */
40
+ entityNameToId = new Map();
41
+ /** Q-variable name to entity+state mapping */
42
+ qVarMapping = new Map();
43
+ constructor(engine) {
44
+ this.engine = engine;
45
+ }
46
+ // ===========================================================================
47
+ // Public API
48
+ // ===========================================================================
49
+ /**
50
+ * Inject FFI results for the next evaluate() call.
51
+ *
52
+ * Called from render-loop Phase 0.5. Results are consumed
53
+ * and cleared when evaluate() runs.
54
+ *
55
+ * @param results - FFI function results from previous frame
56
+ */
57
+ injectFfiResults(results) {
58
+ this.pendingFfiResults = results;
59
+ }
60
+ /**
61
+ * Register entity name to ID mapping.
62
+ *
63
+ * Called during initialization to enable mutation conversion.
64
+ */
65
+ registerEntity(name, id) {
66
+ this.entityNameToId.set(name, id);
67
+ }
68
+ /**
69
+ * Register Q-variable mapping.
70
+ *
71
+ * Maps Q-variable names to entity+state for mutation routing.
72
+ */
73
+ registerQVariable(name, entityId, state) {
74
+ this.qVarMapping.set(name, { entityId, state });
75
+ }
76
+ /**
77
+ * Evaluate constraints and return solver result.
78
+ *
79
+ * Implements ConstraintSolver interface.
80
+ */
81
+ evaluate(mutations) {
82
+ // 1. Build QSnapshot from mutations + FFI results
83
+ const qSnapshot = this.buildQSnapshot(mutations);
84
+ // 2. Call WASM tick()
85
+ let resultJson;
86
+ try {
87
+ resultJson = this.engine.tick(JSON.stringify(qSnapshot));
88
+ }
89
+ catch (error) {
90
+ // tick() failed - FFI results are preserved for next frame retry
91
+ console.error('[WasmSolverBridge] tick() error:', error);
92
+ return {
93
+ bounds: new Map(),
94
+ pendingFfiCalls: [],
95
+ };
96
+ }
97
+ // 3. tick() succeeded - now safe to clear FFI results
98
+ this.pendingFfiResults = [];
99
+ // 4. Parse TickResult
100
+ let tickResult;
101
+ try {
102
+ tickResult = JSON.parse(resultJson);
103
+ }
104
+ catch (error) {
105
+ console.error('[WasmSolverBridge] Failed to parse TickResult:', error);
106
+ return {
107
+ bounds: new Map(),
108
+ pendingFfiCalls: [],
109
+ };
110
+ }
111
+ // 5. Build SolverEvaluationResult
112
+ // Note: bounds extraction is deferred - WASM tick() currently
113
+ // handles rendering internally. This will be refactored when
114
+ // bounds are returned explicitly from tick().
115
+ return {
116
+ bounds: new Map(), // TODO: Extract from tick() when available
117
+ pendingFfiCalls: tickResult.pending_ffi_calls ?? [],
118
+ };
119
+ }
120
+ // ===========================================================================
121
+ // Internal Helpers
122
+ // ===========================================================================
123
+ /**
124
+ * Build QSnapshot from mutations and FFI results.
125
+ */
126
+ buildQSnapshot(mutations) {
127
+ const values = {};
128
+ // 1. Inject FFI results into values
129
+ for (const result of this.pendingFfiResults) {
130
+ values[result.name] = { type: 'float', value: result.value };
131
+ }
132
+ // 2. Convert TStateMutations to Q-values
133
+ // Each mutation targets an entity's T-state, which maps to a Q-variable
134
+ for (const mutation of mutations) {
135
+ const qVarName = this.getQVarName(mutation.entityId, mutation.state);
136
+ values[qVarName] = { type: 'float', value: mutation.value };
137
+ }
138
+ // 3. Build mutation array for legacy format compatibility
139
+ const mutationArray = mutations.map((m) => ({
140
+ entity_id: m.entityId,
141
+ state: m.state,
142
+ value: m.value,
143
+ }));
144
+ return {
145
+ values,
146
+ mutations: mutationArray,
147
+ };
148
+ }
149
+ /**
150
+ * Get Q-variable name for an entity's T-state.
151
+ *
152
+ * Convention: `{entityId}_{state}` (e.g., "1_hover", "2_scroll_y")
153
+ */
154
+ getQVarName(entityId, state) {
155
+ return `${entityId}_${state}`;
156
+ }
157
+ }
158
+ // =============================================================================
159
+ // Factory Function
160
+ // =============================================================================
161
+ /**
162
+ * Create a WASM solver bridge instance.
163
+ *
164
+ * @param engine - WasmViewScriptEngine instance
165
+ */
166
+ export function createWasmSolverBridge(engine) {
167
+ return new WasmSolverBridge(engine);
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viewscript/renderer",
3
- "version": "0.1.0-202605140721",
3
+ "version": "0.1.0-202605141229",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Tests for FFI Dispatcher
3
+ *
4
+ * Validates:
5
+ * 1. Manifest loading and function registry
6
+ * 2. Module registration
7
+ * 3. FFI call dispatch and result buffering
8
+ * 4. Argument resolution (static, q_ref, entity_coord)
9
+ * 5. Result draining for QSnapshot merge
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
13
+ import {
14
+ FfiDispatcher,
15
+ createFfiDispatcher,
16
+ type FfiManifest,
17
+ type PendingFfiCall,
18
+ type CoordinateResolver,
19
+ type QVariableResolver,
20
+ type FfiResult,
21
+ } from '../ffi-dispatcher.js';
22
+
23
+ // =============================================================================
24
+ // Test Fixtures
25
+ // =============================================================================
26
+
27
+ function createTestManifest(): FfiManifest {
28
+ return {
29
+ version: 1,
30
+ entity_map: { button: 1, cursor: 2 },
31
+ bindings: [
32
+ {
33
+ ffi_id: 1,
34
+ bind_name: 'clamped_value',
35
+ module_path: '/src/math.ts',
36
+ export_name: 'clamp',
37
+ args: [
38
+ { type: 'q_ref', name: 'input_value' },
39
+ { type: 'static', value: 0 },
40
+ { type: 'static', value: 1 },
41
+ ],
42
+ },
43
+ {
44
+ ffi_id: 2,
45
+ bind_name: 'distance',
46
+ module_path: '/src/math.ts',
47
+ export_name: 'euclidean',
48
+ args: [
49
+ { type: 'entity_coord', entity_id: 1, component: 'x' },
50
+ { type: 'entity_coord', entity_id: 1, component: 'y' },
51
+ { type: 'entity_coord', entity_id: 2, component: 'x' },
52
+ { type: 'entity_coord', entity_id: 2, component: 'y' },
53
+ ],
54
+ },
55
+ ],
56
+ triggers: [
57
+ {
58
+ trigger_id: 1,
59
+ ffi_id: 3,
60
+ module_path: '/src/math.ts',
61
+ export_name: 'notify',
62
+ condition: { kind: 'bounds_overlap', entity_a: 1, entity_b: 2 },
63
+ args: [{ type: 'static', value: 'clicked' }],
64
+ },
65
+ ],
66
+ };
67
+ }
68
+
69
+ function createMockMathModule(): Record<string, unknown> {
70
+ return {
71
+ clamp: (value: number, min: number, max: number) =>
72
+ Math.max(min, Math.min(max, value)),
73
+ euclidean: (x1: number, y1: number, x2: number, y2: number) =>
74
+ Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2),
75
+ notify: () => 1, // Trigger function
76
+ };
77
+ }
78
+
79
+ function createMockCoordResolver(): CoordinateResolver {
80
+ const coords: Record<string, Record<string, number>> = {
81
+ '1': { x: 100, y: 200, width: 50, height: 30 },
82
+ '2': { x: 150, y: 250, width: 10, height: 10 },
83
+ };
84
+ return {
85
+ getEntityCoord(entityId: number, component: string): number {
86
+ return coords[String(entityId)]?.[component] ?? 0;
87
+ },
88
+ };
89
+ }
90
+
91
+ function createMockQResolver(): QVariableResolver {
92
+ const values: Record<string, number> = {
93
+ input_value: 0.5,
94
+ hover_progress: 0.75,
95
+ };
96
+ return {
97
+ getQValue(name: string): number {
98
+ return values[name] ?? 0;
99
+ },
100
+ };
101
+ }
102
+
103
+ // =============================================================================
104
+ // Tests
105
+ // =============================================================================
106
+
107
+ describe('FfiDispatcher', () => {
108
+ let dispatcher: FfiDispatcher;
109
+
110
+ beforeEach(() => {
111
+ dispatcher = createFfiDispatcher();
112
+ });
113
+
114
+ // ===========================================================================
115
+ // Manifest Loading
116
+ // ===========================================================================
117
+
118
+ describe('loadManifest', () => {
119
+ it('registers bindings from manifest', () => {
120
+ const manifest = createTestManifest();
121
+ dispatcher.loadManifest(manifest);
122
+
123
+ expect(dispatcher.getPendingModules()).toContain('/src/math.ts');
124
+ });
125
+
126
+ it('tracks pending modules for import', () => {
127
+ const manifest = createTestManifest();
128
+ dispatcher.loadManifest(manifest);
129
+
130
+ expect(dispatcher.isReady()).toBe(false);
131
+ expect(dispatcher.getPendingModules()).toHaveLength(1);
132
+ });
133
+
134
+ it('clears previous registry on reload', () => {
135
+ const manifest1 = createTestManifest();
136
+ dispatcher.loadManifest(manifest1);
137
+
138
+ const manifest2: FfiManifest = {
139
+ version: 1,
140
+ entity_map: {},
141
+ bindings: [],
142
+ triggers: [],
143
+ };
144
+ dispatcher.loadManifest(manifest2);
145
+
146
+ expect(dispatcher.getPendingModules()).toHaveLength(0);
147
+ expect(dispatcher.isReady()).toBe(true);
148
+ });
149
+ });
150
+
151
+ // ===========================================================================
152
+ // Module Registration
153
+ // ===========================================================================
154
+
155
+ describe('registerModule', () => {
156
+ it('resolves functions from imported module', () => {
157
+ const manifest = createTestManifest();
158
+ dispatcher.loadManifest(manifest);
159
+
160
+ const mathModule = createMockMathModule();
161
+ dispatcher.registerModule('/src/math.ts', mathModule);
162
+
163
+ expect(dispatcher.isReady()).toBe(true);
164
+ expect(dispatcher.getPendingModules()).toHaveLength(0);
165
+ });
166
+
167
+ it('warns for missing exports', () => {
168
+ const manifest = createTestManifest();
169
+ dispatcher.loadManifest(manifest);
170
+
171
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
172
+
173
+ dispatcher.registerModule('/src/math.ts', { clamp: () => 0 }); // missing euclidean
174
+
175
+ expect(warnSpy).toHaveBeenCalled();
176
+ warnSpy.mockRestore();
177
+ });
178
+
179
+ it('warns for non-function exports', () => {
180
+ const manifest = createTestManifest();
181
+ dispatcher.loadManifest(manifest);
182
+
183
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
184
+
185
+ dispatcher.registerModule('/src/math.ts', {
186
+ clamp: 'not a function',
187
+ euclidean: () => 0,
188
+ });
189
+
190
+ expect(warnSpy).toHaveBeenCalledWith(
191
+ expect.stringContaining('is not a function')
192
+ );
193
+ warnSpy.mockRestore();
194
+ });
195
+ });
196
+
197
+ // ===========================================================================
198
+ // Dispatch
199
+ // ===========================================================================
200
+
201
+ describe('dispatch', () => {
202
+ beforeEach(() => {
203
+ const manifest = createTestManifest();
204
+ dispatcher.loadManifest(manifest);
205
+ dispatcher.registerModule('/src/math.ts', createMockMathModule());
206
+ dispatcher.setQResolver(createMockQResolver());
207
+ dispatcher.setCoordinateResolver(createMockCoordResolver());
208
+ });
209
+
210
+ it('calls registered function with resolved args', () => {
211
+ const calls: PendingFfiCall[] = [{ ffi_id: 1, args: [] }];
212
+
213
+ dispatcher.dispatch(calls);
214
+
215
+ const results = dispatcher.drainResults();
216
+ expect(results).toHaveLength(1);
217
+ expect(results[0].name).toBe('clamped_value');
218
+ expect(results[0].value).toBe(0.5); // clamp(0.5, 0, 1) = 0.5
219
+ });
220
+
221
+ it('uses runtime args when provided', () => {
222
+ const calls: PendingFfiCall[] = [{ ffi_id: 1, args: [1.5, 0, 1] }];
223
+
224
+ dispatcher.dispatch(calls);
225
+
226
+ const results = dispatcher.drainResults();
227
+ expect(results[0].value).toBe(1); // clamp(1.5, 0, 1) = 1
228
+ });
229
+
230
+ it('resolves entity coordinates', () => {
231
+ const calls: PendingFfiCall[] = [{ ffi_id: 2, args: [] }];
232
+
233
+ dispatcher.dispatch(calls);
234
+
235
+ const results = dispatcher.drainResults();
236
+ expect(results).toHaveLength(1);
237
+ expect(results[0].name).toBe('distance');
238
+ // euclidean(100, 200, 150, 250) = sqrt(50^2 + 50^2) ≈ 70.71
239
+ expect(results[0].value).toBeCloseTo(70.71, 1);
240
+ });
241
+
242
+ it('buffers multiple results', () => {
243
+ const calls: PendingFfiCall[] = [
244
+ { ffi_id: 1, args: [0.3, 0, 1] },
245
+ { ffi_id: 1, args: [0.7, 0, 1] },
246
+ ];
247
+
248
+ dispatcher.dispatch(calls);
249
+
250
+ expect(dispatcher.getBufferedCount()).toBe(2);
251
+ });
252
+
253
+ it('handles unknown ffi_id gracefully', () => {
254
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
255
+
256
+ const calls: PendingFfiCall[] = [{ ffi_id: 999, args: [] }];
257
+ dispatcher.dispatch(calls);
258
+
259
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown ffi_id'));
260
+ expect(dispatcher.getBufferedCount()).toBe(0);
261
+ warnSpy.mockRestore();
262
+ });
263
+
264
+ it('handles function errors gracefully', () => {
265
+ // Register a throwing function
266
+ dispatcher.registerModule('/src/math.ts', {
267
+ clamp: () => {
268
+ throw new Error('Test error');
269
+ },
270
+ euclidean: () => 0,
271
+ });
272
+
273
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
274
+
275
+ const calls: PendingFfiCall[] = [{ ffi_id: 1, args: [0.5, 0, 1] }];
276
+ dispatcher.dispatch(calls);
277
+
278
+ expect(errorSpy).toHaveBeenCalled();
279
+ expect(dispatcher.getBufferedCount()).toBe(0);
280
+ errorSpy.mockRestore();
281
+ });
282
+
283
+ it('ignores undefined/null results (side-effect functions)', () => {
284
+ dispatcher.registerModule('/src/math.ts', {
285
+ clamp: () => undefined,
286
+ euclidean: () => null,
287
+ });
288
+
289
+ const calls: PendingFfiCall[] = [
290
+ { ffi_id: 1, args: [] },
291
+ { ffi_id: 2, args: [] },
292
+ ];
293
+ dispatcher.dispatch(calls);
294
+
295
+ expect(dispatcher.getBufferedCount()).toBe(0);
296
+ });
297
+
298
+ it('coerces non-number results to numbers', () => {
299
+ dispatcher.registerModule('/src/math.ts', {
300
+ clamp: () => '42',
301
+ euclidean: () => 0,
302
+ });
303
+
304
+ const calls: PendingFfiCall[] = [{ ffi_id: 1, args: [] }];
305
+ dispatcher.dispatch(calls);
306
+
307
+ const results = dispatcher.drainResults();
308
+ expect(results[0].value).toBe(42);
309
+ });
310
+ });
311
+
312
+ // ===========================================================================
313
+ // Result Draining
314
+ // ===========================================================================
315
+
316
+ describe('drainResults', () => {
317
+ beforeEach(() => {
318
+ const manifest = createTestManifest();
319
+ dispatcher.loadManifest(manifest);
320
+ dispatcher.registerModule('/src/math.ts', createMockMathModule());
321
+ dispatcher.setQResolver(createMockQResolver());
322
+ });
323
+
324
+ it('returns buffered results', () => {
325
+ dispatcher.dispatch([{ ffi_id: 1, args: [0.5, 0, 1] }]);
326
+
327
+ const results = dispatcher.drainResults();
328
+
329
+ expect(results).toHaveLength(1);
330
+ expect(results[0]).toMatchObject({
331
+ name: 'clamped_value',
332
+ value: 0.5,
333
+ });
334
+ expect(results[0].timestamp).toBeGreaterThan(0);
335
+ });
336
+
337
+ it('clears buffer after drain', () => {
338
+ dispatcher.dispatch([{ ffi_id: 1, args: [0.5, 0, 1] }]);
339
+
340
+ dispatcher.drainResults();
341
+ const secondDrain = dispatcher.drainResults();
342
+
343
+ expect(secondDrain).toHaveLength(0);
344
+ });
345
+
346
+ it('returns empty array when no results', () => {
347
+ const results = dispatcher.drainResults();
348
+
349
+ expect(results).toEqual([]);
350
+ });
351
+
352
+ it('preserves order of dispatch', () => {
353
+ dispatcher.dispatch([
354
+ { ffi_id: 1, args: [0.1, 0, 1] },
355
+ { ffi_id: 1, args: [0.2, 0, 1] },
356
+ { ffi_id: 1, args: [0.3, 0, 1] },
357
+ ]);
358
+
359
+ const results = dispatcher.drainResults();
360
+
361
+ expect(results.map((r: FfiResult) => r.value)).toEqual([0.1, 0.2, 0.3]);
362
+ });
363
+ });
364
+
365
+ // ===========================================================================
366
+ // Async Dispatch
367
+ // ===========================================================================
368
+
369
+ describe('async dispatch', () => {
370
+ beforeEach(() => {
371
+ const manifest = createTestManifest();
372
+ dispatcher.loadManifest(manifest);
373
+ dispatcher.setQResolver(createMockQResolver());
374
+ });
375
+
376
+ it('handles async functions returning Promise<number>', async () => {
377
+ dispatcher.registerModule('/src/math.ts', {
378
+ clamp: async (value: number, min: number, max: number) => {
379
+ await new Promise((resolve) => setTimeout(resolve, 10));
380
+ return Math.max(min, Math.min(max, value));
381
+ },
382
+ euclidean: () => 0,
383
+ });
384
+
385
+ dispatcher.dispatch([{ ffi_id: 1, args: [0.5, 0, 1] }]);
386
+
387
+ // Result not immediately available
388
+ expect(dispatcher.getBufferedCount()).toBe(0);
389
+ expect(dispatcher.getInflightCount()).toBe(1);
390
+
391
+ // Wait for async completion
392
+ await new Promise((resolve) => setTimeout(resolve, 20));
393
+
394
+ expect(dispatcher.getBufferedCount()).toBe(1);
395
+ expect(dispatcher.getInflightCount()).toBe(0);
396
+
397
+ const results = dispatcher.drainResults();
398
+ expect(results[0].value).toBe(0.5);
399
+ });
400
+
401
+ it('prevents duplicate dispatch while async call is in-flight', async () => {
402
+ let callCount = 0;
403
+ dispatcher.registerModule('/src/math.ts', {
404
+ clamp: async () => {
405
+ callCount++;
406
+ await new Promise((resolve) => setTimeout(resolve, 50));
407
+ return callCount;
408
+ },
409
+ euclidean: () => 0,
410
+ });
411
+
412
+ // First dispatch starts async call
413
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
414
+ expect(dispatcher.isInflight(1)).toBe(true);
415
+
416
+ // Second dispatch while in-flight should be skipped
417
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
418
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
419
+
420
+ // Wait for completion
421
+ await new Promise((resolve) => setTimeout(resolve, 60));
422
+
423
+ // Only one call was made
424
+ expect(callCount).toBe(1);
425
+ expect(dispatcher.getBufferedCount()).toBe(1);
426
+ });
427
+
428
+ it('allows new dispatch after async call completes', async () => {
429
+ let callCount = 0;
430
+ dispatcher.registerModule('/src/math.ts', {
431
+ clamp: async () => {
432
+ callCount++;
433
+ await new Promise((resolve) => setTimeout(resolve, 10));
434
+ return callCount * 10;
435
+ },
436
+ euclidean: () => 0,
437
+ });
438
+
439
+ // First call
440
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
441
+ await new Promise((resolve) => setTimeout(resolve, 20));
442
+
443
+ // Second call after first completes
444
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
445
+ await new Promise((resolve) => setTimeout(resolve, 20));
446
+
447
+ expect(callCount).toBe(2);
448
+ const results = dispatcher.drainResults();
449
+ expect(results).toHaveLength(2);
450
+ expect(results[0].value).toBe(10);
451
+ expect(results[1].value).toBe(20);
452
+ });
453
+
454
+ it('handles async errors gracefully', async () => {
455
+ dispatcher.registerModule('/src/math.ts', {
456
+ clamp: async () => {
457
+ await new Promise((resolve) => setTimeout(resolve, 10));
458
+ throw new Error('Async failure');
459
+ },
460
+ euclidean: () => 0,
461
+ });
462
+
463
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
464
+
465
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
466
+ await new Promise((resolve) => setTimeout(resolve, 20));
467
+
468
+ expect(errorSpy).toHaveBeenCalled();
469
+ expect(dispatcher.getInflightCount()).toBe(0); // Cleared on error
470
+ expect(dispatcher.getBufferedCount()).toBe(0); // No result buffered
471
+
472
+ errorSpy.mockRestore();
473
+ });
474
+
475
+ it('mixes sync and async functions correctly', async () => {
476
+ dispatcher.registerModule('/src/math.ts', {
477
+ clamp: (value: number, min: number, max: number) => {
478
+ // Sync function
479
+ return Math.max(min, Math.min(max, value));
480
+ },
481
+ euclidean: async () => {
482
+ // Async function
483
+ await new Promise((resolve) => setTimeout(resolve, 10));
484
+ return 42;
485
+ },
486
+ });
487
+
488
+ dispatcher.dispatch([
489
+ { ffi_id: 1, args: [0.5, 0, 1] }, // sync
490
+ { ffi_id: 2, args: [] }, // async
491
+ ]);
492
+
493
+ // Sync result immediately available
494
+ expect(dispatcher.getBufferedCount()).toBe(1);
495
+
496
+ // Wait for async
497
+ await new Promise((resolve) => setTimeout(resolve, 20));
498
+
499
+ expect(dispatcher.getBufferedCount()).toBe(2);
500
+ const results = dispatcher.drainResults();
501
+ expect(results.map((r: FfiResult) => r.value)).toContain(0.5);
502
+ expect(results.map((r: FfiResult) => r.value)).toContain(42);
503
+ });
504
+ });
505
+
506
+ // ===========================================================================
507
+ // Factory Function
508
+ // ===========================================================================
509
+
510
+ describe('createFfiDispatcher', () => {
511
+ it('creates a new instance', () => {
512
+ const d1 = createFfiDispatcher();
513
+ const d2 = createFfiDispatcher();
514
+
515
+ expect(d1).not.toBe(d2);
516
+ expect(d1).toBeInstanceOf(FfiDispatcher);
517
+ });
518
+ });
519
+ });