@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.
Files changed (89) hide show
  1. package/dist/ast/types.d.ts +403 -0
  2. package/dist/ast/types.js +33 -0
  3. package/dist/compiler/chunk-splitter.d.ts +98 -0
  4. package/dist/compiler/chunk-splitter.js +361 -0
  5. package/dist/index.d.ts +55 -0
  6. package/dist/index.js +17 -0
  7. package/dist/rasterizer/__tests__/error-distribution.test.d.ts +7 -0
  8. package/dist/rasterizer/__tests__/error-distribution.test.js +322 -0
  9. package/dist/rasterizer/canvas-mapper.d.ts +280 -0
  10. package/dist/rasterizer/canvas-mapper.js +414 -0
  11. package/dist/rasterizer/error-distribution.d.ts +143 -0
  12. package/dist/rasterizer/error-distribution.js +231 -0
  13. package/dist/rasterizer/gradient-mapper.d.ts +223 -0
  14. package/dist/rasterizer/gradient-mapper.js +352 -0
  15. package/dist/rasterizer/topology-rounding.d.ts +151 -0
  16. package/dist/rasterizer/topology-rounding.js +347 -0
  17. package/dist/runtime/__tests__/event-backpressure.test.d.ts +10 -0
  18. package/dist/runtime/__tests__/event-backpressure.test.js +190 -0
  19. package/dist/runtime/event-backpressure.d.ts +393 -0
  20. package/dist/runtime/event-backpressure.js +458 -0
  21. package/dist/runtime/render-loop.d.ts +277 -0
  22. package/dist/runtime/render-loop.js +435 -0
  23. package/dist/runtime/wasm-resource-manager.d.ts +122 -0
  24. package/dist/runtime/wasm-resource-manager.js +253 -0
  25. package/dist/runtime/wgpu-renderer-adapter.d.ts +168 -0
  26. package/dist/runtime/wgpu-renderer-adapter.js +230 -0
  27. package/dist/semantic/__tests__/semantic-translator.test.d.ts +4 -0
  28. package/dist/semantic/__tests__/semantic-translator.test.js +203 -0
  29. package/dist/semantic/semantic-translator.d.ts +229 -0
  30. package/dist/semantic/semantic-translator.js +398 -0
  31. package/package.json +28 -0
  32. package/playwright-report/data/0bafe4e0863f0e244bba68a838f73241f8f2efaa.md +226 -0
  33. package/playwright-report/data/9281aca8abfb06c6cecb35d5ddd13d61f8c752d8.md +226 -0
  34. package/playwright-report/index.html +90 -0
  35. package/playwright.config.ts +160 -0
  36. package/screenshot-chrome.png +0 -0
  37. package/screenshots/visual-demo-verification.png +0 -0
  38. package/screenshots/visual-demo.png +0 -0
  39. package/src/ast/types.ts +473 -0
  40. package/src/compiler/chunk-splitter.ts +534 -0
  41. package/src/index.ts +62 -0
  42. package/src/rasterizer/__tests__/error-distribution.test.ts +382 -0
  43. package/src/rasterizer/canvas-mapper.ts +677 -0
  44. package/src/rasterizer/error-distribution.ts +344 -0
  45. package/src/rasterizer/gradient-mapper.ts +563 -0
  46. package/src/rasterizer/topology-rounding.ts +499 -0
  47. package/src/runtime/__tests__/event-backpressure.test.ts +254 -0
  48. package/src/runtime/event-backpressure.ts +622 -0
  49. package/src/runtime/render-loop.ts +660 -0
  50. package/src/runtime/wasm-resource-manager.ts +349 -0
  51. package/src/runtime/wgpu-renderer-adapter.ts +318 -0
  52. package/src/semantic/__tests__/semantic-translator.test.ts +263 -0
  53. package/src/semantic/semantic-translator.ts +637 -0
  54. package/test-results/.last-run.json +4 -0
  55. package/tests/e2e/async-race.spec.ts +612 -0
  56. package/tests/e2e/bilayer-sync.spec.ts +405 -0
  57. package/tests/e2e/failures/.gitkeep +0 -0
  58. package/tests/e2e/fullstack.spec.ts +681 -0
  59. package/tests/e2e/g1-continuity.spec.ts +703 -0
  60. package/tests/e2e/golden/.gitkeep +0 -0
  61. package/tests/e2e/golden/conic-color-wheel.raw +0 -0
  62. package/tests/e2e/golden/conic-color-wheel.sha256 +1 -0
  63. package/tests/e2e/golden/conic-rotated.raw +0 -0
  64. package/tests/e2e/golden/conic-rotated.sha256 +1 -0
  65. package/tests/e2e/golden/linear-45deg.raw +0 -0
  66. package/tests/e2e/golden/linear-45deg.sha256 +1 -0
  67. package/tests/e2e/golden/linear-horizontal.raw +0 -0
  68. package/tests/e2e/golden/linear-horizontal.sha256 +1 -0
  69. package/tests/e2e/golden/linear-multi-stop.raw +0 -0
  70. package/tests/e2e/golden/linear-multi-stop.sha256 +1 -0
  71. package/tests/e2e/golden/radial-circle-center.raw +0 -0
  72. package/tests/e2e/golden/radial-circle-center.sha256 +1 -0
  73. package/tests/e2e/golden/radial-offset.raw +0 -0
  74. package/tests/e2e/golden/radial-offset.sha256 +1 -0
  75. package/tests/e2e/golden/tile-mirror.raw +0 -0
  76. package/tests/e2e/golden/tile-mirror.sha256 +1 -0
  77. package/tests/e2e/golden/tile-repeat.raw +0 -0
  78. package/tests/e2e/golden/tile-repeat.sha256 +1 -0
  79. package/tests/e2e/gradient-animation.spec.ts +606 -0
  80. package/tests/e2e/memory-stability.spec.ts +396 -0
  81. package/tests/e2e/path-topology.spec.ts +674 -0
  82. package/tests/e2e/performance-profile.spec.ts +501 -0
  83. package/tests/e2e/screenshot.spec.ts +60 -0
  84. package/tests/e2e/test-harness.html +1005 -0
  85. package/tests/e2e/text-layout.spec.ts +451 -0
  86. package/tests/e2e/visual-demo.html +340 -0
  87. package/tests/e2e/visual-regression.spec.ts +335 -0
  88. package/tsconfig.json +12 -0
  89. 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
+ }