@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 @@
1
+ 2b4a52a0684d487de5dd6f1559255eb89d6ca9197d2fb277bfd859c3a3aa208c
@@ -0,0 +1 @@
1
+ 193bb1df97bcd23fbb7bf8829018130241b15b9e4e6813077e3f0bcc447de974
@@ -0,0 +1 @@
1
+ 088aec9b0ea35189e9e4b67f3f5b9d182cda0242c6fae682010677c274b5473f
@@ -0,0 +1 @@
1
+ 636c5f60922e40ff263bc1bb9d6512d648dc4448c900a82e52b7a6d91372df74
@@ -0,0 +1 @@
1
+ e2fbf8f050f5bfcea45d69bb3b269abb6b75aed389d3c13f2c9cf65338ad2932
@@ -0,0 +1 @@
1
+ 23fbf97fd2be49d531ce5ff7792b2a4d90444ea024b8dbb5d88c3325e674de38
@@ -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
+ }