@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,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bilayer Synchronization E2E Tests
|
|
3
|
+
*
|
|
4
|
+
* This module verifies that the Canvas (visual) and DOM (interaction)
|
|
5
|
+
* layers are perfectly synchronized. The critical invariant:
|
|
6
|
+
*
|
|
7
|
+
* "Clicking a pixel on Canvas MUST trigger the correct DOM event handler"
|
|
8
|
+
*
|
|
9
|
+
* ## The Problem: Layer Desynchronization
|
|
10
|
+
*
|
|
11
|
+
* If a button visually appears at (100, 50) on Canvas but its DOM hit
|
|
12
|
+
* region is at (100, 51), clicking the visual button misses the handler.
|
|
13
|
+
* This is catastrophic UX failure.
|
|
14
|
+
*
|
|
15
|
+
* ## Test Strategy
|
|
16
|
+
*
|
|
17
|
+
* 1. Render a button with known bounds via constraint graph
|
|
18
|
+
* 2. Click at visual boundary edges (1px inside each edge)
|
|
19
|
+
* 3. Verify event fires and visual state changes
|
|
20
|
+
* 4. Repeat for edge cases (animation mid-frame, rapid movement)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { test, expect, type Page, type Browser } from '@playwright/test';
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Test Configuration
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
interface ButtonBounds {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TEST_BUTTON_BOUNDS: ButtonBounds = {
|
|
37
|
+
x: 100,
|
|
38
|
+
y: 100,
|
|
39
|
+
width: 200,
|
|
40
|
+
height: 50,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Colors for state verification
|
|
44
|
+
const BUTTON_IDLE_COLOR = { r: 0, g: 100, b: 200 }; // Blue
|
|
45
|
+
const BUTTON_PRESSED_COLOR = { r: 200, g: 100, b: 0 }; // Orange
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// Bilayer Synchronization Tests
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
test.describe('Bilayer Synchronization: Canvas-DOM Coherence', () => {
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Test: Click at visual center triggers event
|
|
55
|
+
*
|
|
56
|
+
* Basic sanity check that clicking the center of a button works.
|
|
57
|
+
*/
|
|
58
|
+
test('click at button center triggers event and changes visual state', async ({ page }) => {
|
|
59
|
+
await setupTestPage(page);
|
|
60
|
+
|
|
61
|
+
// Render a button at known position
|
|
62
|
+
await renderButton(page, TEST_BUTTON_BOUNDS);
|
|
63
|
+
|
|
64
|
+
// Calculate center coordinates
|
|
65
|
+
const centerX = TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width / 2;
|
|
66
|
+
const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
|
|
67
|
+
|
|
68
|
+
// Verify initial color (idle)
|
|
69
|
+
const initialColor = await samplePixel(page, centerX, centerY);
|
|
70
|
+
expectColorMatch(initialColor, BUTTON_IDLE_COLOR);
|
|
71
|
+
|
|
72
|
+
// Click the center
|
|
73
|
+
await page.mouse.click(centerX, centerY);
|
|
74
|
+
|
|
75
|
+
// Wait for next frame (T-vector update + render)
|
|
76
|
+
await waitForNextFrame(page);
|
|
77
|
+
|
|
78
|
+
// Verify color changed (pressed)
|
|
79
|
+
const finalColor = await samplePixel(page, centerX, centerY);
|
|
80
|
+
expectColorMatch(finalColor, BUTTON_PRESSED_COLOR);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Test: Click at visual boundary (1px inside left edge)
|
|
85
|
+
*
|
|
86
|
+
* Critical edge case: Does the DOM hit region extend exactly
|
|
87
|
+
* to the visual boundary?
|
|
88
|
+
*/
|
|
89
|
+
test('click 1px inside left edge triggers event', async ({ page }) => {
|
|
90
|
+
await setupTestPage(page);
|
|
91
|
+
await renderButton(page, TEST_BUTTON_BOUNDS);
|
|
92
|
+
|
|
93
|
+
// 1px inside left edge
|
|
94
|
+
const edgeX = TEST_BUTTON_BOUNDS.x + 1;
|
|
95
|
+
const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
|
|
96
|
+
|
|
97
|
+
// Click at edge
|
|
98
|
+
await page.mouse.click(edgeX, centerY);
|
|
99
|
+
await waitForNextFrame(page);
|
|
100
|
+
|
|
101
|
+
// Verify event fired (color changed)
|
|
102
|
+
const color = await samplePixel(page, edgeX, centerY);
|
|
103
|
+
expectColorMatch(color, BUTTON_PRESSED_COLOR);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Test: Click at visual boundary (1px inside right edge)
|
|
108
|
+
*/
|
|
109
|
+
test('click 1px inside right edge triggers event', async ({ page }) => {
|
|
110
|
+
await setupTestPage(page);
|
|
111
|
+
await renderButton(page, TEST_BUTTON_BOUNDS);
|
|
112
|
+
|
|
113
|
+
const edgeX = TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width - 1;
|
|
114
|
+
const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
|
|
115
|
+
|
|
116
|
+
await page.mouse.click(edgeX, centerY);
|
|
117
|
+
await waitForNextFrame(page);
|
|
118
|
+
|
|
119
|
+
const color = await samplePixel(page, edgeX, centerY);
|
|
120
|
+
expectColorMatch(color, BUTTON_PRESSED_COLOR);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Test: Click at visual boundary (1px inside top edge)
|
|
125
|
+
*/
|
|
126
|
+
test('click 1px inside top edge triggers event', async ({ page }) => {
|
|
127
|
+
await setupTestPage(page);
|
|
128
|
+
await renderButton(page, TEST_BUTTON_BOUNDS);
|
|
129
|
+
|
|
130
|
+
const centerX = TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width / 2;
|
|
131
|
+
const edgeY = TEST_BUTTON_BOUNDS.y + 1;
|
|
132
|
+
|
|
133
|
+
await page.mouse.click(centerX, edgeY);
|
|
134
|
+
await waitForNextFrame(page);
|
|
135
|
+
|
|
136
|
+
const color = await samplePixel(page, centerX, edgeY);
|
|
137
|
+
expectColorMatch(color, BUTTON_PRESSED_COLOR);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Test: Click at visual boundary (1px inside bottom edge)
|
|
142
|
+
*/
|
|
143
|
+
test('click 1px inside bottom edge triggers event', async ({ page }) => {
|
|
144
|
+
await setupTestPage(page);
|
|
145
|
+
await renderButton(page, TEST_BUTTON_BOUNDS);
|
|
146
|
+
|
|
147
|
+
const centerX = TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width / 2;
|
|
148
|
+
const edgeY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height - 1;
|
|
149
|
+
|
|
150
|
+
await page.mouse.click(centerX, edgeY);
|
|
151
|
+
await waitForNextFrame(page);
|
|
152
|
+
|
|
153
|
+
const color = await samplePixel(page, centerX, edgeY);
|
|
154
|
+
expectColorMatch(color, BUTTON_PRESSED_COLOR);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Test: Click 1px OUTSIDE visual boundary does NOT trigger event
|
|
159
|
+
*
|
|
160
|
+
* Negative test: Verify that the DOM region doesn't extend
|
|
161
|
+
* beyond the visual bounds.
|
|
162
|
+
*/
|
|
163
|
+
test('click 1px outside left edge does NOT trigger event', async ({ page }) => {
|
|
164
|
+
await setupTestPage(page);
|
|
165
|
+
await renderButton(page, TEST_BUTTON_BOUNDS);
|
|
166
|
+
|
|
167
|
+
const outsideX = TEST_BUTTON_BOUNDS.x - 1;
|
|
168
|
+
const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
|
|
169
|
+
|
|
170
|
+
// Click outside
|
|
171
|
+
await page.mouse.click(outsideX, centerY);
|
|
172
|
+
await waitForNextFrame(page);
|
|
173
|
+
|
|
174
|
+
// Sample inside the button to verify it didn't change
|
|
175
|
+
const insideX = TEST_BUTTON_BOUNDS.x + 10;
|
|
176
|
+
const color = await samplePixel(page, insideX, centerY);
|
|
177
|
+
expectColorMatch(color, BUTTON_IDLE_COLOR); // Still idle!
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Test: Rapid clicks during animation
|
|
182
|
+
*
|
|
183
|
+
* Stress test: Click while button is animating (T-vector changing).
|
|
184
|
+
* Verifies that DOM position updates atomically with Canvas.
|
|
185
|
+
*
|
|
186
|
+
* DETERMINISM: Query actual button position from renderer state
|
|
187
|
+
* rather than assuming time-based position.
|
|
188
|
+
*/
|
|
189
|
+
test('click during animation hits moving target', async ({ page }) => {
|
|
190
|
+
await setupTestPage(page);
|
|
191
|
+
|
|
192
|
+
// Start animation: button moves from x=100 to x=300 over 500ms
|
|
193
|
+
await startButtonAnimation(page, {
|
|
194
|
+
from: { ...TEST_BUTTON_BOUNDS },
|
|
195
|
+
to: { ...TEST_BUTTON_BOUNDS, x: 300 },
|
|
196
|
+
durationMs: 500,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Wait for animation to reach midpoint and query actual position
|
|
200
|
+
await page.waitForTimeout(250);
|
|
201
|
+
|
|
202
|
+
// Query the ACTUAL current position from renderer state (deterministic)
|
|
203
|
+
const currentBounds = await page.evaluate(() => {
|
|
204
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
205
|
+
return renderer.getEntityBounds(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Click at actual current center (not assumed position)
|
|
209
|
+
const actualCenterX = currentBounds.x + TEST_BUTTON_BOUNDS.width / 2;
|
|
210
|
+
const centerY = TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height / 2;
|
|
211
|
+
|
|
212
|
+
await page.mouse.click(actualCenterX, centerY);
|
|
213
|
+
await waitForNextFrame(page);
|
|
214
|
+
|
|
215
|
+
// Verify the click registered (check global event counter)
|
|
216
|
+
const clickCount = await page.evaluate(() => (window as any).__VS_CLICK_COUNT__);
|
|
217
|
+
expect(clickCount).toBeGreaterThan(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Test: All four corners (subpixel precision boundary)
|
|
222
|
+
*
|
|
223
|
+
* Tests the exact corner pixels to verify topology-preserving
|
|
224
|
+
* rounding produces correct hit regions.
|
|
225
|
+
*/
|
|
226
|
+
test('all four corner pixels are clickable', async ({ page }) => {
|
|
227
|
+
await setupTestPage(page);
|
|
228
|
+
await renderButton(page, TEST_BUTTON_BOUNDS);
|
|
229
|
+
|
|
230
|
+
const corners = [
|
|
231
|
+
{ x: TEST_BUTTON_BOUNDS.x + 1, y: TEST_BUTTON_BOUNDS.y + 1 }, // Top-left
|
|
232
|
+
{ x: TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width - 1, y: TEST_BUTTON_BOUNDS.y + 1 }, // Top-right
|
|
233
|
+
{ x: TEST_BUTTON_BOUNDS.x + 1, y: TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height - 1 }, // Bottom-left
|
|
234
|
+
{ x: TEST_BUTTON_BOUNDS.x + TEST_BUTTON_BOUNDS.width - 1, y: TEST_BUTTON_BOUNDS.y + TEST_BUTTON_BOUNDS.height - 1 }, // Bottom-right
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
for (const corner of corners) {
|
|
238
|
+
// Reset button state
|
|
239
|
+
await resetButtonState(page);
|
|
240
|
+
|
|
241
|
+
// Click corner
|
|
242
|
+
await page.mouse.click(corner.x, corner.y);
|
|
243
|
+
await waitForNextFrame(page);
|
|
244
|
+
|
|
245
|
+
// Verify click registered
|
|
246
|
+
const clickCount = await page.evaluate(() => (window as any).__VS_CLICK_COUNT__);
|
|
247
|
+
expect(clickCount).toBeGreaterThan(0);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// =============================================================================
|
|
253
|
+
// Helper Functions
|
|
254
|
+
// =============================================================================
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Setup the test page with VS renderer.
|
|
258
|
+
*/
|
|
259
|
+
async function setupTestPage(page: Page): Promise<void> {
|
|
260
|
+
await page.goto('/test-harness.html');
|
|
261
|
+
await page.waitForFunction(() => (window as any).__VS_RENDERER_READY__ === true, {
|
|
262
|
+
timeout: 10000,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Initialize click counter
|
|
266
|
+
await page.evaluate(() => {
|
|
267
|
+
(window as any).__VS_CLICK_COUNT__ = 0;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Render a clickable button at specified bounds.
|
|
273
|
+
*/
|
|
274
|
+
async function renderButton(page: Page, bounds: ButtonBounds): Promise<void> {
|
|
275
|
+
await page.evaluate((b) => {
|
|
276
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
277
|
+
|
|
278
|
+
// Create button entity with click handler
|
|
279
|
+
renderer.render({
|
|
280
|
+
entities: [
|
|
281
|
+
{
|
|
282
|
+
id: 1,
|
|
283
|
+
type: 'rect',
|
|
284
|
+
bounds: b,
|
|
285
|
+
fill: '#0064C8', // BUTTON_IDLE_COLOR
|
|
286
|
+
interactive: true,
|
|
287
|
+
onClick: () => {
|
|
288
|
+
(window as any).__VS_CLICK_COUNT__++;
|
|
289
|
+
// Change color to indicate pressed
|
|
290
|
+
renderer.updateEntity(1, { fill: '#C86400' }); // BUTTON_PRESSED_COLOR
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
constraints: [],
|
|
295
|
+
});
|
|
296
|
+
}, bounds);
|
|
297
|
+
|
|
298
|
+
// Wait for render
|
|
299
|
+
await waitForNextFrame(page);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Start an animation on the button.
|
|
304
|
+
*/
|
|
305
|
+
async function startButtonAnimation(
|
|
306
|
+
page: Page,
|
|
307
|
+
config: { from: ButtonBounds; to: ButtonBounds; durationMs: number },
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
await page.evaluate((cfg) => {
|
|
310
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
311
|
+
|
|
312
|
+
// First render the button at starting position
|
|
313
|
+
renderer.render({
|
|
314
|
+
entities: [
|
|
315
|
+
{
|
|
316
|
+
id: 1,
|
|
317
|
+
type: 'rect',
|
|
318
|
+
bounds: cfg.from,
|
|
319
|
+
fill: '#0064C8',
|
|
320
|
+
interactive: true,
|
|
321
|
+
onClick: () => {
|
|
322
|
+
(window as any).__VS_CLICK_COUNT__++;
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
constraints: [],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Start animation via T-vector binding
|
|
330
|
+
renderer.animate(1, 'x', cfg.from.x, cfg.to.x, cfg.durationMs);
|
|
331
|
+
}, config);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Reset button to idle state.
|
|
336
|
+
*/
|
|
337
|
+
async function resetButtonState(page: Page): Promise<void> {
|
|
338
|
+
await page.evaluate(() => {
|
|
339
|
+
(window as any).__VS_CLICK_COUNT__ = 0;
|
|
340
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
341
|
+
renderer.updateEntity(1, { fill: '#0064C8' });
|
|
342
|
+
});
|
|
343
|
+
await waitForNextFrame(page);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Sample a pixel color at (x, y).
|
|
348
|
+
*
|
|
349
|
+
* CRITICAL: Accounts for devicePixelRatio when sampling canvas pixels.
|
|
350
|
+
* Viewport coordinates must be scaled to canvas backing store coordinates.
|
|
351
|
+
*/
|
|
352
|
+
async function samplePixel(
|
|
353
|
+
page: Page,
|
|
354
|
+
x: number,
|
|
355
|
+
y: number,
|
|
356
|
+
): Promise<{ r: number; g: number; b: number }> {
|
|
357
|
+
return await page.evaluate(({ px, py }) => {
|
|
358
|
+
const canvas = document.getElementById('vs-canvas') as HTMLCanvasElement;
|
|
359
|
+
const ctx = canvas.getContext('2d');
|
|
360
|
+
if (!ctx) throw new Error('No 2D context');
|
|
361
|
+
|
|
362
|
+
// Account for canvas position in viewport
|
|
363
|
+
const rect = canvas.getBoundingClientRect();
|
|
364
|
+
const canvasX = px - rect.left;
|
|
365
|
+
const canvasY = py - rect.top;
|
|
366
|
+
|
|
367
|
+
// Scale by devicePixelRatio for backing store coordinates
|
|
368
|
+
const dpr = window.devicePixelRatio || 1;
|
|
369
|
+
const backingX = Math.floor(canvasX * dpr);
|
|
370
|
+
const backingY = Math.floor(canvasY * dpr);
|
|
371
|
+
|
|
372
|
+
const imageData = ctx.getImageData(backingX, backingY, 1, 1);
|
|
373
|
+
return {
|
|
374
|
+
r: imageData.data[0],
|
|
375
|
+
g: imageData.data[1],
|
|
376
|
+
b: imageData.data[2],
|
|
377
|
+
};
|
|
378
|
+
}, { px: x, py: y });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Wait for next animation frame to ensure render completed.
|
|
383
|
+
*/
|
|
384
|
+
async function waitForNextFrame(page: Page): Promise<void> {
|
|
385
|
+
await page.evaluate(() => {
|
|
386
|
+
return new Promise<void>((resolve) => {
|
|
387
|
+
requestAnimationFrame(() => {
|
|
388
|
+
requestAnimationFrame(() => resolve());
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Assert color match with tolerance for antialiasing.
|
|
396
|
+
*/
|
|
397
|
+
function expectColorMatch(
|
|
398
|
+
actual: { r: number; g: number; b: number },
|
|
399
|
+
expected: { r: number; g: number; b: number },
|
|
400
|
+
tolerance: number = 5,
|
|
401
|
+
): void {
|
|
402
|
+
expect(Math.abs(actual.r - expected.r)).toBeLessThanOrEqual(tolerance);
|
|
403
|
+
expect(Math.abs(actual.g - expected.g)).toBeLessThanOrEqual(tolerance);
|
|
404
|
+
expect(Math.abs(actual.b - expected.b)).toBeLessThanOrEqual(tolerance);
|
|
405
|
+
}
|
|
File without changes
|