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