@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.
- 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,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
|
@@ -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
|
+
});
|