@viewscript/renderer 0.1.0-202605140639
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/ast/types.d.ts +403 -0
- package/dist/ast/types.js +33 -0
- package/dist/compiler/chunk-splitter.d.ts +98 -0
- package/dist/compiler/chunk-splitter.js +361 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +17 -0
- package/dist/rasterizer/__tests__/error-distribution.test.d.ts +7 -0
- package/dist/rasterizer/__tests__/error-distribution.test.js +322 -0
- package/dist/rasterizer/canvas-mapper.d.ts +280 -0
- package/dist/rasterizer/canvas-mapper.js +414 -0
- package/dist/rasterizer/error-distribution.d.ts +143 -0
- package/dist/rasterizer/error-distribution.js +231 -0
- package/dist/rasterizer/gradient-mapper.d.ts +223 -0
- package/dist/rasterizer/gradient-mapper.js +352 -0
- package/dist/rasterizer/topology-rounding.d.ts +151 -0
- package/dist/rasterizer/topology-rounding.js +347 -0
- package/dist/runtime/__tests__/event-backpressure.test.d.ts +10 -0
- package/dist/runtime/__tests__/event-backpressure.test.js +190 -0
- package/dist/runtime/event-backpressure.d.ts +393 -0
- package/dist/runtime/event-backpressure.js +458 -0
- package/dist/runtime/render-loop.d.ts +277 -0
- package/dist/runtime/render-loop.js +435 -0
- package/dist/runtime/wasm-resource-manager.d.ts +122 -0
- package/dist/runtime/wasm-resource-manager.js +253 -0
- package/dist/runtime/wgpu-renderer-adapter.d.ts +168 -0
- package/dist/runtime/wgpu-renderer-adapter.js +230 -0
- package/dist/semantic/__tests__/semantic-translator.test.d.ts +4 -0
- package/dist/semantic/__tests__/semantic-translator.test.js +203 -0
- package/dist/semantic/semantic-translator.d.ts +229 -0
- package/dist/semantic/semantic-translator.js +398 -0
- package/package.json +28 -0
- package/playwright-report/data/0bafe4e0863f0e244bba68a838f73241f8f2efaa.md +226 -0
- package/playwright-report/data/9281aca8abfb06c6cecb35d5ddd13d61f8c752d8.md +226 -0
- package/playwright-report/index.html +90 -0
- package/playwright.config.ts +160 -0
- package/screenshot-chrome.png +0 -0
- package/screenshots/visual-demo-verification.png +0 -0
- package/screenshots/visual-demo.png +0 -0
- package/src/ast/types.ts +473 -0
- package/src/compiler/chunk-splitter.ts +534 -0
- package/src/index.ts +62 -0
- package/src/rasterizer/__tests__/error-distribution.test.ts +382 -0
- package/src/rasterizer/canvas-mapper.ts +677 -0
- package/src/rasterizer/error-distribution.ts +344 -0
- package/src/rasterizer/gradient-mapper.ts +563 -0
- package/src/rasterizer/topology-rounding.ts +499 -0
- package/src/runtime/__tests__/event-backpressure.test.ts +254 -0
- package/src/runtime/event-backpressure.ts +622 -0
- package/src/runtime/render-loop.ts +660 -0
- package/src/runtime/wasm-resource-manager.ts +349 -0
- package/src/runtime/wgpu-renderer-adapter.ts +318 -0
- package/src/semantic/__tests__/semantic-translator.test.ts +263 -0
- package/src/semantic/semantic-translator.ts +637 -0
- package/test-results/.last-run.json +4 -0
- package/tests/e2e/async-race.spec.ts +612 -0
- package/tests/e2e/bilayer-sync.spec.ts +405 -0
- package/tests/e2e/failures/.gitkeep +0 -0
- package/tests/e2e/fullstack.spec.ts +681 -0
- package/tests/e2e/g1-continuity.spec.ts +703 -0
- package/tests/e2e/golden/.gitkeep +0 -0
- package/tests/e2e/golden/conic-color-wheel.raw +0 -0
- package/tests/e2e/golden/conic-color-wheel.sha256 +1 -0
- package/tests/e2e/golden/conic-rotated.raw +0 -0
- package/tests/e2e/golden/conic-rotated.sha256 +1 -0
- package/tests/e2e/golden/linear-45deg.raw +0 -0
- package/tests/e2e/golden/linear-45deg.sha256 +1 -0
- package/tests/e2e/golden/linear-horizontal.raw +0 -0
- package/tests/e2e/golden/linear-horizontal.sha256 +1 -0
- package/tests/e2e/golden/linear-multi-stop.raw +0 -0
- package/tests/e2e/golden/linear-multi-stop.sha256 +1 -0
- package/tests/e2e/golden/radial-circle-center.raw +0 -0
- package/tests/e2e/golden/radial-circle-center.sha256 +1 -0
- package/tests/e2e/golden/radial-offset.raw +0 -0
- package/tests/e2e/golden/radial-offset.sha256 +1 -0
- package/tests/e2e/golden/tile-mirror.raw +0 -0
- package/tests/e2e/golden/tile-mirror.sha256 +1 -0
- package/tests/e2e/golden/tile-repeat.raw +0 -0
- package/tests/e2e/golden/tile-repeat.sha256 +1 -0
- package/tests/e2e/gradient-animation.spec.ts +606 -0
- package/tests/e2e/memory-stability.spec.ts +396 -0
- package/tests/e2e/path-topology.spec.ts +674 -0
- package/tests/e2e/performance-profile.spec.ts +501 -0
- package/tests/e2e/screenshot.spec.ts +60 -0
- package/tests/e2e/test-harness.html +1005 -0
- package/tests/e2e/text-layout.spec.ts +451 -0
- package/tests/e2e/visual-demo.html +340 -0
- package/tests/e2e/visual-regression.spec.ts +335 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async Race Condition E2E Tests
|
|
3
|
+
*
|
|
4
|
+
* This module provides mathematical proof that asynchronous Q-dimension events
|
|
5
|
+
* are processed atomically within the render loop, with no event loss or
|
|
6
|
+
* ordering violations.
|
|
7
|
+
*
|
|
8
|
+
* ## The Problem: Event Loop Race Conditions
|
|
9
|
+
*
|
|
10
|
+
* In JavaScript's event loop, synchronous events (mousemove) and asynchronous
|
|
11
|
+
* callbacks (fetch handlers, setTimeout, Promise.then) can interleave in
|
|
12
|
+
* non-deterministic order. Without proper isolation:
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* Tick N:
|
|
16
|
+
* mousemove arrives → buffer.push()
|
|
17
|
+
* Promise.then fires → buffer.push() ← Race: which comes first?
|
|
18
|
+
* setTimeout fires → buffer.push() ← Race: order undefined!
|
|
19
|
+
* flush() → events may be out of order
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* ## The Solution: Async Event Isolation
|
|
23
|
+
*
|
|
24
|
+
* The EventBuffer isolates async events in a separate buffer, merging them
|
|
25
|
+
* atomically at tick start:
|
|
26
|
+
*
|
|
27
|
+
* ```
|
|
28
|
+
* Tick N:
|
|
29
|
+
* [Before tick] Async events accumulate in pendingAsyncEvents
|
|
30
|
+
* [Tick start] mergeAsyncEvents() moves all async → main buffer (atomic)
|
|
31
|
+
* [During tick] Sync events go directly to main buffer
|
|
32
|
+
* [Tick end] flush() returns deterministic event order
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* ## Test Strategy
|
|
36
|
+
*
|
|
37
|
+
* 1. Create an event storm: hundreds of sync + async events colliding
|
|
38
|
+
* 2. Wait for a single rAF to process them all
|
|
39
|
+
* 3. Verify the final state matches the expected mathematical result
|
|
40
|
+
* 4. Verify no events were lost (counter check)
|
|
41
|
+
* 5. Verify CRITICAL event ordering was preserved
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { test, expect, type Page } from '@playwright/test';
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Test Configuration
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
/** Number of events to inject in storm test */
|
|
51
|
+
const STORM_EVENT_COUNT = 500;
|
|
52
|
+
|
|
53
|
+
/** Number of entities to use in multi-entity test */
|
|
54
|
+
const MULTI_ENTITY_COUNT = 50;
|
|
55
|
+
|
|
56
|
+
/** Expected final T-vector states after all events processed */
|
|
57
|
+
interface ExpectedTVectorState {
|
|
58
|
+
entityId: number;
|
|
59
|
+
hover: number;
|
|
60
|
+
pressed: number;
|
|
61
|
+
scroll_y: number;
|
|
62
|
+
animation_t: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// Race Condition Tests
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
test.describe('Async Race Condition: Event Atomicity Proof', () => {
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Test: Sync and async events in same frame produce deterministic result
|
|
73
|
+
*
|
|
74
|
+
* Injects 500 events split between sync (mousemove) and async (Promise.then)
|
|
75
|
+
* contexts. Verifies the final T-vector state is exactly as expected.
|
|
76
|
+
*/
|
|
77
|
+
test('500 mixed sync/async events produce deterministic T-vector state', async ({ page }) => {
|
|
78
|
+
await setupTestPage(page);
|
|
79
|
+
|
|
80
|
+
// Inject event storm and capture final state
|
|
81
|
+
const result = await page.evaluate(async (eventCount) => {
|
|
82
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
83
|
+
const eventBuffer = renderer.getEventBuffer();
|
|
84
|
+
|
|
85
|
+
// Track events processed
|
|
86
|
+
let syncEventCount = 0;
|
|
87
|
+
let asyncEventCount = 0;
|
|
88
|
+
|
|
89
|
+
// Entity 1: receives alternating hover events
|
|
90
|
+
// Final hover should be eventCount % 2 (last event wins due to coalescing)
|
|
91
|
+
const entity1Id = 1;
|
|
92
|
+
|
|
93
|
+
// Entity 2: receives scroll_y events with cumulative value
|
|
94
|
+
// Final scroll_y should be 0.5 (middle of normalized range)
|
|
95
|
+
const entity2Id = 2;
|
|
96
|
+
|
|
97
|
+
// Phase 1: Inject sync events (direct DOM event simulation)
|
|
98
|
+
for (let i = 0; i < eventCount / 2; i++) {
|
|
99
|
+
eventBuffer.push({
|
|
100
|
+
entityId: entity1Id,
|
|
101
|
+
eventType: 'pointermove',
|
|
102
|
+
targetState: 'hover',
|
|
103
|
+
value: i % 2, // Alternating 0, 1, 0, 1...
|
|
104
|
+
timestamp: performance.now(),
|
|
105
|
+
priority: 1, // NORMAL
|
|
106
|
+
});
|
|
107
|
+
syncEventCount++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Phase 2: Inject async events via Promise.then (microtask queue)
|
|
111
|
+
const asyncPromises: Promise<void>[] = [];
|
|
112
|
+
for (let i = 0; i < eventCount / 4; i++) {
|
|
113
|
+
asyncPromises.push(
|
|
114
|
+
Promise.resolve().then(() => {
|
|
115
|
+
eventBuffer.pushAsync({
|
|
116
|
+
entityId: entity2Id,
|
|
117
|
+
eventType: 'scroll',
|
|
118
|
+
targetState: 'scroll_y',
|
|
119
|
+
value: i / (eventCount / 4), // 0.0 to ~1.0
|
|
120
|
+
timestamp: performance.now(),
|
|
121
|
+
priority: 1,
|
|
122
|
+
});
|
|
123
|
+
asyncEventCount++;
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Phase 3: Inject async events via setTimeout (macrotask queue)
|
|
129
|
+
for (let i = 0; i < eventCount / 4; i++) {
|
|
130
|
+
asyncPromises.push(
|
|
131
|
+
new Promise<void>((resolve) => {
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
eventBuffer.pushAsync({
|
|
134
|
+
entityId: entity2Id,
|
|
135
|
+
eventType: 'scroll',
|
|
136
|
+
targetState: 'scroll_y',
|
|
137
|
+
value: 0.5, // Constant value (should be final due to coalescing)
|
|
138
|
+
timestamp: performance.now(),
|
|
139
|
+
priority: 1,
|
|
140
|
+
});
|
|
141
|
+
asyncEventCount++;
|
|
142
|
+
resolve();
|
|
143
|
+
}, 0);
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Wait for all async events to be queued
|
|
149
|
+
await Promise.all(asyncPromises);
|
|
150
|
+
|
|
151
|
+
// Wait for next rAF to process all events
|
|
152
|
+
await new Promise<void>((resolve) => {
|
|
153
|
+
requestAnimationFrame(() => {
|
|
154
|
+
requestAnimationFrame(() => resolve());
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Query final T-vector state
|
|
159
|
+
const tVectorState = renderer.getTVectorState();
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
syncEventCount,
|
|
163
|
+
asyncEventCount,
|
|
164
|
+
totalExpected: eventCount,
|
|
165
|
+
entity1Hover: tVectorState[entity1Id]?.hover ?? -1,
|
|
166
|
+
entity2ScrollY: tVectorState[entity2Id]?.scroll_y ?? -1,
|
|
167
|
+
};
|
|
168
|
+
}, STORM_EVENT_COUNT);
|
|
169
|
+
|
|
170
|
+
// Verify event counts
|
|
171
|
+
expect(result.syncEventCount).toBe(STORM_EVENT_COUNT / 2);
|
|
172
|
+
expect(result.asyncEventCount).toBe(STORM_EVENT_COUNT / 2);
|
|
173
|
+
|
|
174
|
+
// Verify deterministic final state
|
|
175
|
+
// Entity 1: hover should be (STORM_EVENT_COUNT/2 - 1) % 2 = last value
|
|
176
|
+
const expectedHover = ((STORM_EVENT_COUNT / 2) - 1) % 2;
|
|
177
|
+
expect(result.entity1Hover).toBe(expectedHover);
|
|
178
|
+
|
|
179
|
+
// Entity 2: scroll_y should be 0.5 (last setTimeout value wins)
|
|
180
|
+
expect(result.entity2ScrollY).toBeCloseTo(0.5, 10);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Test: CRITICAL events preserve ordering despite async interleaving
|
|
185
|
+
*
|
|
186
|
+
* CRITICAL events (click, keydown) must never be coalesced and must
|
|
187
|
+
* maintain their relative ordering even when async events interleave.
|
|
188
|
+
*/
|
|
189
|
+
test('CRITICAL events preserve strict ordering', async ({ page }) => {
|
|
190
|
+
await setupTestPage(page);
|
|
191
|
+
|
|
192
|
+
const result = await page.evaluate(async () => {
|
|
193
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
194
|
+
const eventBuffer = renderer.getEventBuffer();
|
|
195
|
+
|
|
196
|
+
// Track the order in which events are processed
|
|
197
|
+
const processedOrder: number[] = [];
|
|
198
|
+
|
|
199
|
+
// Inject CRITICAL events with sequence numbers
|
|
200
|
+
// Some via sync, some via async - order must be preserved
|
|
201
|
+
const sequenceIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
|
202
|
+
|
|
203
|
+
// Odd sequence numbers: sync push
|
|
204
|
+
for (const seq of sequenceIds.filter(s => s % 2 === 1)) {
|
|
205
|
+
eventBuffer.push({
|
|
206
|
+
entityId: seq,
|
|
207
|
+
eventType: 'click',
|
|
208
|
+
targetState: 'pressed',
|
|
209
|
+
value: seq,
|
|
210
|
+
timestamp: performance.now() + seq, // Unique timestamp
|
|
211
|
+
priority: 3, // CRITICAL
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Even sequence numbers: async push via Promise.then
|
|
216
|
+
await Promise.all(
|
|
217
|
+
sequenceIds.filter(s => s % 2 === 0).map(seq =>
|
|
218
|
+
Promise.resolve().then(() => {
|
|
219
|
+
eventBuffer.pushAsync({
|
|
220
|
+
entityId: seq,
|
|
221
|
+
eventType: 'click',
|
|
222
|
+
targetState: 'pressed',
|
|
223
|
+
value: seq,
|
|
224
|
+
timestamp: performance.now() + seq,
|
|
225
|
+
priority: 3, // CRITICAL
|
|
226
|
+
});
|
|
227
|
+
})
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Merge async and flush
|
|
232
|
+
eventBuffer.mergeAsyncEvents();
|
|
233
|
+
const flushed = eventBuffer.flush();
|
|
234
|
+
|
|
235
|
+
// Extract the sequence of entity IDs (which encode the original sequence)
|
|
236
|
+
const flushedOrder = flushed.map((e: any) => e.entityId);
|
|
237
|
+
|
|
238
|
+
// CRITICAL events should all be present (no loss)
|
|
239
|
+
const allPresent = sequenceIds.every(seq => flushedOrder.includes(seq));
|
|
240
|
+
|
|
241
|
+
// Within each category (sync vs async), relative order should be preserved
|
|
242
|
+
const syncOrder = flushedOrder.filter((id: number) => id % 2 === 1);
|
|
243
|
+
const asyncOrder = flushedOrder.filter((id: number) => id % 2 === 0);
|
|
244
|
+
|
|
245
|
+
const syncOrderPreserved = syncOrder.every(
|
|
246
|
+
(id: number, i: number) => i === 0 || id > syncOrder[i - 1]
|
|
247
|
+
);
|
|
248
|
+
const asyncOrderPreserved = asyncOrder.every(
|
|
249
|
+
(id: number, i: number) => i === 0 || id > asyncOrder[i - 1]
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
totalFlushed: flushed.length,
|
|
254
|
+
allPresent,
|
|
255
|
+
syncOrderPreserved,
|
|
256
|
+
asyncOrderPreserved,
|
|
257
|
+
flushedOrder,
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// All 10 CRITICAL events must be present (no coalescing, no loss)
|
|
262
|
+
expect(result.totalFlushed).toBe(10);
|
|
263
|
+
expect(result.allPresent).toBe(true);
|
|
264
|
+
|
|
265
|
+
// Order within sync and async categories must be preserved
|
|
266
|
+
expect(result.syncOrderPreserved).toBe(true);
|
|
267
|
+
expect(result.asyncOrderPreserved).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Test: P-dimension coordinates derived from T-vector are bit-perfect
|
|
272
|
+
*
|
|
273
|
+
* After processing all events, the constraint solver must produce
|
|
274
|
+
* exactly the expected P-dimension values - no floating point drift.
|
|
275
|
+
*/
|
|
276
|
+
test('P-dimension coordinates are bit-perfect after event storm', async ({ page }) => {
|
|
277
|
+
await setupTestPage(page);
|
|
278
|
+
|
|
279
|
+
const result = await page.evaluate(async (entityCount) => {
|
|
280
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
281
|
+
const eventBuffer = renderer.getEventBuffer();
|
|
282
|
+
|
|
283
|
+
// Create entities with constraints: X = hover * 100 + entityId
|
|
284
|
+
const entities = [];
|
|
285
|
+
for (let id = 1; id <= entityCount; id++) {
|
|
286
|
+
entities.push({
|
|
287
|
+
id,
|
|
288
|
+
type: 'rect',
|
|
289
|
+
bounds: { x: id, y: 0, width: 10, height: 10 },
|
|
290
|
+
interactive: true,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Render entities with T-dependent constraints
|
|
295
|
+
renderer.render({
|
|
296
|
+
entities,
|
|
297
|
+
constraints: entities.map(e => ({
|
|
298
|
+
target: e.id,
|
|
299
|
+
component: 'x',
|
|
300
|
+
relation: 'eq',
|
|
301
|
+
// X = hover * 100 + entityId
|
|
302
|
+
term: { type: 'linear', tState: 'hover', coefficient: 100, offset: e.id },
|
|
303
|
+
})),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Inject events: set hover=1 for all entities via async storm
|
|
307
|
+
const asyncPromises: Promise<void>[] = [];
|
|
308
|
+
|
|
309
|
+
for (let id = 1; id <= entityCount; id++) {
|
|
310
|
+
// Half via Promise.then
|
|
311
|
+
if (id % 2 === 0) {
|
|
312
|
+
asyncPromises.push(
|
|
313
|
+
Promise.resolve().then(() => {
|
|
314
|
+
eventBuffer.pushAsync({
|
|
315
|
+
entityId: id,
|
|
316
|
+
eventType: 'pointerenter',
|
|
317
|
+
targetState: 'hover',
|
|
318
|
+
value: 1,
|
|
319
|
+
timestamp: performance.now(),
|
|
320
|
+
priority: 2, // HIGH
|
|
321
|
+
});
|
|
322
|
+
})
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
// Half via setTimeout
|
|
326
|
+
else {
|
|
327
|
+
asyncPromises.push(
|
|
328
|
+
new Promise<void>((resolve) => {
|
|
329
|
+
setTimeout(() => {
|
|
330
|
+
eventBuffer.pushAsync({
|
|
331
|
+
entityId: id,
|
|
332
|
+
eventType: 'pointerenter',
|
|
333
|
+
targetState: 'hover',
|
|
334
|
+
value: 1,
|
|
335
|
+
timestamp: performance.now(),
|
|
336
|
+
priority: 2,
|
|
337
|
+
});
|
|
338
|
+
resolve();
|
|
339
|
+
}, 0);
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await Promise.all(asyncPromises);
|
|
346
|
+
|
|
347
|
+
// Wait for render tick to process
|
|
348
|
+
await new Promise<void>((resolve) => {
|
|
349
|
+
requestAnimationFrame(() => {
|
|
350
|
+
requestAnimationFrame(() => resolve());
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Query P-dimension coordinates and T-vector state
|
|
355
|
+
const pCoordinates: { id: number; x: number }[] = [];
|
|
356
|
+
const tStates: { id: number; hover: number }[] = [];
|
|
357
|
+
|
|
358
|
+
for (let id = 1; id <= entityCount; id++) {
|
|
359
|
+
const bounds = renderer.getEntityBounds(id);
|
|
360
|
+
const tState = renderer.getTVectorState()[id];
|
|
361
|
+
|
|
362
|
+
pCoordinates.push({ id, x: bounds.x });
|
|
363
|
+
tStates.push({ id, hover: tState?.hover ?? 0 });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { pCoordinates, tStates, entityCount };
|
|
367
|
+
}, MULTI_ENTITY_COUNT);
|
|
368
|
+
|
|
369
|
+
// Verify all entities received hover=1
|
|
370
|
+
for (const tState of result.tStates) {
|
|
371
|
+
expect(tState.hover).toBe(1);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Verify P-dimension X coordinates are exactly as expected
|
|
375
|
+
// X = hover * 100 + entityId = 1 * 100 + entityId = 100 + entityId
|
|
376
|
+
for (const pCoord of result.pCoordinates) {
|
|
377
|
+
const expectedX = 100 + pCoord.id;
|
|
378
|
+
expect(pCoord.x).toBe(expectedX);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Test: No event loss under extreme concurrency
|
|
384
|
+
*
|
|
385
|
+
* Fire events from multiple async sources simultaneously and verify
|
|
386
|
+
* every single event is accounted for.
|
|
387
|
+
*/
|
|
388
|
+
test('zero event loss under concurrent async sources', async ({ page }) => {
|
|
389
|
+
await setupTestPage(page);
|
|
390
|
+
|
|
391
|
+
const result = await page.evaluate(async () => {
|
|
392
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
393
|
+
const eventBuffer = renderer.getEventBuffer();
|
|
394
|
+
|
|
395
|
+
// Counter for verification
|
|
396
|
+
let expectedCount = 0;
|
|
397
|
+
|
|
398
|
+
// Source 1: Promise.resolve chain (microtasks)
|
|
399
|
+
const microtaskPromises: Promise<void>[] = [];
|
|
400
|
+
for (let i = 0; i < 100; i++) {
|
|
401
|
+
microtaskPromises.push(
|
|
402
|
+
Promise.resolve().then(() => {
|
|
403
|
+
eventBuffer.pushAsync({
|
|
404
|
+
entityId: 1,
|
|
405
|
+
eventType: 'pointermove',
|
|
406
|
+
targetState: 'animation_t',
|
|
407
|
+
value: i / 100,
|
|
408
|
+
timestamp: performance.now(),
|
|
409
|
+
priority: 0, // LOW
|
|
410
|
+
});
|
|
411
|
+
expectedCount++;
|
|
412
|
+
})
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Source 2: setTimeout 0 (macrotasks)
|
|
417
|
+
const macrotaskPromises: Promise<void>[] = [];
|
|
418
|
+
for (let i = 0; i < 100; i++) {
|
|
419
|
+
macrotaskPromises.push(
|
|
420
|
+
new Promise<void>((resolve) => {
|
|
421
|
+
setTimeout(() => {
|
|
422
|
+
eventBuffer.pushAsync({
|
|
423
|
+
entityId: 2,
|
|
424
|
+
eventType: 'pointermove',
|
|
425
|
+
targetState: 'animation_t',
|
|
426
|
+
value: i / 100,
|
|
427
|
+
timestamp: performance.now(),
|
|
428
|
+
priority: 0,
|
|
429
|
+
});
|
|
430
|
+
expectedCount++;
|
|
431
|
+
resolve();
|
|
432
|
+
}, 0);
|
|
433
|
+
})
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Source 3: queueMicrotask
|
|
438
|
+
const queuedMicrotasks: Promise<void>[] = [];
|
|
439
|
+
for (let i = 0; i < 100; i++) {
|
|
440
|
+
queuedMicrotasks.push(
|
|
441
|
+
new Promise<void>((resolve) => {
|
|
442
|
+
queueMicrotask(() => {
|
|
443
|
+
eventBuffer.pushAsync({
|
|
444
|
+
entityId: 3,
|
|
445
|
+
eventType: 'pointermove',
|
|
446
|
+
targetState: 'animation_t',
|
|
447
|
+
value: i / 100,
|
|
448
|
+
timestamp: performance.now(),
|
|
449
|
+
priority: 0,
|
|
450
|
+
});
|
|
451
|
+
expectedCount++;
|
|
452
|
+
resolve();
|
|
453
|
+
});
|
|
454
|
+
})
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Wait for all sources
|
|
459
|
+
await Promise.all([
|
|
460
|
+
...microtaskPromises,
|
|
461
|
+
...macrotaskPromises,
|
|
462
|
+
...queuedMicrotasks,
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
// Get stats before merge
|
|
466
|
+
const statsBefore = eventBuffer.getStats();
|
|
467
|
+
|
|
468
|
+
// Merge and flush
|
|
469
|
+
eventBuffer.mergeAsyncEvents();
|
|
470
|
+
const flushed = eventBuffer.flush();
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
expectedCount,
|
|
474
|
+
asyncPendingBefore: statsBefore.asyncPendingSize,
|
|
475
|
+
flushedCount: flushed.length,
|
|
476
|
+
// Due to coalescing, we expect 3 events (one per entity, last value wins)
|
|
477
|
+
uniqueEntities: new Set(flushed.map((e: any) => e.entityId)).size,
|
|
478
|
+
};
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// All 300 events should have been received
|
|
482
|
+
expect(result.expectedCount).toBe(300);
|
|
483
|
+
expect(result.asyncPendingBefore).toBe(300);
|
|
484
|
+
|
|
485
|
+
// After coalescing, we should have exactly 3 events (one per entity)
|
|
486
|
+
expect(result.flushedCount).toBe(3);
|
|
487
|
+
expect(result.uniqueEntities).toBe(3);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Test: Canvas and DOM state match after async event storm
|
|
492
|
+
*
|
|
493
|
+
* The bilayer invariant must hold even after async event processing:
|
|
494
|
+
* Canvas visual position === DOM hit region position
|
|
495
|
+
*
|
|
496
|
+
* Note: Coalescing uses "last push wins" semantics, not timestamp ordering.
|
|
497
|
+
* We use sequential pushAsync calls to ensure deterministic final value.
|
|
498
|
+
*/
|
|
499
|
+
test('bilayer sync maintained after async event storm', async ({ page }) => {
|
|
500
|
+
await setupTestPage(page);
|
|
501
|
+
|
|
502
|
+
const result = await page.evaluate(async () => {
|
|
503
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
504
|
+
const eventBuffer = renderer.getEventBuffer();
|
|
505
|
+
|
|
506
|
+
// Create a button that moves based on animation_t
|
|
507
|
+
renderer.render({
|
|
508
|
+
entities: [
|
|
509
|
+
{
|
|
510
|
+
id: 1,
|
|
511
|
+
type: 'rect',
|
|
512
|
+
bounds: { x: 0, y: 100, width: 100, height: 50 },
|
|
513
|
+
interactive: true,
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
constraints: [
|
|
517
|
+
{
|
|
518
|
+
target: 1,
|
|
519
|
+
component: 'x',
|
|
520
|
+
relation: 'eq',
|
|
521
|
+
// X = animation_t * 200 (moves 0 to 200 as t goes 0 to 1)
|
|
522
|
+
term: { type: 'linear', tState: 'animation_t', coefficient: 200, offset: 0 },
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Inject async events SEQUENTIALLY to ensure deterministic ordering
|
|
528
|
+
// The LAST pushed event for entity:state wins due to coalescing
|
|
529
|
+
await Promise.resolve().then(() => {
|
|
530
|
+
eventBuffer.pushAsync({
|
|
531
|
+
entityId: 1,
|
|
532
|
+
eventType: 'custom',
|
|
533
|
+
targetState: 'animation_t',
|
|
534
|
+
value: 0.25,
|
|
535
|
+
timestamp: performance.now(),
|
|
536
|
+
priority: 1,
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
await Promise.resolve().then(() => {
|
|
541
|
+
eventBuffer.pushAsync({
|
|
542
|
+
entityId: 1,
|
|
543
|
+
eventType: 'custom',
|
|
544
|
+
targetState: 'animation_t',
|
|
545
|
+
value: 0.5,
|
|
546
|
+
timestamp: performance.now(),
|
|
547
|
+
priority: 1,
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// This is pushed LAST, so it wins (coalescing: last push wins)
|
|
552
|
+
await Promise.resolve().then(() => {
|
|
553
|
+
eventBuffer.pushAsync({
|
|
554
|
+
entityId: 1,
|
|
555
|
+
eventType: 'custom',
|
|
556
|
+
targetState: 'animation_t',
|
|
557
|
+
value: 0.75,
|
|
558
|
+
timestamp: performance.now(),
|
|
559
|
+
priority: 1,
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Wait for render
|
|
564
|
+
await new Promise<void>((resolve) => {
|
|
565
|
+
requestAnimationFrame(() => {
|
|
566
|
+
requestAnimationFrame(() => resolve());
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Query Canvas position (P-dimension)
|
|
571
|
+
const canvasBounds = renderer.getEntityBounds(1);
|
|
572
|
+
|
|
573
|
+
// Query DOM hit region position
|
|
574
|
+
const domElement = document.querySelector('[data-vs-entity="1"]') as HTMLElement;
|
|
575
|
+
const domTransform = domElement?.style.transform || '';
|
|
576
|
+
const domXMatch = domTransform.match(/translate3d\(([^,]+)px/);
|
|
577
|
+
const domX = domXMatch ? parseFloat(domXMatch[1]) : -1;
|
|
578
|
+
|
|
579
|
+
// Query T-vector state
|
|
580
|
+
const tState = renderer.getTVectorState()[1];
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
canvasX: canvasBounds.x,
|
|
584
|
+
domX,
|
|
585
|
+
animationT: tState?.animation_t ?? -1,
|
|
586
|
+
};
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// animation_t should be 0.75 (last push wins)
|
|
590
|
+
expect(result.animationT).toBeCloseTo(0.75, 10);
|
|
591
|
+
|
|
592
|
+
// Expected X = 0.75 * 200 = 150
|
|
593
|
+
expect(result.canvasX).toBe(150);
|
|
594
|
+
|
|
595
|
+
// DOM X must match Canvas X exactly (bilayer invariant)
|
|
596
|
+
expect(result.domX).toBe(result.canvasX);
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// =============================================================================
|
|
601
|
+
// Helper Functions
|
|
602
|
+
// =============================================================================
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Setup the test page with VS renderer.
|
|
606
|
+
*/
|
|
607
|
+
async function setupTestPage(page: Page): Promise<void> {
|
|
608
|
+
await page.goto('/test-harness.html');
|
|
609
|
+
await page.waitForFunction(() => (window as any).__VS_RENDERER_READY__ === true, {
|
|
610
|
+
timeout: 10000,
|
|
611
|
+
});
|
|
612
|
+
}
|