@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,214 @@
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
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
10
+ import { WasmSolverBridge, createWasmSolverBridge, } from '../wasm-solver-bridge.js';
11
+ // =============================================================================
12
+ // Mock WASM Engine
13
+ // =============================================================================
14
+ function createMockEngine() {
15
+ return {
16
+ lastInput: null,
17
+ tick(inputJson) {
18
+ this.lastInput = inputJson;
19
+ return JSON.stringify({ pending_ffi_calls: [] });
20
+ },
21
+ };
22
+ }
23
+ function createMockEngineWithCalls(calls) {
24
+ return {
25
+ tick() {
26
+ return JSON.stringify({ pending_ffi_calls: calls });
27
+ },
28
+ };
29
+ }
30
+ // =============================================================================
31
+ // Tests
32
+ // =============================================================================
33
+ describe('WasmSolverBridge', () => {
34
+ let bridge;
35
+ let mockEngine;
36
+ beforeEach(() => {
37
+ mockEngine = createMockEngine();
38
+ bridge = createWasmSolverBridge(mockEngine);
39
+ });
40
+ // ===========================================================================
41
+ // Basic Evaluation
42
+ // ===========================================================================
43
+ describe('evaluate', () => {
44
+ it('calls engine.tick() with QSnapshot JSON', () => {
45
+ const mutations = [];
46
+ bridge.evaluate(mutations);
47
+ expect(mockEngine.lastInput).not.toBeNull();
48
+ const snapshot = JSON.parse(mockEngine.lastInput);
49
+ expect(snapshot).toHaveProperty('values');
50
+ expect(snapshot).toHaveProperty('mutations');
51
+ });
52
+ it('returns empty bounds and pendingFfiCalls by default', () => {
53
+ const result = bridge.evaluate([]);
54
+ expect(result.bounds.size).toBe(0);
55
+ expect(result.pendingFfiCalls).toEqual([]);
56
+ });
57
+ it('extracts pendingFfiCalls from tick result', () => {
58
+ const calls = [{ ffi_id: 1, args: [1, 2, 3] }];
59
+ bridge = createWasmSolverBridge(createMockEngineWithCalls(calls));
60
+ const result = bridge.evaluate([]);
61
+ expect(result.pendingFfiCalls).toEqual(calls);
62
+ });
63
+ });
64
+ // ===========================================================================
65
+ // Mutation Conversion
66
+ // ===========================================================================
67
+ describe('mutation conversion', () => {
68
+ it('converts TStateMutation to QSnapshot values', () => {
69
+ const mutations = [
70
+ { entityId: 1, state: 'hover', value: 1, timestamp: 100 },
71
+ { entityId: 2, state: 'scroll_y', value: 0.5, timestamp: 100 },
72
+ ];
73
+ bridge.evaluate(mutations);
74
+ const snapshot = JSON.parse(mockEngine.lastInput);
75
+ expect(snapshot.values['1_hover']).toEqual({ type: 'float', value: 1 });
76
+ expect(snapshot.values['2_scroll_y']).toEqual({ type: 'float', value: 0.5 });
77
+ });
78
+ it('includes mutations array for legacy format', () => {
79
+ const mutations = [
80
+ { entityId: 1, state: 'pressed', value: 1, timestamp: 100 },
81
+ ];
82
+ bridge.evaluate(mutations);
83
+ const snapshot = JSON.parse(mockEngine.lastInput);
84
+ expect(snapshot.mutations).toHaveLength(1);
85
+ expect(snapshot.mutations[0]).toMatchObject({
86
+ entity_id: 1,
87
+ state: 'pressed',
88
+ value: 1,
89
+ });
90
+ });
91
+ });
92
+ // ===========================================================================
93
+ // FFI Result Injection
94
+ // ===========================================================================
95
+ describe('injectFfiResults', () => {
96
+ it('includes injected FFI results in QSnapshot values', () => {
97
+ const ffiResults = [
98
+ { name: 'clamped_opacity', value: 0.75, timestamp: 100 },
99
+ { name: 'distance', value: 42, timestamp: 100 },
100
+ ];
101
+ bridge.injectFfiResults(ffiResults);
102
+ bridge.evaluate([]);
103
+ const snapshot = JSON.parse(mockEngine.lastInput);
104
+ expect(snapshot.values['clamped_opacity']).toEqual({ type: 'float', value: 0.75 });
105
+ expect(snapshot.values['distance']).toEqual({ type: 'float', value: 42 });
106
+ });
107
+ it('clears FFI results after evaluate()', () => {
108
+ bridge.injectFfiResults([{ name: 'test', value: 1, timestamp: 100 }]);
109
+ // First evaluate includes FFI result
110
+ bridge.evaluate([]);
111
+ let snapshot = JSON.parse(mockEngine.lastInput);
112
+ expect(snapshot.values['test']).toBeDefined();
113
+ // Second evaluate should NOT include FFI result
114
+ bridge.evaluate([]);
115
+ snapshot = JSON.parse(mockEngine.lastInput);
116
+ expect(snapshot.values['test']).toBeUndefined();
117
+ });
118
+ it('FFI results override mutation values with same name', () => {
119
+ // This tests the merge order: FFI results come first, then mutations
120
+ // If there's a naming collision, the mutation should win
121
+ const ffiResults = [
122
+ { name: '1_hover', value: 0.5, timestamp: 100 },
123
+ ];
124
+ const mutations = [
125
+ { entityId: 1, state: 'hover', value: 1, timestamp: 100 },
126
+ ];
127
+ bridge.injectFfiResults(ffiResults);
128
+ bridge.evaluate(mutations);
129
+ const snapshot = JSON.parse(mockEngine.lastInput);
130
+ // Mutation overwrites FFI result (processed later)
131
+ expect(snapshot.values['1_hover'].value).toBe(1);
132
+ });
133
+ });
134
+ // ===========================================================================
135
+ // Error Handling
136
+ // ===========================================================================
137
+ describe('error handling', () => {
138
+ it('handles engine.tick() throwing', () => {
139
+ const errorEngine = {
140
+ tick() {
141
+ throw new Error('WASM panic');
142
+ },
143
+ };
144
+ bridge = createWasmSolverBridge(errorEngine);
145
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
146
+ const result = bridge.evaluate([]);
147
+ expect(result.bounds.size).toBe(0);
148
+ expect(result.pendingFfiCalls).toEqual([]);
149
+ expect(errorSpy).toHaveBeenCalled();
150
+ errorSpy.mockRestore();
151
+ });
152
+ it('preserves FFI results when tick() throws for next frame retry', () => {
153
+ let callCount = 0;
154
+ const flakyEngine = {
155
+ lastInput: null,
156
+ tick(inputJson) {
157
+ callCount++;
158
+ if (callCount === 1) {
159
+ throw new Error('Transient WASM error');
160
+ }
161
+ this.lastInput = inputJson;
162
+ return JSON.stringify({ pending_ffi_calls: [] });
163
+ },
164
+ };
165
+ bridge = createWasmSolverBridge(flakyEngine);
166
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
167
+ // Inject FFI results
168
+ bridge.injectFfiResults([{ name: 'important_value', value: 42, timestamp: 100 }]);
169
+ // First evaluate fails - FFI results should be preserved
170
+ bridge.evaluate([]);
171
+ expect(errorSpy).toHaveBeenCalled();
172
+ // Second evaluate succeeds - FFI results should still be included
173
+ bridge.evaluate([]);
174
+ const snapshot = JSON.parse(flakyEngine.lastInput);
175
+ expect(snapshot.values['important_value']).toEqual({ type: 'float', value: 42 });
176
+ errorSpy.mockRestore();
177
+ });
178
+ it('handles invalid JSON from tick()', () => {
179
+ const badEngine = {
180
+ tick() {
181
+ return 'not valid json';
182
+ },
183
+ };
184
+ bridge = createWasmSolverBridge(badEngine);
185
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
186
+ const result = bridge.evaluate([]);
187
+ expect(result.pendingFfiCalls).toEqual([]);
188
+ expect(errorSpy).toHaveBeenCalled();
189
+ errorSpy.mockRestore();
190
+ });
191
+ it('handles missing pending_ffi_calls in result', () => {
192
+ const partialEngine = {
193
+ tick() {
194
+ return JSON.stringify({}); // No pending_ffi_calls field
195
+ },
196
+ };
197
+ bridge = createWasmSolverBridge(partialEngine);
198
+ const result = bridge.evaluate([]);
199
+ expect(result.pendingFfiCalls).toEqual([]);
200
+ });
201
+ });
202
+ // ===========================================================================
203
+ // Factory Function
204
+ // ===========================================================================
205
+ describe('createWasmSolverBridge', () => {
206
+ it('creates a new instance', () => {
207
+ const engine = createMockEngine();
208
+ const b1 = createWasmSolverBridge(engine);
209
+ const b2 = createWasmSolverBridge(engine);
210
+ expect(b1).not.toBe(b2);
211
+ expect(b1).toBeInstanceOf(WasmSolverBridge);
212
+ });
213
+ });
214
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * FFI Dispatcher for ViewScript Runtime
3
+ *
4
+ * Evaluates pending FFI calls after frame commit (Phase 8) and buffers
5
+ * results for consumption in the next frame's QSnapshot merge (Phase 0-1).
6
+ *
7
+ * ## Design Rationale
8
+ *
9
+ * FFI function execution time is non-deterministic (user-defined functions).
10
+ * By executing after commitFrame(), we:
11
+ * 1. Keep the rendering critical path (Phase 2-7) free of unpredictable latency
12
+ * 2. Utilize the idle time between commitFrame() and the next rAF callback
13
+ * 3. Maintain 1-frame latency for FFI results (Axiom 2: Ouroboros Binding)
14
+ *
15
+ * ## Data Flow
16
+ *
17
+ * ```
18
+ * Frame N:
19
+ * Phase 2: tick() → TickResult { pending_ffi_calls }
20
+ * Phase 8: dispatch(pending_ffi_calls) → buffer results
21
+ *
22
+ * Frame N+1:
23
+ * Phase 0-1: drainResults() → QSnapshot.values merge
24
+ * Phase 2: solver sees FFI results as Q-dimension input
25
+ * ```
26
+ */
27
+ /**
28
+ * FFI argument types from manifest.
29
+ */
30
+ export type FfiArg = {
31
+ type: 'static';
32
+ value: unknown;
33
+ } | {
34
+ type: 'q_ref';
35
+ name: string;
36
+ } | {
37
+ type: 'entity_coord';
38
+ entity_id: number;
39
+ component: string;
40
+ };
41
+ /**
42
+ * FFI binding from manifest.
43
+ */
44
+ export interface FfiBinding {
45
+ ffi_id: number;
46
+ bind_name: string;
47
+ module_path: string;
48
+ export_name: string;
49
+ args: FfiArg[];
50
+ }
51
+ /**
52
+ * FFI trigger from manifest.
53
+ */
54
+ export interface FfiTrigger {
55
+ trigger_id: number;
56
+ ffi_id: number;
57
+ module_path: string;
58
+ export_name: string;
59
+ condition: {
60
+ kind: string;
61
+ entity_a?: number;
62
+ entity_b?: number;
63
+ };
64
+ args: FfiArg[];
65
+ }
66
+ /**
67
+ * Complete FFI manifest.
68
+ */
69
+ export interface FfiManifest {
70
+ version: number;
71
+ entity_map: Record<string, number>;
72
+ bindings: FfiBinding[];
73
+ triggers: FfiTrigger[];
74
+ }
75
+ /**
76
+ * Pending FFI call from WASM tick() result.
77
+ */
78
+ export interface PendingFfiCall {
79
+ ffi_id: number;
80
+ trigger_id?: number;
81
+ args: unknown[];
82
+ }
83
+ /**
84
+ * Result of an FFI call, ready for QSnapshot merge.
85
+ */
86
+ export interface FfiResult {
87
+ /** Q-variable name to update */
88
+ name: string;
89
+ /** Computed value */
90
+ value: number;
91
+ /** Timestamp for event ordering */
92
+ timestamp: number;
93
+ }
94
+ export declare class FfiDispatcher {
95
+ /** Function registry: ffi_id -> entry */
96
+ private registry;
97
+ /** Pending modules awaiting registration */
98
+ private pendingModules;
99
+ /** Buffered results for next frame */
100
+ private resultBuffer;
101
+ /** In-flight async calls (prevents duplicate dispatch while awaiting) */
102
+ private inflight;
103
+ /** Entity coordinate resolver (injected) */
104
+ private coordResolver;
105
+ /** Q-variable resolver (injected) */
106
+ private qResolver;
107
+ /**
108
+ * Load FFI manifest and build function registry.
109
+ *
110
+ * @param manifest - Parsed FfiManifest JSON
111
+ */
112
+ loadManifest(manifest: FfiManifest): void;
113
+ /**
114
+ * Register a dynamically imported ESM module.
115
+ *
116
+ * Call this after `import(modulePath)` resolves.
117
+ *
118
+ * @param modulePath - Resolved module path (must match manifest's module_path)
119
+ * @param module - The imported module object
120
+ */
121
+ registerModule(modulePath: string, module: Record<string, unknown>): void;
122
+ /**
123
+ * Set the coordinate resolver for EntityCoord arguments.
124
+ */
125
+ setCoordinateResolver(resolver: CoordinateResolver): void;
126
+ /**
127
+ * Set the Q-variable resolver for QRef arguments.
128
+ */
129
+ setQResolver(resolver: QVariableResolver): void;
130
+ /**
131
+ * Get list of module paths that need to be imported.
132
+ */
133
+ getPendingModules(): string[];
134
+ /**
135
+ * Check if all modules have been registered.
136
+ */
137
+ isReady(): boolean;
138
+ /**
139
+ * Dispatch pending FFI calls (Phase 8).
140
+ *
141
+ * Evaluates JS functions and buffers results. Supports both sync and async
142
+ * functions. For async functions, results are buffered when the Promise
143
+ * resolves (may be multiple frames later).
144
+ *
145
+ * In-flight guard: If an async call for a given ffi_id is still pending,
146
+ * subsequent dispatch requests for the same ffi_id are skipped until the
147
+ * previous call completes.
148
+ *
149
+ * @param pendingCalls - Array of PendingFfiCall from TickResult
150
+ */
151
+ dispatch(pendingCalls: PendingFfiCall[]): void;
152
+ /**
153
+ * Buffer a result value if it's numeric.
154
+ */
155
+ private bufferResult;
156
+ /**
157
+ * Drain buffered results for QSnapshot merge (Phase 0-1).
158
+ *
159
+ * Returns all buffered results and clears the buffer.
160
+ * Call this at the start of the next frame.
161
+ *
162
+ * @returns Array of FfiResult for QSnapshot.values merge
163
+ */
164
+ drainResults(): FfiResult[];
165
+ /**
166
+ * Get the number of buffered results (for diagnostics).
167
+ */
168
+ getBufferedCount(): number;
169
+ /**
170
+ * Get the number of in-flight async calls (for diagnostics/testing).
171
+ */
172
+ getInflightCount(): number;
173
+ /**
174
+ * Check if a specific ffi_id has an in-flight async call.
175
+ */
176
+ isInflight(ffiId: number): boolean;
177
+ /**
178
+ * Resolve FfiArg array to concrete values.
179
+ *
180
+ * @param argSpecs - Argument specifications from manifest
181
+ * @param runtimeArgs - Runtime argument overrides from PendingFfiCall
182
+ */
183
+ private resolveArgs;
184
+ /**
185
+ * Resolve a single FfiArg to its concrete value.
186
+ */
187
+ private resolveArg;
188
+ }
189
+ /**
190
+ * Interface for resolving entity coordinates.
191
+ */
192
+ export interface CoordinateResolver {
193
+ /**
194
+ * Get a coordinate component for an entity.
195
+ *
196
+ * @param entityId - Entity ID
197
+ * @param component - Component name ('x', 'y', 'width', 'height')
198
+ * @returns Coordinate value (integer pixels)
199
+ */
200
+ getEntityCoord(entityId: number, component: string): number;
201
+ }
202
+ /**
203
+ * Interface for resolving Q-variable values.
204
+ */
205
+ export interface QVariableResolver {
206
+ /**
207
+ * Get current value of a Q-variable.
208
+ *
209
+ * @param name - Q-variable name
210
+ * @returns Current value
211
+ */
212
+ getQValue(name: string): number;
213
+ }
214
+ /**
215
+ * Create a new FFI dispatcher instance.
216
+ */
217
+ export declare function createFfiDispatcher(): FfiDispatcher;