@viewscript/renderer 0.1.0-20260515015257 → 0.1.0-20260515015849
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/compiler/chunk-splitter.d.ts +1 -1
- package/dist/rasterizer/canvas-mapper.d.ts +1 -1
- package/dist/rasterizer/error-distribution.d.ts +1 -1
- package/dist/rasterizer/gradient-mapper.d.ts +1 -1
- package/dist/rasterizer/topology-rounding.d.ts +1 -1
- package/dist/runtime/event-backpressure.d.ts +1 -1
- package/dist/runtime/render-loop.d.ts +1 -1
- package/dist/semantic/semantic-translator.d.ts +1 -1
- package/package.json +1 -1
- package/src/compiler/chunk-splitter.ts +1 -1
- package/src/rasterizer/__tests__/error-distribution.test.ts +11 -11
- package/src/rasterizer/canvas-mapper.ts +1 -1
- package/src/rasterizer/error-distribution.ts +1 -1
- package/src/rasterizer/gradient-mapper.ts +1 -1
- package/src/rasterizer/topology-rounding.ts +1 -1
- package/src/runtime/__tests__/event-backpressure.test.ts +3 -3
- package/src/runtime/__tests__/ffi-integration.test.ts +8 -0
- package/src/runtime/event-backpressure.ts +1 -1
- package/src/runtime/render-loop.ts +1 -1
- package/src/semantic/__tests__/semantic-translator.test.ts +2 -2
- package/src/semantic/semantic-translator.ts +1 -1
- package/tsconfig.json +2 -1
- package/dist/rasterizer/__tests__/error-distribution.test.d.ts +0 -7
- package/dist/rasterizer/__tests__/error-distribution.test.js +0 -322
- package/dist/runtime/__tests__/event-backpressure.test.d.ts +0 -10
- package/dist/runtime/__tests__/event-backpressure.test.js +0 -190
- package/dist/runtime/__tests__/ffi-dispatcher.test.d.ts +0 -11
- package/dist/runtime/__tests__/ffi-dispatcher.test.js +0 -408
- package/dist/runtime/__tests__/ffi-integration.test.d.ts +0 -9
- package/dist/runtime/__tests__/ffi-integration.test.js +0 -514
- package/dist/runtime/__tests__/wasm-solver-bridge.test.d.ts +0 -9
- package/dist/runtime/__tests__/wasm-solver-bridge.test.js +0 -214
- package/dist/semantic/__tests__/semantic-translator.test.d.ts +0 -4
- package/dist/semantic/__tests__/semantic-translator.test.js +0 -203
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for Q-Dimension Event Backpressure Control
|
|
3
|
-
*
|
|
4
|
-
* Validates:
|
|
5
|
-
* 1. Event coalescing (latest-only sampling)
|
|
6
|
-
* 2. Priority queue handling (critical events)
|
|
7
|
-
* 3. Async event isolation and merging
|
|
8
|
-
* 4. P/Q boundary enforcement (T-state keys only)
|
|
9
|
-
*/
|
|
10
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
11
|
-
import { EventBuffer, EventPriority, } from '../event-backpressure';
|
|
12
|
-
describe('EventBuffer', () => {
|
|
13
|
-
let buffer;
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
buffer = new EventBuffer({ maxEventsPerFrame: 10 });
|
|
16
|
-
});
|
|
17
|
-
// ===========================================================================
|
|
18
|
-
// Basic Event Handling
|
|
19
|
-
// ===========================================================================
|
|
20
|
-
describe('push and flush', () => {
|
|
21
|
-
it('should flush pushed events', () => {
|
|
22
|
-
const event = createEvent(1, 'hover', 1);
|
|
23
|
-
buffer.push(event);
|
|
24
|
-
const flushed = buffer.flush();
|
|
25
|
-
expect(flushed).toHaveLength(1);
|
|
26
|
-
expect(flushed[0].entityId).toBe(1);
|
|
27
|
-
expect(flushed[0].state).toBe('hover');
|
|
28
|
-
expect(flushed[0].value).toBe(1);
|
|
29
|
-
});
|
|
30
|
-
it('should coalesce events for same entity+state', () => {
|
|
31
|
-
// Push multiple events for same entity+state
|
|
32
|
-
buffer.push(createEvent(1, 'scroll_y', 0.1));
|
|
33
|
-
buffer.push(createEvent(1, 'scroll_y', 0.2));
|
|
34
|
-
buffer.push(createEvent(1, 'scroll_y', 0.3));
|
|
35
|
-
const flushed = buffer.flush();
|
|
36
|
-
// Should only have ONE event (the latest)
|
|
37
|
-
expect(flushed).toHaveLength(1);
|
|
38
|
-
expect(flushed[0].value).toBe(0.3);
|
|
39
|
-
});
|
|
40
|
-
it('should NOT coalesce events for different states', () => {
|
|
41
|
-
buffer.push(createEvent(1, 'hover', 1));
|
|
42
|
-
buffer.push(createEvent(1, 'pressed', 1));
|
|
43
|
-
const flushed = buffer.flush();
|
|
44
|
-
expect(flushed).toHaveLength(2);
|
|
45
|
-
});
|
|
46
|
-
it('should clear buffer after flush', () => {
|
|
47
|
-
buffer.push(createEvent(1, 'hover', 1));
|
|
48
|
-
buffer.flush();
|
|
49
|
-
const secondFlush = buffer.flush();
|
|
50
|
-
expect(secondFlush).toHaveLength(0);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
// ===========================================================================
|
|
54
|
-
// Priority Queue
|
|
55
|
-
// ===========================================================================
|
|
56
|
-
describe('priority handling', () => {
|
|
57
|
-
it('should never coalesce CRITICAL events', () => {
|
|
58
|
-
// Multiple click events should all be preserved
|
|
59
|
-
buffer.push(createEvent(1, 'pressed', 1, EventPriority.CRITICAL));
|
|
60
|
-
buffer.push(createEvent(1, 'pressed', 0, EventPriority.CRITICAL));
|
|
61
|
-
buffer.push(createEvent(1, 'pressed', 1, EventPriority.CRITICAL));
|
|
62
|
-
const flushed = buffer.flush();
|
|
63
|
-
// All 3 CRITICAL events should be preserved
|
|
64
|
-
expect(flushed).toHaveLength(3);
|
|
65
|
-
});
|
|
66
|
-
it('should process CRITICAL events before coalesced events', () => {
|
|
67
|
-
// Push low-priority first
|
|
68
|
-
buffer.push(createEvent(1, 'scroll_y', 0.5, EventPriority.LOW));
|
|
69
|
-
// Then critical
|
|
70
|
-
buffer.push(createEvent(2, 'pressed', 1, EventPriority.CRITICAL));
|
|
71
|
-
const flushed = buffer.flush();
|
|
72
|
-
// CRITICAL should come first
|
|
73
|
-
expect(flushed[0].entityId).toBe(2);
|
|
74
|
-
expect(flushed[0].state).toBe('pressed');
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
// ===========================================================================
|
|
78
|
-
// Async Event Handling (Phase 2 Remediation)
|
|
79
|
-
// ===========================================================================
|
|
80
|
-
describe('pushAsync and mergeAsyncEvents', () => {
|
|
81
|
-
it('should isolate async events from sync events', () => {
|
|
82
|
-
// Push sync event
|
|
83
|
-
buffer.push(createEvent(1, 'hover', 1));
|
|
84
|
-
// Push async event (isolated)
|
|
85
|
-
buffer.pushAsync(createEvent(2, 'animation_t', 0.5));
|
|
86
|
-
// Before merge, stats should show async pending
|
|
87
|
-
const stats = buffer.getStats();
|
|
88
|
-
expect(stats.asyncPendingSize).toBe(1);
|
|
89
|
-
expect(stats.coalescedSize).toBe(1);
|
|
90
|
-
});
|
|
91
|
-
it('should merge async events on mergeAsyncEvents call', () => {
|
|
92
|
-
buffer.pushAsync(createEvent(1, 'animation_t', 0.5));
|
|
93
|
-
buffer.pushAsync(createEvent(2, 'drag_progress', 0.3));
|
|
94
|
-
// Before merge
|
|
95
|
-
expect(buffer.getStats().asyncPendingSize).toBe(2);
|
|
96
|
-
// Merge
|
|
97
|
-
buffer.mergeAsyncEvents();
|
|
98
|
-
// After merge, async buffer should be empty
|
|
99
|
-
expect(buffer.getStats().asyncPendingSize).toBe(0);
|
|
100
|
-
// Events should now be in main buffer
|
|
101
|
-
const flushed = buffer.flush();
|
|
102
|
-
expect(flushed).toHaveLength(2);
|
|
103
|
-
});
|
|
104
|
-
it('should apply coalescing rules to async events during merge', () => {
|
|
105
|
-
// Multiple async events for same entity+state
|
|
106
|
-
buffer.pushAsync(createEvent(1, 'animation_t', 0.1));
|
|
107
|
-
buffer.pushAsync(createEvent(1, 'animation_t', 0.2));
|
|
108
|
-
buffer.pushAsync(createEvent(1, 'animation_t', 0.3));
|
|
109
|
-
buffer.mergeAsyncEvents();
|
|
110
|
-
const flushed = buffer.flush();
|
|
111
|
-
// Should be coalesced to latest value
|
|
112
|
-
expect(flushed).toHaveLength(1);
|
|
113
|
-
expect(flushed[0].value).toBe(0.3);
|
|
114
|
-
});
|
|
115
|
-
it('should handle empty async buffer gracefully', () => {
|
|
116
|
-
// Merge with nothing pending should be a no-op
|
|
117
|
-
buffer.mergeAsyncEvents();
|
|
118
|
-
expect(buffer.getStats().asyncPendingSize).toBe(0);
|
|
119
|
-
});
|
|
120
|
-
it('should preserve async event ordering (FIFO within async)', () => {
|
|
121
|
-
// Async events with different states
|
|
122
|
-
buffer.pushAsync(createEvent(1, 'hover', 1));
|
|
123
|
-
buffer.pushAsync(createEvent(1, 'pressed', 1));
|
|
124
|
-
buffer.pushAsync(createEvent(1, 'focused', 1));
|
|
125
|
-
buffer.mergeAsyncEvents();
|
|
126
|
-
const flushed = buffer.flush();
|
|
127
|
-
// All three should be present (different states, no coalescing)
|
|
128
|
-
expect(flushed).toHaveLength(3);
|
|
129
|
-
});
|
|
130
|
-
it('should merge async before sync in tick simulation', () => {
|
|
131
|
-
// Simulate tick() behavior:
|
|
132
|
-
// 1. Async events arrive before tick
|
|
133
|
-
buffer.pushAsync(createEvent(1, 'animation_t', 0.5));
|
|
134
|
-
// 2. Sync event arrives
|
|
135
|
-
buffer.push(createEvent(2, 'hover', 1));
|
|
136
|
-
// 3. At tick start, merge async
|
|
137
|
-
buffer.mergeAsyncEvents();
|
|
138
|
-
// 4. Flush all
|
|
139
|
-
const flushed = buffer.flush();
|
|
140
|
-
expect(flushed).toHaveLength(2);
|
|
141
|
-
// Both events should be present
|
|
142
|
-
expect(flushed.some(e => e.entityId === 1)).toBe(true);
|
|
143
|
-
expect(flushed.some(e => e.entityId === 2)).toBe(true);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
// ===========================================================================
|
|
147
|
-
// Backpressure Limits
|
|
148
|
-
// ===========================================================================
|
|
149
|
-
describe('backpressure', () => {
|
|
150
|
-
it('should respect maxEventsPerFrame limit', () => {
|
|
151
|
-
const limitedBuffer = new EventBuffer({ maxEventsPerFrame: 3 });
|
|
152
|
-
// Push many critical events (which can't be coalesced)
|
|
153
|
-
for (let i = 0; i < 10; i++) {
|
|
154
|
-
limitedBuffer.push(createEvent(i, 'pressed', 1, EventPriority.CRITICAL));
|
|
155
|
-
}
|
|
156
|
-
const flushed = limitedBuffer.flush();
|
|
157
|
-
// Should be limited (critical events + coalesced up to limit)
|
|
158
|
-
// Note: implementation may vary, but should not exceed reasonable limit
|
|
159
|
-
expect(flushed.length).toBeLessThanOrEqual(10);
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
// ===========================================================================
|
|
163
|
-
// Stats
|
|
164
|
-
// ===========================================================================
|
|
165
|
-
describe('getStats', () => {
|
|
166
|
-
it('should report correct buffer sizes', () => {
|
|
167
|
-
buffer.push(createEvent(1, 'hover', 1));
|
|
168
|
-
buffer.push(createEvent(2, 'hover', 1));
|
|
169
|
-
buffer.push(createEvent(3, 'pressed', 1, EventPriority.CRITICAL));
|
|
170
|
-
buffer.pushAsync(createEvent(4, 'animation_t', 0.5));
|
|
171
|
-
const stats = buffer.getStats();
|
|
172
|
-
expect(stats.coalescedSize).toBe(2); // Two coalesced events
|
|
173
|
-
expect(stats.priorityQueueSize).toBe(1); // One critical event
|
|
174
|
-
expect(stats.asyncPendingSize).toBe(1); // One async event
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
// ===========================================================================
|
|
179
|
-
// Test Helpers
|
|
180
|
-
// ===========================================================================
|
|
181
|
-
function createEvent(entityId, targetState, value, priority = EventPriority.NORMAL) {
|
|
182
|
-
return {
|
|
183
|
-
entityId,
|
|
184
|
-
eventType: 'pointermove',
|
|
185
|
-
targetState,
|
|
186
|
-
value,
|
|
187
|
-
timestamp: performance.now(),
|
|
188
|
-
priority,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
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 {};
|
|
@@ -1,408 +0,0 @@
|
|
|
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
|
-
});
|