@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.
- package/dist/runtime/__tests__/ffi-dispatcher.test.d.ts +11 -0
- package/dist/runtime/__tests__/ffi-dispatcher.test.js +408 -0
- package/dist/runtime/__tests__/ffi-integration.test.d.ts +9 -0
- package/dist/runtime/__tests__/ffi-integration.test.js +514 -0
- package/dist/runtime/__tests__/wasm-solver-bridge.test.d.ts +9 -0
- package/dist/runtime/__tests__/wasm-solver-bridge.test.js +214 -0
- package/dist/runtime/ffi-dispatcher.d.ts +217 -0
- package/dist/runtime/ffi-dispatcher.js +291 -0
- package/dist/runtime/render-loop.d.ts +46 -1
- package/dist/runtime/render-loop.js +67 -1
- package/dist/runtime/wasm-solver-bridge.d.ts +122 -0
- package/dist/runtime/wasm-solver-bridge.js +168 -0
- package/package.json +1 -1
- package/src/runtime/__tests__/ffi-dispatcher.test.ts +519 -0
- package/src/runtime/__tests__/ffi-integration.test.ts +650 -0
- package/src/runtime/__tests__/wasm-solver-bridge.test.ts +276 -0
- package/src/runtime/ffi-dispatcher.ts +451 -0
- package/src/runtime/render-loop.ts +90 -2
- package/src/runtime/wasm-solver-bridge.ts +268 -0
|
@@ -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
|
+
});
|