@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 @@
|
|
|
1
|
+
2b4a52a0684d487de5dd6f1559255eb89d6ca9197d2fb277bfd859c3a3aa208c
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
193bb1df97bcd23fbb7bf8829018130241b15b9e4e6813077e3f0bcc447de974
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
088aec9b0ea35189e9e4b67f3f5b9d182cda0242c6fae682010677c274b5473f
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
636c5f60922e40ff263bc1bb9d6512d648dc4448c900a82e52b7a6d91372df74
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
e2fbf8f050f5bfcea45d69bb3b269abb6b75aed389d3c13f2c9cf65338ad2932
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
23fbf97fd2be49d531ce5ff7792b2a4d90444ea024b8dbb5d88c3325e674de38
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
e2e2551db57d907a0ee0bce54c5a730631dff11a41bdd7bd3e83dfa93124b951
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
849154f8e1b63bb820a9f20a1688c8c7b97a61d0c04b1c8b01dae13dc9cb5716
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
11c46680840039057696dca2f03b3a1125f677c9d41dbebcb55cc5caf6cac024
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gradient Animation E2E Tests: Phase 17 Validation
|
|
3
|
+
*
|
|
4
|
+
* This module validates the complete gradient pipeline from CSS input
|
|
5
|
+
* to GPU shader output, including T-vector animation binding.
|
|
6
|
+
*
|
|
7
|
+
* ## Test Coverage
|
|
8
|
+
*
|
|
9
|
+
* 1. Static Gradient Rendering
|
|
10
|
+
* - Linear gradients (directional)
|
|
11
|
+
* - Radial gradients (circular, elliptical)
|
|
12
|
+
* - Conic gradients (sweep)
|
|
13
|
+
*
|
|
14
|
+
* 2. T-Vector Animation
|
|
15
|
+
* - Color stop position interpolation
|
|
16
|
+
* - Gradient angle animation
|
|
17
|
+
* - Rotation animation for conic gradients
|
|
18
|
+
*
|
|
19
|
+
* 3. P-Dimension Integrity
|
|
20
|
+
* - Rational color values preserved until rasterization
|
|
21
|
+
* - No floating-point contamination in constraint evaluation
|
|
22
|
+
* - Topology-preserving rounding at GPU boundary
|
|
23
|
+
*
|
|
24
|
+
* 4. Tile Mode Behavior
|
|
25
|
+
* - Clamp (default)
|
|
26
|
+
* - Repeat
|
|
27
|
+
* - Mirror
|
|
28
|
+
* - Decal
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { test, expect, type Page } from '@playwright/test';
|
|
32
|
+
import * as crypto from 'crypto';
|
|
33
|
+
import * as fs from 'fs';
|
|
34
|
+
import * as path from 'path';
|
|
35
|
+
import { fileURLToPath } from 'url';
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Test Configuration
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
42
|
+
const __dirname = path.dirname(__filename);
|
|
43
|
+
|
|
44
|
+
const GOLDEN_DIR = path.join(__dirname, 'golden');
|
|
45
|
+
const FAILURE_DIR = path.join(__dirname, 'failures');
|
|
46
|
+
|
|
47
|
+
const CANVASKIT_DETERMINISTIC_CONFIG = {
|
|
48
|
+
disableWebGL: true,
|
|
49
|
+
preferLowPowerToHighPerformance: false,
|
|
50
|
+
useSubpixelText: false,
|
|
51
|
+
devicePixelRatio: 1.0,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Linear Gradient Tests
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
test.describe('Gradient Rendering: Linear Gradients', () => {
|
|
59
|
+
test.beforeAll(async () => {
|
|
60
|
+
ensureDirectories();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Test: Simple horizontal linear gradient
|
|
65
|
+
*
|
|
66
|
+
* CSS: linear-gradient(to right, red, blue)
|
|
67
|
+
* Expected: Smooth horizontal transition from red to blue
|
|
68
|
+
*/
|
|
69
|
+
test('renders horizontal linear gradient', async ({ page }) => {
|
|
70
|
+
await setupDeterministicRenderer(page);
|
|
71
|
+
|
|
72
|
+
const pixelBuffer = await renderGradient(page, {
|
|
73
|
+
type: 'linear-gradient',
|
|
74
|
+
css: 'linear-gradient(to right, red, blue)',
|
|
75
|
+
bounds: { x: 0, y: 0, width: 200, height: 100 },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Verify color transition: left edge should be red, right edge should be blue
|
|
79
|
+
const { leftEdgeColor, rightEdgeColor } = analyzeHorizontalGradient(pixelBuffer, 200, 100);
|
|
80
|
+
|
|
81
|
+
expect(leftEdgeColor.r).toBeGreaterThan(200); // Red
|
|
82
|
+
expect(leftEdgeColor.g).toBeLessThan(50);
|
|
83
|
+
expect(leftEdgeColor.b).toBeLessThan(50);
|
|
84
|
+
|
|
85
|
+
expect(rightEdgeColor.r).toBeLessThan(50);
|
|
86
|
+
expect(rightEdgeColor.g).toBeLessThan(50);
|
|
87
|
+
expect(rightEdgeColor.b).toBeGreaterThan(200); // Blue
|
|
88
|
+
|
|
89
|
+
// Golden hash comparison
|
|
90
|
+
await compareGolden(pixelBuffer, 'linear-horizontal');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Test: 45-degree linear gradient
|
|
95
|
+
*
|
|
96
|
+
* CSS: linear-gradient(45deg, #FF0000, #0000FF)
|
|
97
|
+
* Expected: Diagonal gradient from bottom-left to top-right
|
|
98
|
+
*/
|
|
99
|
+
test('renders 45-degree linear gradient', async ({ page }) => {
|
|
100
|
+
await setupDeterministicRenderer(page);
|
|
101
|
+
|
|
102
|
+
const pixelBuffer = await renderGradient(page, {
|
|
103
|
+
type: 'linear-gradient',
|
|
104
|
+
css: 'linear-gradient(45deg, #FF0000, #0000FF)',
|
|
105
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// For a 45deg gradient in a square, bottom-left should be red, top-right should be blue
|
|
109
|
+
const bottomLeft = getPixelColor(pixelBuffer, 100, 100, 10, 90);
|
|
110
|
+
const topRight = getPixelColor(pixelBuffer, 100, 100, 90, 10);
|
|
111
|
+
|
|
112
|
+
expect(bottomLeft.r).toBeGreaterThan(150); // More red
|
|
113
|
+
expect(topRight.b).toBeGreaterThan(150); // More blue
|
|
114
|
+
|
|
115
|
+
await compareGolden(pixelBuffer, 'linear-45deg');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Test: Multi-stop linear gradient
|
|
120
|
+
*
|
|
121
|
+
* CSS: linear-gradient(to right, red 0%, yellow 50%, blue 100%)
|
|
122
|
+
* Expected: Red -> Yellow -> Blue transition
|
|
123
|
+
*/
|
|
124
|
+
test('renders multi-stop linear gradient', async ({ page }) => {
|
|
125
|
+
await setupDeterministicRenderer(page);
|
|
126
|
+
|
|
127
|
+
const pixelBuffer = await renderGradient(page, {
|
|
128
|
+
type: 'linear-gradient',
|
|
129
|
+
css: 'linear-gradient(to right, red 0%, yellow 50%, blue 100%)',
|
|
130
|
+
bounds: { x: 0, y: 0, width: 200, height: 50 },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Sample at 25% (red-yellow mix), 50% (yellow), 75% (yellow-blue mix)
|
|
134
|
+
const at25 = getPixelColor(pixelBuffer, 200, 50, 50, 25);
|
|
135
|
+
const at50 = getPixelColor(pixelBuffer, 200, 50, 100, 25);
|
|
136
|
+
const at75 = getPixelColor(pixelBuffer, 200, 50, 150, 25);
|
|
137
|
+
|
|
138
|
+
// At 50%, should be yellow (high R, high G, low B)
|
|
139
|
+
expect(at50.r).toBeGreaterThan(200);
|
|
140
|
+
expect(at50.g).toBeGreaterThan(200);
|
|
141
|
+
expect(at50.b).toBeLessThan(100);
|
|
142
|
+
|
|
143
|
+
await compareGolden(pixelBuffer, 'linear-multi-stop');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// =============================================================================
|
|
148
|
+
// Radial Gradient Tests
|
|
149
|
+
// =============================================================================
|
|
150
|
+
|
|
151
|
+
test.describe('Gradient Rendering: Radial Gradients', () => {
|
|
152
|
+
test.beforeAll(async () => {
|
|
153
|
+
ensureDirectories();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Test: Circle radial gradient at center
|
|
158
|
+
*
|
|
159
|
+
* CSS: radial-gradient(circle at center, white, black)
|
|
160
|
+
* Expected: White center fading to black edges
|
|
161
|
+
*/
|
|
162
|
+
test('renders centered circle radial gradient', async ({ page }) => {
|
|
163
|
+
await setupDeterministicRenderer(page);
|
|
164
|
+
|
|
165
|
+
const pixelBuffer = await renderGradient(page, {
|
|
166
|
+
type: 'radial-gradient',
|
|
167
|
+
css: 'radial-gradient(circle at center, white, black)',
|
|
168
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Center should be white, corners should be dark
|
|
172
|
+
const center = getPixelColor(pixelBuffer, 100, 100, 50, 50);
|
|
173
|
+
const corner = getPixelColor(pixelBuffer, 100, 100, 5, 5);
|
|
174
|
+
|
|
175
|
+
expect(center.r).toBeGreaterThan(200);
|
|
176
|
+
expect(center.g).toBeGreaterThan(200);
|
|
177
|
+
expect(center.b).toBeGreaterThan(200);
|
|
178
|
+
|
|
179
|
+
expect(corner.r).toBeLessThan(100);
|
|
180
|
+
expect(corner.g).toBeLessThan(100);
|
|
181
|
+
expect(corner.b).toBeLessThan(100);
|
|
182
|
+
|
|
183
|
+
await compareGolden(pixelBuffer, 'radial-circle-center');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Test: Offset focal point radial gradient
|
|
188
|
+
*
|
|
189
|
+
* CSS: radial-gradient(circle at 25% 25%, red, blue)
|
|
190
|
+
* Expected: Gradient center offset to top-left quadrant
|
|
191
|
+
*/
|
|
192
|
+
test('renders offset radial gradient', async ({ page }) => {
|
|
193
|
+
await setupDeterministicRenderer(page);
|
|
194
|
+
|
|
195
|
+
const pixelBuffer = await renderGradient(page, {
|
|
196
|
+
type: 'radial-gradient',
|
|
197
|
+
css: 'radial-gradient(circle at 25% 25%, red, blue)',
|
|
198
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Point at 25%, 25% should be red (center of gradient)
|
|
202
|
+
const focalPoint = getPixelColor(pixelBuffer, 100, 100, 25, 25);
|
|
203
|
+
|
|
204
|
+
expect(focalPoint.r).toBeGreaterThan(200);
|
|
205
|
+
expect(focalPoint.b).toBeLessThan(100);
|
|
206
|
+
|
|
207
|
+
await compareGolden(pixelBuffer, 'radial-offset');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// =============================================================================
|
|
212
|
+
// Conic Gradient Tests
|
|
213
|
+
// =============================================================================
|
|
214
|
+
|
|
215
|
+
test.describe('Gradient Rendering: Conic (Sweep) Gradients', () => {
|
|
216
|
+
test.beforeAll(async () => {
|
|
217
|
+
ensureDirectories();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Test: Color wheel conic gradient
|
|
222
|
+
*
|
|
223
|
+
* CSS: conic-gradient(from 0deg, red, yellow, lime, cyan, blue, magenta, red)
|
|
224
|
+
* Expected: Full color wheel around center
|
|
225
|
+
*/
|
|
226
|
+
test('renders color wheel conic gradient', async ({ page }) => {
|
|
227
|
+
await setupDeterministicRenderer(page);
|
|
228
|
+
|
|
229
|
+
const pixelBuffer = await renderGradient(page, {
|
|
230
|
+
type: 'conic-gradient',
|
|
231
|
+
css: 'conic-gradient(from 0deg, red, yellow, lime, cyan, blue, magenta, red)',
|
|
232
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// CSS conic-gradient: 0deg = top (north), angles increase clockwise.
|
|
236
|
+
// With "from 0deg", red starts at top and transitions clockwise:
|
|
237
|
+
// top (0deg) = red, right (90deg) = yellow/lime, bottom (180deg) = cyan, left (270deg) = blue/magenta
|
|
238
|
+
const top = getPixelColor(pixelBuffer, 100, 100, 50, 5); // 0deg - red
|
|
239
|
+
const right = getPixelColor(pixelBuffer, 100, 100, 95, 50); // 90deg - between yellow and lime
|
|
240
|
+
const bottom = getPixelColor(pixelBuffer, 100, 100, 50, 95); // 180deg - cyan
|
|
241
|
+
const left = getPixelColor(pixelBuffer, 100, 100, 5, 50); // 270deg - blue/magenta
|
|
242
|
+
|
|
243
|
+
// Top edge (0deg) should be red
|
|
244
|
+
expect(top.r).toBeGreaterThan(150);
|
|
245
|
+
|
|
246
|
+
await compareGolden(pixelBuffer, 'conic-color-wheel');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Test: Rotated conic gradient
|
|
251
|
+
*
|
|
252
|
+
* CSS: conic-gradient(from 90deg at center, red, blue)
|
|
253
|
+
* Expected: Gradient starts from bottom instead of right
|
|
254
|
+
*/
|
|
255
|
+
test('renders rotated conic gradient', async ({ page }) => {
|
|
256
|
+
await setupDeterministicRenderer(page);
|
|
257
|
+
|
|
258
|
+
const pixelBuffer = await renderGradient(page, {
|
|
259
|
+
type: 'conic-gradient',
|
|
260
|
+
css: 'conic-gradient(from 90deg at center, red, blue)',
|
|
261
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// With 90deg rotation, red should be at bottom, blue at top
|
|
265
|
+
const bottom = getPixelColor(pixelBuffer, 100, 100, 50, 95);
|
|
266
|
+
const top = getPixelColor(pixelBuffer, 100, 100, 50, 5);
|
|
267
|
+
|
|
268
|
+
expect(bottom.r).toBeGreaterThan(bottom.b); // More red at bottom
|
|
269
|
+
expect(top.b).toBeGreaterThan(top.r); // More blue at top
|
|
270
|
+
|
|
271
|
+
await compareGolden(pixelBuffer, 'conic-rotated');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// =============================================================================
|
|
276
|
+
// T-Vector Animation Tests
|
|
277
|
+
// =============================================================================
|
|
278
|
+
|
|
279
|
+
test.describe('Gradient Animation: T-Vector Binding', () => {
|
|
280
|
+
test.beforeAll(async () => {
|
|
281
|
+
ensureDirectories();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Test: Animated gradient angle
|
|
286
|
+
*
|
|
287
|
+
* T-vector controls the gradient angle: as T increases, angle rotates.
|
|
288
|
+
* This validates that P-dimension constraints properly propagate to GPU.
|
|
289
|
+
*/
|
|
290
|
+
test('animates gradient angle via T-vector', async ({ page }) => {
|
|
291
|
+
await setupDeterministicRenderer(page);
|
|
292
|
+
|
|
293
|
+
// Frame 1: T=0, angle=0deg
|
|
294
|
+
const frame1 = await renderAnimatedGradient(page, {
|
|
295
|
+
type: 'linear-gradient',
|
|
296
|
+
baseAngle: 0,
|
|
297
|
+
tValue: 0,
|
|
298
|
+
anglePerT: 90, // 90 degrees per T unit
|
|
299
|
+
colors: ['red', 'blue'],
|
|
300
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Frame 2: T=1, angle=90deg
|
|
304
|
+
const frame2 = await renderAnimatedGradient(page, {
|
|
305
|
+
type: 'linear-gradient',
|
|
306
|
+
baseAngle: 0,
|
|
307
|
+
tValue: 1,
|
|
308
|
+
anglePerT: 90,
|
|
309
|
+
colors: ['red', 'blue'],
|
|
310
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Frame 1 should be horizontal (red on left)
|
|
314
|
+
const f1Left = getPixelColor(frame1, 100, 100, 10, 50);
|
|
315
|
+
expect(f1Left.r).toBeGreaterThan(f1Left.b);
|
|
316
|
+
|
|
317
|
+
// Frame 2 should be vertical (red on top)
|
|
318
|
+
const f2Top = getPixelColor(frame2, 100, 100, 50, 10);
|
|
319
|
+
expect(f2Top.r).toBeGreaterThan(f2Top.b);
|
|
320
|
+
|
|
321
|
+
// Frames should be different
|
|
322
|
+
const hash1 = computeHash(frame1);
|
|
323
|
+
const hash2 = computeHash(frame2);
|
|
324
|
+
expect(hash1).not.toBe(hash2);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Test: Animated color stop positions
|
|
329
|
+
*
|
|
330
|
+
* T-vector controls color stop positions for dynamic effects.
|
|
331
|
+
*/
|
|
332
|
+
test('animates color stop positions via T-vector', async ({ page }) => {
|
|
333
|
+
await setupDeterministicRenderer(page);
|
|
334
|
+
|
|
335
|
+
// Frame 1: First stop at 30%
|
|
336
|
+
const frame1 = await renderAnimatedGradient(page, {
|
|
337
|
+
type: 'linear-gradient',
|
|
338
|
+
baseAngle: 90, // Top to bottom
|
|
339
|
+
tValue: 0.3,
|
|
340
|
+
colors: ['red', 'blue'],
|
|
341
|
+
stopPositions: [{ base: 0, tFactor: 1 }, { base: 1, tFactor: 0 }],
|
|
342
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Frame 2: First stop at 70%
|
|
346
|
+
const frame2 = await renderAnimatedGradient(page, {
|
|
347
|
+
type: 'linear-gradient',
|
|
348
|
+
baseAngle: 90,
|
|
349
|
+
tValue: 0.7,
|
|
350
|
+
colors: ['red', 'blue'],
|
|
351
|
+
stopPositions: [{ base: 0, tFactor: 1 }, { base: 1, tFactor: 0 }],
|
|
352
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// In frame 1, red extends further (to ~30% from top)
|
|
356
|
+
// In frame 2, red extends much further (to ~70% from top)
|
|
357
|
+
const f1Mid = getPixelColor(frame1, 100, 100, 50, 50);
|
|
358
|
+
const f2Mid = getPixelColor(frame2, 100, 100, 50, 50);
|
|
359
|
+
|
|
360
|
+
// Frame 2 middle should have more red than frame 1 middle
|
|
361
|
+
expect(f2Mid.r).toBeGreaterThan(f1Mid.r);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// =============================================================================
|
|
366
|
+
// Tile Mode Tests
|
|
367
|
+
// =============================================================================
|
|
368
|
+
|
|
369
|
+
test.describe('Gradient Rendering: Tile Modes', () => {
|
|
370
|
+
test.beforeAll(async () => {
|
|
371
|
+
ensureDirectories();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Test: Repeat tile mode
|
|
376
|
+
*
|
|
377
|
+
* Gradient should repeat beyond 100% position
|
|
378
|
+
*/
|
|
379
|
+
test('applies repeat tile mode correctly', async ({ page }) => {
|
|
380
|
+
await setupDeterministicRenderer(page);
|
|
381
|
+
|
|
382
|
+
const pixelBuffer = await renderGradient(page, {
|
|
383
|
+
type: 'linear-gradient',
|
|
384
|
+
css: 'linear-gradient(to right, red 0%, blue 25%)',
|
|
385
|
+
tileMode: 'repeat',
|
|
386
|
+
bounds: { x: 0, y: 0, width: 200, height: 50 },
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// With repeat, the gradient at 50% should look like gradient at 0%
|
|
390
|
+
const at0 = getPixelColor(pixelBuffer, 200, 50, 5, 25);
|
|
391
|
+
const at50 = getPixelColor(pixelBuffer, 200, 50, 105, 25);
|
|
392
|
+
|
|
393
|
+
// Both should be similar (both at start of repeat cycle)
|
|
394
|
+
expect(Math.abs(at0.r - at50.r)).toBeLessThan(30);
|
|
395
|
+
|
|
396
|
+
await compareGolden(pixelBuffer, 'tile-repeat');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Test: Mirror tile mode
|
|
401
|
+
*
|
|
402
|
+
* Gradient should reverse direction at 100%
|
|
403
|
+
*/
|
|
404
|
+
test('applies mirror tile mode correctly', async ({ page }) => {
|
|
405
|
+
await setupDeterministicRenderer(page);
|
|
406
|
+
|
|
407
|
+
const pixelBuffer = await renderGradient(page, {
|
|
408
|
+
type: 'linear-gradient',
|
|
409
|
+
css: 'linear-gradient(to right, red 0%, blue 50%)',
|
|
410
|
+
tileMode: 'mirror',
|
|
411
|
+
bounds: { x: 0, y: 0, width: 200, height: 50 },
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// With mirror, color at 75% of total width should be same as at 25%
|
|
415
|
+
const at25 = getPixelColor(pixelBuffer, 200, 50, 50, 25);
|
|
416
|
+
const at75 = getPixelColor(pixelBuffer, 200, 50, 150, 25);
|
|
417
|
+
|
|
418
|
+
// Mirror means 75% in second half = 25% position
|
|
419
|
+
expect(Math.abs(at25.r - at75.r)).toBeLessThan(30);
|
|
420
|
+
|
|
421
|
+
await compareGolden(pixelBuffer, 'tile-mirror');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// =============================================================================
|
|
426
|
+
// P-Dimension Integrity Tests
|
|
427
|
+
// =============================================================================
|
|
428
|
+
|
|
429
|
+
test.describe('Gradient Rendering: P-Dimension Integrity', () => {
|
|
430
|
+
test.beforeAll(async () => {
|
|
431
|
+
ensureDirectories();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Test: Exact rational color preservation
|
|
436
|
+
*
|
|
437
|
+
* Color specified as rational 255/3 (~85) should not drift due to
|
|
438
|
+
* floating-point operations in constraint evaluation.
|
|
439
|
+
*/
|
|
440
|
+
test('preserves exact rational color values', async ({ page }) => {
|
|
441
|
+
await setupDeterministicRenderer(page);
|
|
442
|
+
|
|
443
|
+
// Specify color as exact rational: RGB(255/3, 255/3, 255/3) = gray ~85
|
|
444
|
+
const pixelBuffer = await renderGradient(page, {
|
|
445
|
+
type: 'solid',
|
|
446
|
+
rationalColor: {
|
|
447
|
+
r: { numerator: 255n, denominator: 3n },
|
|
448
|
+
g: { numerator: 255n, denominator: 3n },
|
|
449
|
+
b: { numerator: 255n, denominator: 3n },
|
|
450
|
+
},
|
|
451
|
+
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// Sample center pixel
|
|
455
|
+
const center = getPixelColor(pixelBuffer, 100, 100, 50, 50);
|
|
456
|
+
|
|
457
|
+
// 255/3 = 85 (floor) - should be exactly 85, not 84 or 86
|
|
458
|
+
expect(center.r).toBe(85);
|
|
459
|
+
expect(center.g).toBe(85);
|
|
460
|
+
expect(center.b).toBe(85);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Test: Topology-preserving color clamping
|
|
465
|
+
*
|
|
466
|
+
* Colors outside [0, 255] should be clamped, preserving ordering.
|
|
467
|
+
*/
|
|
468
|
+
test('clamps out-of-range colors while preserving order', async ({ page }) => {
|
|
469
|
+
await setupDeterministicRenderer(page);
|
|
470
|
+
|
|
471
|
+
const pixelBuffer = await renderGradient(page, {
|
|
472
|
+
type: 'linear-gradient',
|
|
473
|
+
colors: [
|
|
474
|
+
{ r: -10, g: 0, b: 0 }, // Should clamp to 0
|
|
475
|
+
{ r: 300, g: 0, b: 0 }, // Should clamp to 255
|
|
476
|
+
],
|
|
477
|
+
bounds: { x: 0, y: 0, width: 100, height: 50 },
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const left = getPixelColor(pixelBuffer, 100, 50, 5, 25);
|
|
481
|
+
const right = getPixelColor(pixelBuffer, 100, 50, 95, 25);
|
|
482
|
+
|
|
483
|
+
// Left should be clamped to black (r≈0, allowing 1-2 for subpixel tolerance)
|
|
484
|
+
expect(left.r).toBeLessThanOrEqual(2);
|
|
485
|
+
|
|
486
|
+
// Right should be clamped to bright red (r≈255, allowing 1-2 for subpixel tolerance)
|
|
487
|
+
expect(right.r).toBeGreaterThanOrEqual(253);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// =============================================================================
|
|
492
|
+
// Helper Functions
|
|
493
|
+
// =============================================================================
|
|
494
|
+
|
|
495
|
+
function ensureDirectories(): void {
|
|
496
|
+
if (!fs.existsSync(GOLDEN_DIR)) {
|
|
497
|
+
fs.mkdirSync(GOLDEN_DIR, { recursive: true });
|
|
498
|
+
}
|
|
499
|
+
if (!fs.existsSync(FAILURE_DIR)) {
|
|
500
|
+
fs.mkdirSync(FAILURE_DIR, { recursive: true });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function setupDeterministicRenderer(page: Page): Promise<void> {
|
|
505
|
+
await page.goto('/test-harness.html', { waitUntil: 'networkidle' });
|
|
506
|
+
|
|
507
|
+
await page.evaluate((config) => {
|
|
508
|
+
(window as any).__VS_CANVASKIT_CONFIG__ = config;
|
|
509
|
+
}, CANVASKIT_DETERMINISTIC_CONFIG);
|
|
510
|
+
|
|
511
|
+
await page.waitForFunction(() => (window as any).__VS_RENDERER_READY__ === true, {
|
|
512
|
+
timeout: 10000,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function renderGradient(
|
|
517
|
+
page: Page,
|
|
518
|
+
spec: Record<string, unknown>,
|
|
519
|
+
): Promise<Uint8Array> {
|
|
520
|
+
const base64 = await page.evaluate(async (gradientSpec) => {
|
|
521
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
522
|
+
await renderer.renderGradient(gradientSpec);
|
|
523
|
+
|
|
524
|
+
const canvas = document.getElementById('vs-canvas') as HTMLCanvasElement;
|
|
525
|
+
const ctx = canvas.getContext('2d');
|
|
526
|
+
if (!ctx) throw new Error('No 2D context');
|
|
527
|
+
|
|
528
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
529
|
+
const buffer = imageData.data;
|
|
530
|
+
|
|
531
|
+
let binary = '';
|
|
532
|
+
const chunkSize = 8192;
|
|
533
|
+
for (let i = 0; i < buffer.length; i += chunkSize) {
|
|
534
|
+
const chunk = buffer.subarray(i, Math.min(i + chunkSize, buffer.length));
|
|
535
|
+
binary += String.fromCharCode.apply(null, Array.from(chunk));
|
|
536
|
+
}
|
|
537
|
+
return btoa(binary);
|
|
538
|
+
}, spec);
|
|
539
|
+
|
|
540
|
+
const binary = atob(base64);
|
|
541
|
+
const bytes = new Uint8Array(binary.length);
|
|
542
|
+
for (let i = 0; i < binary.length; i++) {
|
|
543
|
+
bytes[i] = binary.charCodeAt(i);
|
|
544
|
+
}
|
|
545
|
+
return bytes;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function renderAnimatedGradient(
|
|
549
|
+
page: Page,
|
|
550
|
+
spec: Record<string, unknown>,
|
|
551
|
+
): Promise<Uint8Array> {
|
|
552
|
+
return renderGradient(page, { ...spec, animated: true });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function computeHash(buffer: Uint8Array): string {
|
|
556
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function getPixelColor(
|
|
560
|
+
buffer: Uint8Array,
|
|
561
|
+
width: number,
|
|
562
|
+
height: number,
|
|
563
|
+
x: number,
|
|
564
|
+
y: number,
|
|
565
|
+
): { r: number; g: number; b: number; a: number } {
|
|
566
|
+
const offset = (y * width + x) * 4;
|
|
567
|
+
return {
|
|
568
|
+
r: buffer[offset],
|
|
569
|
+
g: buffer[offset + 1],
|
|
570
|
+
b: buffer[offset + 2],
|
|
571
|
+
a: buffer[offset + 3],
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function analyzeHorizontalGradient(
|
|
576
|
+
buffer: Uint8Array,
|
|
577
|
+
width: number,
|
|
578
|
+
height: number,
|
|
579
|
+
): { leftEdgeColor: { r: number; g: number; b: number }; rightEdgeColor: { r: number; g: number; b: number } } {
|
|
580
|
+
const midY = Math.floor(height / 2);
|
|
581
|
+
return {
|
|
582
|
+
leftEdgeColor: getPixelColor(buffer, width, height, 5, midY),
|
|
583
|
+
rightEdgeColor: getPixelColor(buffer, width, height, width - 5, midY),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function compareGolden(buffer: Uint8Array, testName: string): Promise<void> {
|
|
588
|
+
const hash = computeHash(buffer);
|
|
589
|
+
const hashFile = path.join(GOLDEN_DIR, `${testName}.sha256`);
|
|
590
|
+
|
|
591
|
+
if (fs.existsSync(hashFile)) {
|
|
592
|
+
const goldenHash = fs.readFileSync(hashFile, 'utf-8').trim();
|
|
593
|
+
if (hash !== goldenHash) {
|
|
594
|
+
// Save failure for debugging
|
|
595
|
+
const failFile = path.join(FAILURE_DIR, `${testName}-fail.raw`);
|
|
596
|
+
fs.writeFileSync(failFile, buffer);
|
|
597
|
+
}
|
|
598
|
+
expect(hash).toBe(goldenHash);
|
|
599
|
+
} else {
|
|
600
|
+
// First run: save as golden
|
|
601
|
+
const rawFile = path.join(GOLDEN_DIR, `${testName}.raw`);
|
|
602
|
+
fs.writeFileSync(rawFile, buffer);
|
|
603
|
+
fs.writeFileSync(hashFile, hash);
|
|
604
|
+
console.log(`[GOLDEN] Saved: ${testName} (${hash})`);
|
|
605
|
+
}
|
|
606
|
+
}
|