@viewscript/renderer 0.1.0-202605140732 → 0.1.0-20260514130715

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,11 @@
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
+ export {};
@@ -0,0 +1,408 @@
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
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
12
+ import { FfiDispatcher, createFfiDispatcher, } from '../ffi-dispatcher.js';
13
+ // =============================================================================
14
+ // Test Fixtures
15
+ // =============================================================================
16
+ function createTestManifest() {
17
+ return {
18
+ version: 1,
19
+ entity_map: { button: 1, cursor: 2 },
20
+ bindings: [
21
+ {
22
+ ffi_id: 1,
23
+ bind_name: 'clamped_value',
24
+ module_path: '/src/math.ts',
25
+ export_name: 'clamp',
26
+ args: [
27
+ { type: 'q_ref', name: 'input_value' },
28
+ { type: 'static', value: 0 },
29
+ { type: 'static', value: 1 },
30
+ ],
31
+ },
32
+ {
33
+ ffi_id: 2,
34
+ bind_name: 'distance',
35
+ module_path: '/src/math.ts',
36
+ export_name: 'euclidean',
37
+ args: [
38
+ { type: 'entity_coord', entity_id: 1, component: 'x' },
39
+ { type: 'entity_coord', entity_id: 1, component: 'y' },
40
+ { type: 'entity_coord', entity_id: 2, component: 'x' },
41
+ { type: 'entity_coord', entity_id: 2, component: 'y' },
42
+ ],
43
+ },
44
+ ],
45
+ triggers: [
46
+ {
47
+ trigger_id: 1,
48
+ ffi_id: 3,
49
+ module_path: '/src/math.ts',
50
+ export_name: 'notify',
51
+ condition: { kind: 'bounds_overlap', entity_a: 1, entity_b: 2 },
52
+ args: [{ type: 'static', value: 'clicked' }],
53
+ },
54
+ ],
55
+ };
56
+ }
57
+ function createMockMathModule() {
58
+ return {
59
+ clamp: (value, min, max) => Math.max(min, Math.min(max, value)),
60
+ euclidean: (x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2),
61
+ notify: () => 1, // Trigger function
62
+ };
63
+ }
64
+ function createMockCoordResolver() {
65
+ const coords = {
66
+ '1': { x: 100, y: 200, width: 50, height: 30 },
67
+ '2': { x: 150, y: 250, width: 10, height: 10 },
68
+ };
69
+ return {
70
+ getEntityCoord(entityId, component) {
71
+ return coords[String(entityId)]?.[component] ?? 0;
72
+ },
73
+ };
74
+ }
75
+ function createMockQResolver() {
76
+ const values = {
77
+ input_value: 0.5,
78
+ hover_progress: 0.75,
79
+ };
80
+ return {
81
+ getQValue(name) {
82
+ return values[name] ?? 0;
83
+ },
84
+ };
85
+ }
86
+ // =============================================================================
87
+ // Tests
88
+ // =============================================================================
89
+ describe('FfiDispatcher', () => {
90
+ let dispatcher;
91
+ beforeEach(() => {
92
+ dispatcher = createFfiDispatcher();
93
+ });
94
+ // ===========================================================================
95
+ // Manifest Loading
96
+ // ===========================================================================
97
+ describe('loadManifest', () => {
98
+ it('registers bindings from manifest', () => {
99
+ const manifest = createTestManifest();
100
+ dispatcher.loadManifest(manifest);
101
+ expect(dispatcher.getPendingModules()).toContain('/src/math.ts');
102
+ });
103
+ it('tracks pending modules for import', () => {
104
+ const manifest = createTestManifest();
105
+ dispatcher.loadManifest(manifest);
106
+ expect(dispatcher.isReady()).toBe(false);
107
+ expect(dispatcher.getPendingModules()).toHaveLength(1);
108
+ });
109
+ it('clears previous registry on reload', () => {
110
+ const manifest1 = createTestManifest();
111
+ dispatcher.loadManifest(manifest1);
112
+ const manifest2 = {
113
+ version: 1,
114
+ entity_map: {},
115
+ bindings: [],
116
+ triggers: [],
117
+ };
118
+ dispatcher.loadManifest(manifest2);
119
+ expect(dispatcher.getPendingModules()).toHaveLength(0);
120
+ expect(dispatcher.isReady()).toBe(true);
121
+ });
122
+ });
123
+ // ===========================================================================
124
+ // Module Registration
125
+ // ===========================================================================
126
+ describe('registerModule', () => {
127
+ it('resolves functions from imported module', () => {
128
+ const manifest = createTestManifest();
129
+ dispatcher.loadManifest(manifest);
130
+ const mathModule = createMockMathModule();
131
+ dispatcher.registerModule('/src/math.ts', mathModule);
132
+ expect(dispatcher.isReady()).toBe(true);
133
+ expect(dispatcher.getPendingModules()).toHaveLength(0);
134
+ });
135
+ it('warns for missing exports', () => {
136
+ const manifest = createTestManifest();
137
+ dispatcher.loadManifest(manifest);
138
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
139
+ dispatcher.registerModule('/src/math.ts', { clamp: () => 0 }); // missing euclidean
140
+ expect(warnSpy).toHaveBeenCalled();
141
+ warnSpy.mockRestore();
142
+ });
143
+ it('warns for non-function exports', () => {
144
+ const manifest = createTestManifest();
145
+ dispatcher.loadManifest(manifest);
146
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
147
+ dispatcher.registerModule('/src/math.ts', {
148
+ clamp: 'not a function',
149
+ euclidean: () => 0,
150
+ });
151
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('is not a function'));
152
+ warnSpy.mockRestore();
153
+ });
154
+ });
155
+ // ===========================================================================
156
+ // Dispatch
157
+ // ===========================================================================
158
+ describe('dispatch', () => {
159
+ beforeEach(() => {
160
+ const manifest = createTestManifest();
161
+ dispatcher.loadManifest(manifest);
162
+ dispatcher.registerModule('/src/math.ts', createMockMathModule());
163
+ dispatcher.setQResolver(createMockQResolver());
164
+ dispatcher.setCoordinateResolver(createMockCoordResolver());
165
+ });
166
+ it('calls registered function with resolved args', () => {
167
+ const calls = [{ ffi_id: 1, args: [] }];
168
+ dispatcher.dispatch(calls);
169
+ const results = dispatcher.drainResults();
170
+ expect(results).toHaveLength(1);
171
+ expect(results[0].name).toBe('clamped_value');
172
+ expect(results[0].value).toBe(0.5); // clamp(0.5, 0, 1) = 0.5
173
+ });
174
+ it('uses runtime args when provided', () => {
175
+ const calls = [{ ffi_id: 1, args: [1.5, 0, 1] }];
176
+ dispatcher.dispatch(calls);
177
+ const results = dispatcher.drainResults();
178
+ expect(results[0].value).toBe(1); // clamp(1.5, 0, 1) = 1
179
+ });
180
+ it('resolves entity coordinates', () => {
181
+ const calls = [{ ffi_id: 2, args: [] }];
182
+ dispatcher.dispatch(calls);
183
+ const results = dispatcher.drainResults();
184
+ expect(results).toHaveLength(1);
185
+ expect(results[0].name).toBe('distance');
186
+ // euclidean(100, 200, 150, 250) = sqrt(50^2 + 50^2) ≈ 70.71
187
+ expect(results[0].value).toBeCloseTo(70.71, 1);
188
+ });
189
+ it('buffers multiple results', () => {
190
+ const calls = [
191
+ { ffi_id: 1, args: [0.3, 0, 1] },
192
+ { ffi_id: 1, args: [0.7, 0, 1] },
193
+ ];
194
+ dispatcher.dispatch(calls);
195
+ expect(dispatcher.getBufferedCount()).toBe(2);
196
+ });
197
+ it('handles unknown ffi_id gracefully', () => {
198
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
199
+ const calls = [{ ffi_id: 999, args: [] }];
200
+ dispatcher.dispatch(calls);
201
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown ffi_id'));
202
+ expect(dispatcher.getBufferedCount()).toBe(0);
203
+ warnSpy.mockRestore();
204
+ });
205
+ it('handles function errors gracefully', () => {
206
+ // Register a throwing function
207
+ dispatcher.registerModule('/src/math.ts', {
208
+ clamp: () => {
209
+ throw new Error('Test error');
210
+ },
211
+ euclidean: () => 0,
212
+ });
213
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
214
+ const calls = [{ ffi_id: 1, args: [0.5, 0, 1] }];
215
+ dispatcher.dispatch(calls);
216
+ expect(errorSpy).toHaveBeenCalled();
217
+ expect(dispatcher.getBufferedCount()).toBe(0);
218
+ errorSpy.mockRestore();
219
+ });
220
+ it('ignores undefined/null results (side-effect functions)', () => {
221
+ dispatcher.registerModule('/src/math.ts', {
222
+ clamp: () => undefined,
223
+ euclidean: () => null,
224
+ });
225
+ const calls = [
226
+ { ffi_id: 1, args: [] },
227
+ { ffi_id: 2, args: [] },
228
+ ];
229
+ dispatcher.dispatch(calls);
230
+ expect(dispatcher.getBufferedCount()).toBe(0);
231
+ });
232
+ it('coerces non-number results to numbers', () => {
233
+ dispatcher.registerModule('/src/math.ts', {
234
+ clamp: () => '42',
235
+ euclidean: () => 0,
236
+ });
237
+ const calls = [{ ffi_id: 1, args: [] }];
238
+ dispatcher.dispatch(calls);
239
+ const results = dispatcher.drainResults();
240
+ expect(results[0].value).toBe(42);
241
+ });
242
+ });
243
+ // ===========================================================================
244
+ // Result Draining
245
+ // ===========================================================================
246
+ describe('drainResults', () => {
247
+ beforeEach(() => {
248
+ const manifest = createTestManifest();
249
+ dispatcher.loadManifest(manifest);
250
+ dispatcher.registerModule('/src/math.ts', createMockMathModule());
251
+ dispatcher.setQResolver(createMockQResolver());
252
+ });
253
+ it('returns buffered results', () => {
254
+ dispatcher.dispatch([{ ffi_id: 1, args: [0.5, 0, 1] }]);
255
+ const results = dispatcher.drainResults();
256
+ expect(results).toHaveLength(1);
257
+ expect(results[0]).toMatchObject({
258
+ name: 'clamped_value',
259
+ value: 0.5,
260
+ });
261
+ expect(results[0].timestamp).toBeGreaterThan(0);
262
+ });
263
+ it('clears buffer after drain', () => {
264
+ dispatcher.dispatch([{ ffi_id: 1, args: [0.5, 0, 1] }]);
265
+ dispatcher.drainResults();
266
+ const secondDrain = dispatcher.drainResults();
267
+ expect(secondDrain).toHaveLength(0);
268
+ });
269
+ it('returns empty array when no results', () => {
270
+ const results = dispatcher.drainResults();
271
+ expect(results).toEqual([]);
272
+ });
273
+ it('preserves order of dispatch', () => {
274
+ dispatcher.dispatch([
275
+ { ffi_id: 1, args: [0.1, 0, 1] },
276
+ { ffi_id: 1, args: [0.2, 0, 1] },
277
+ { ffi_id: 1, args: [0.3, 0, 1] },
278
+ ]);
279
+ const results = dispatcher.drainResults();
280
+ expect(results.map((r) => r.value)).toEqual([0.1, 0.2, 0.3]);
281
+ });
282
+ });
283
+ // ===========================================================================
284
+ // Async Dispatch
285
+ // ===========================================================================
286
+ describe('async dispatch', () => {
287
+ beforeEach(() => {
288
+ const manifest = createTestManifest();
289
+ dispatcher.loadManifest(manifest);
290
+ dispatcher.setQResolver(createMockQResolver());
291
+ });
292
+ it('handles async functions returning Promise<number>', async () => {
293
+ dispatcher.registerModule('/src/math.ts', {
294
+ clamp: async (value, min, max) => {
295
+ await new Promise((resolve) => setTimeout(resolve, 10));
296
+ return Math.max(min, Math.min(max, value));
297
+ },
298
+ euclidean: () => 0,
299
+ });
300
+ dispatcher.dispatch([{ ffi_id: 1, args: [0.5, 0, 1] }]);
301
+ // Result not immediately available
302
+ expect(dispatcher.getBufferedCount()).toBe(0);
303
+ expect(dispatcher.getInflightCount()).toBe(1);
304
+ // Wait for async completion
305
+ await new Promise((resolve) => setTimeout(resolve, 20));
306
+ expect(dispatcher.getBufferedCount()).toBe(1);
307
+ expect(dispatcher.getInflightCount()).toBe(0);
308
+ const results = dispatcher.drainResults();
309
+ expect(results[0].value).toBe(0.5);
310
+ });
311
+ it('prevents duplicate dispatch while async call is in-flight', async () => {
312
+ let callCount = 0;
313
+ dispatcher.registerModule('/src/math.ts', {
314
+ clamp: async () => {
315
+ callCount++;
316
+ await new Promise((resolve) => setTimeout(resolve, 50));
317
+ return callCount;
318
+ },
319
+ euclidean: () => 0,
320
+ });
321
+ // First dispatch starts async call
322
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
323
+ expect(dispatcher.isInflight(1)).toBe(true);
324
+ // Second dispatch while in-flight should be skipped
325
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
326
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
327
+ // Wait for completion
328
+ await new Promise((resolve) => setTimeout(resolve, 60));
329
+ // Only one call was made
330
+ expect(callCount).toBe(1);
331
+ expect(dispatcher.getBufferedCount()).toBe(1);
332
+ });
333
+ it('allows new dispatch after async call completes', async () => {
334
+ let callCount = 0;
335
+ dispatcher.registerModule('/src/math.ts', {
336
+ clamp: async () => {
337
+ callCount++;
338
+ await new Promise((resolve) => setTimeout(resolve, 10));
339
+ return callCount * 10;
340
+ },
341
+ euclidean: () => 0,
342
+ });
343
+ // First call
344
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
345
+ await new Promise((resolve) => setTimeout(resolve, 20));
346
+ // Second call after first completes
347
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
348
+ await new Promise((resolve) => setTimeout(resolve, 20));
349
+ expect(callCount).toBe(2);
350
+ const results = dispatcher.drainResults();
351
+ expect(results).toHaveLength(2);
352
+ expect(results[0].value).toBe(10);
353
+ expect(results[1].value).toBe(20);
354
+ });
355
+ it('handles async errors gracefully', async () => {
356
+ dispatcher.registerModule('/src/math.ts', {
357
+ clamp: async () => {
358
+ await new Promise((resolve) => setTimeout(resolve, 10));
359
+ throw new Error('Async failure');
360
+ },
361
+ euclidean: () => 0,
362
+ });
363
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
364
+ dispatcher.dispatch([{ ffi_id: 1, args: [] }]);
365
+ await new Promise((resolve) => setTimeout(resolve, 20));
366
+ expect(errorSpy).toHaveBeenCalled();
367
+ expect(dispatcher.getInflightCount()).toBe(0); // Cleared on error
368
+ expect(dispatcher.getBufferedCount()).toBe(0); // No result buffered
369
+ errorSpy.mockRestore();
370
+ });
371
+ it('mixes sync and async functions correctly', async () => {
372
+ dispatcher.registerModule('/src/math.ts', {
373
+ clamp: (value, min, max) => {
374
+ // Sync function
375
+ return Math.max(min, Math.min(max, value));
376
+ },
377
+ euclidean: async () => {
378
+ // Async function
379
+ await new Promise((resolve) => setTimeout(resolve, 10));
380
+ return 42;
381
+ },
382
+ });
383
+ dispatcher.dispatch([
384
+ { ffi_id: 1, args: [0.5, 0, 1] }, // sync
385
+ { ffi_id: 2, args: [] }, // async
386
+ ]);
387
+ // Sync result immediately available
388
+ expect(dispatcher.getBufferedCount()).toBe(1);
389
+ // Wait for async
390
+ await new Promise((resolve) => setTimeout(resolve, 20));
391
+ expect(dispatcher.getBufferedCount()).toBe(2);
392
+ const results = dispatcher.drainResults();
393
+ expect(results.map((r) => r.value)).toContain(0.5);
394
+ expect(results.map((r) => r.value)).toContain(42);
395
+ });
396
+ });
397
+ // ===========================================================================
398
+ // Factory Function
399
+ // ===========================================================================
400
+ describe('createFfiDispatcher', () => {
401
+ it('creates a new instance', () => {
402
+ const d1 = createFfiDispatcher();
403
+ const d2 = createFfiDispatcher();
404
+ expect(d1).not.toBe(d2);
405
+ expect(d1).toBeInstanceOf(FfiDispatcher);
406
+ });
407
+ });
408
+ });
@@ -0,0 +1,9 @@
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
+ export {};