@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,398 @@
1
+ /**
2
+ * Semantic Translator for P/Q Dimension Bridge (Phase 7: Task 20)
3
+ *
4
+ * This module converts raw solver output (VarId -> Rational maps) into
5
+ * entity-level coordinate descriptions suitable for LLM comprehension.
6
+ *
7
+ * ## Purpose
8
+ *
9
+ * The P-dimension solver produces solutions in the form:
10
+ * { "1:x": "100/1", "1:y": "200/1", "2:x": "300/1", ... }
11
+ *
12
+ * This is not LLM-friendly. The SemanticTranslator converts this to:
13
+ * {
14
+ * entities: [
15
+ * { entityId: 1, coordinates: { x: 100, y: 200 }, description: "Point at (100, 200)" },
16
+ * { entityId: 2, coordinates: { x: 300, y: 150 }, description: "Point at (300, 150)" }
17
+ * ],
18
+ * relationships: [
19
+ * { type: "distance", from: 1, to: 2, value: "~212 units" }
20
+ * ]
21
+ * }
22
+ *
23
+ * ## Axiom 2: Ouroboros Binding
24
+ *
25
+ * This module sits at the P/Q dimension boundary. It translates deterministic
26
+ * solver output (P) into natural language suitable for non-deterministic
27
+ * oracles (Q/LLM).
28
+ *
29
+ * ## Usage
30
+ *
31
+ * ```typescript
32
+ * const translator = new SemanticTranslator(entityRegistry);
33
+ * const semanticSolution = translator.translateSolution(rawSolution);
34
+ * const diff = translator.compareSolutions(solution1, solution2);
35
+ * ```
36
+ */
37
+ // =============================================================================
38
+ // SemanticTranslator Implementation
39
+ // =============================================================================
40
+ /**
41
+ * Translates raw solver output to LLM-friendly semantic descriptions.
42
+ */
43
+ export class SemanticTranslator {
44
+ registry;
45
+ constructor(registry) {
46
+ this.registry = registry;
47
+ }
48
+ /**
49
+ * Parse a raw VarId key (e.g., "5:x") into its components.
50
+ */
51
+ parseVarId(key) {
52
+ const [entityStr, component] = key.split(':');
53
+ if (!entityStr || !component)
54
+ return null;
55
+ const entityId = parseInt(entityStr, 10);
56
+ if (isNaN(entityId))
57
+ return null;
58
+ const validComponents = [
59
+ 'x', 'y', 'z', 't', 'value', 'r', 'g', 'b', 'alpha', 'position',
60
+ ];
61
+ if (!validComponents.includes(component))
62
+ return null;
63
+ return { entityId, component: component };
64
+ }
65
+ /**
66
+ * Parse a rational string (e.g., "100/1" or "100") to a number.
67
+ *
68
+ * WARNING: This converts P-dimension exact rationals to Q-dimension floats.
69
+ * Only use for display/LLM consumption, NEVER for constraint solving.
70
+ */
71
+ parseRationalToFloat(value) {
72
+ const parts = value.split('/');
73
+ if (parts.length === 2) {
74
+ const num = parseFloat(parts[0]);
75
+ const denom = parseFloat(parts[1]);
76
+ if (denom === 0)
77
+ return NaN;
78
+ return num / denom;
79
+ }
80
+ return parseFloat(value);
81
+ }
82
+ /**
83
+ * Translate a raw solution to a semantic solution.
84
+ */
85
+ translateSolution(rawSolution, solutionIndex = 0) {
86
+ // Group by entity
87
+ const entityMap = new Map();
88
+ for (const [key, value] of rawSolution) {
89
+ const parsed = this.parseVarId(key);
90
+ if (!parsed)
91
+ continue;
92
+ let components = entityMap.get(parsed.entityId);
93
+ if (!components) {
94
+ components = new Map();
95
+ entityMap.set(parsed.entityId, components);
96
+ }
97
+ components.set(parsed.component, this.parseRationalToFloat(value));
98
+ }
99
+ // Build semantic entities
100
+ const entities = [];
101
+ for (const [entityId, components] of entityMap) {
102
+ const metadata = this.registry.get(entityId);
103
+ const entity = this.buildSemanticEntity(entityId, metadata?.type ?? 'unknown', metadata?.name, components);
104
+ entities.push(entity);
105
+ }
106
+ // Sort by entity ID for consistent output
107
+ entities.sort((a, b) => a.entityId - b.entityId);
108
+ // Derive relationships
109
+ const relationships = this.deriveRelationships(entities);
110
+ // Generate summary
111
+ const summary = this.generateSummary(entities, relationships);
112
+ return {
113
+ solutionIndex,
114
+ entities,
115
+ relationships,
116
+ summary,
117
+ };
118
+ }
119
+ /**
120
+ * Build a semantic entity from raw components.
121
+ */
122
+ buildSemanticEntity(entityId, type, name, components) {
123
+ const entity = {
124
+ entityId,
125
+ type,
126
+ name,
127
+ description: '',
128
+ };
129
+ // Build coordinates/color/scalar based on entity type
130
+ if (type === 'color_stop') {
131
+ entity.color = {
132
+ r: components.get('r'),
133
+ g: components.get('g'),
134
+ b: components.get('b'),
135
+ alpha: components.get('alpha'),
136
+ position: components.get('position'),
137
+ };
138
+ entity.description = this.describeColorStop(entity.color, name);
139
+ }
140
+ else if (type === 'scalar') {
141
+ entity.scalar = {
142
+ value: components.get('value'),
143
+ };
144
+ entity.description = this.describeScalar(entity.scalar, name);
145
+ }
146
+ else {
147
+ // Spatial entity
148
+ entity.coordinates = {
149
+ x: components.get('x'),
150
+ y: components.get('y'),
151
+ z: components.get('z'),
152
+ t: components.get('t'),
153
+ };
154
+ entity.description = this.describeSpatialEntity(type, entity.coordinates, name);
155
+ }
156
+ return entity;
157
+ }
158
+ /**
159
+ * Generate description for a spatial entity.
160
+ */
161
+ describeSpatialEntity(type, coords, name) {
162
+ const nameStr = name ? `"${name}" ` : '';
163
+ const x = coords.x !== undefined ? this.formatCoord(coords.x) : '?';
164
+ const y = coords.y !== undefined ? this.formatCoord(coords.y) : '?';
165
+ switch (type) {
166
+ case 'point':
167
+ case 'control_point':
168
+ return `${nameStr}${type === 'control_point' ? 'Control point' : 'Point'} at (${x}, ${y})`;
169
+ case 'rect':
170
+ return `${nameStr}Rectangle at (${x}, ${y})`;
171
+ case 'circle':
172
+ return `${nameStr}Circle centered at (${x}, ${y})`;
173
+ case 'text':
174
+ return `${nameStr}Text at (${x}, ${y})`;
175
+ default:
176
+ return `${nameStr}Entity ${type} at (${x}, ${y})`;
177
+ }
178
+ }
179
+ /**
180
+ * Generate description for a color stop.
181
+ */
182
+ describeColorStop(color, name) {
183
+ const nameStr = name ? `"${name}" ` : '';
184
+ const r = color.r !== undefined ? Math.round(color.r) : '?';
185
+ const g = color.g !== undefined ? Math.round(color.g) : '?';
186
+ const b = color.b !== undefined ? Math.round(color.b) : '?';
187
+ const pos = color.position !== undefined ? `${(color.position * 100).toFixed(0)}%` : '?';
188
+ return `${nameStr}Color stop at ${pos}: rgb(${r}, ${g}, ${b})`;
189
+ }
190
+ /**
191
+ * Generate description for a scalar entity.
192
+ */
193
+ describeScalar(scalar, name) {
194
+ const nameStr = name ? `"${name}"` : 'Scalar';
195
+ const val = scalar.value !== undefined ? this.formatCoord(scalar.value) : '?';
196
+ return `${nameStr} = ${val}`;
197
+ }
198
+ /**
199
+ * Format a coordinate value for display.
200
+ */
201
+ formatCoord(value) {
202
+ // Round to reasonable precision for display
203
+ if (Number.isInteger(value)) {
204
+ return value.toString();
205
+ }
206
+ return value.toFixed(2).replace(/\.?0+$/, '');
207
+ }
208
+ /**
209
+ * Derive geometric relationships between entities.
210
+ */
211
+ deriveRelationships(entities) {
212
+ const relationships = [];
213
+ // Find horizontally aligned entities (same Y)
214
+ const byY = new Map();
215
+ for (const entity of entities) {
216
+ if (entity.coordinates?.y !== undefined) {
217
+ const y = Math.round(entity.coordinates.y);
218
+ let list = byY.get(y);
219
+ if (!list) {
220
+ list = [];
221
+ byY.set(y, list);
222
+ }
223
+ list.push(entity);
224
+ }
225
+ }
226
+ for (const [y, aligned] of byY) {
227
+ if (aligned.length >= 2) {
228
+ const ids = aligned.map(e => e.entityId).join(', ');
229
+ relationships.push({
230
+ type: 'alignment',
231
+ fromEntityId: aligned[0].entityId,
232
+ toEntityId: aligned[aligned.length - 1].entityId,
233
+ description: `Entities ${ids} are horizontally aligned at y=${y}`,
234
+ });
235
+ }
236
+ }
237
+ // Find vertically aligned entities (same X)
238
+ const byX = new Map();
239
+ for (const entity of entities) {
240
+ if (entity.coordinates?.x !== undefined) {
241
+ const x = Math.round(entity.coordinates.x);
242
+ let list = byX.get(x);
243
+ if (!list) {
244
+ list = [];
245
+ byX.set(x, list);
246
+ }
247
+ list.push(entity);
248
+ }
249
+ }
250
+ for (const [x, aligned] of byX) {
251
+ if (aligned.length >= 2) {
252
+ const ids = aligned.map(e => e.entityId).join(', ');
253
+ relationships.push({
254
+ type: 'alignment',
255
+ fromEntityId: aligned[0].entityId,
256
+ toEntityId: aligned[aligned.length - 1].entityId,
257
+ description: `Entities ${ids} are vertically aligned at x=${x}`,
258
+ });
259
+ }
260
+ }
261
+ return relationships;
262
+ }
263
+ /**
264
+ * Generate a high-level summary of the solution.
265
+ */
266
+ generateSummary(entities, relationships) {
267
+ const entityCounts = new Map();
268
+ for (const entity of entities) {
269
+ entityCounts.set(entity.type, (entityCounts.get(entity.type) ?? 0) + 1);
270
+ }
271
+ const parts = [];
272
+ for (const [type, count] of entityCounts) {
273
+ parts.push(`${count} ${type}${count > 1 ? 's' : ''}`);
274
+ }
275
+ let summary = `Solution with ${entities.length} entities: ${parts.join(', ')}.`;
276
+ if (relationships.length > 0) {
277
+ summary += ` ${relationships.length} geometric relationship${relationships.length > 1 ? 's' : ''} detected.`;
278
+ }
279
+ return summary;
280
+ }
281
+ /**
282
+ * Compare two solutions and generate a diff.
283
+ */
284
+ compareSolutions(solution1, solution2) {
285
+ const diffs = [];
286
+ const entities1 = new Map(solution1.entities.map(e => [e.entityId, e]));
287
+ const entities2 = new Map(solution2.entities.map(e => [e.entityId, e]));
288
+ // Find entities that differ
289
+ for (const [entityId, e1] of entities1) {
290
+ const e2 = entities2.get(entityId);
291
+ if (!e2)
292
+ continue;
293
+ if (!this.entitiesEqual(e1, e2)) {
294
+ diffs.push({
295
+ entityId,
296
+ name: e1.name ?? e2.name,
297
+ solution1: e1,
298
+ solution2: e2,
299
+ description: this.describeDiff(e1, e2),
300
+ });
301
+ }
302
+ }
303
+ const summary = diffs.length === 0
304
+ ? 'Solutions are identical'
305
+ : `${diffs.length} entities differ: ${diffs.map(d => d.entityId).join(', ')}`;
306
+ return { differingEntities: diffs, summary };
307
+ }
308
+ /**
309
+ * Check if two semantic entities are equal.
310
+ */
311
+ entitiesEqual(e1, e2) {
312
+ const tolerance = 1e-6;
313
+ if (e1.coordinates && e2.coordinates) {
314
+ for (const key of ['x', 'y', 'z', 't']) {
315
+ const v1 = e1.coordinates[key];
316
+ const v2 = e2.coordinates[key];
317
+ if (v1 !== undefined && v2 !== undefined && Math.abs(v1 - v2) > tolerance) {
318
+ return false;
319
+ }
320
+ }
321
+ }
322
+ if (e1.color && e2.color) {
323
+ for (const key of ['r', 'g', 'b', 'alpha', 'position']) {
324
+ const v1 = e1.color[key];
325
+ const v2 = e2.color[key];
326
+ if (v1 !== undefined && v2 !== undefined && Math.abs(v1 - v2) > tolerance) {
327
+ return false;
328
+ }
329
+ }
330
+ }
331
+ if (e1.scalar && e2.scalar) {
332
+ const v1 = e1.scalar.value;
333
+ const v2 = e2.scalar.value;
334
+ if (v1 !== undefined && v2 !== undefined && Math.abs(v1 - v2) > tolerance) {
335
+ return false;
336
+ }
337
+ }
338
+ return true;
339
+ }
340
+ /**
341
+ * Describe the difference between two entities.
342
+ */
343
+ describeDiff(e1, e2) {
344
+ if (e1.coordinates && e2.coordinates) {
345
+ const changes = [];
346
+ if (e1.coordinates.x !== e2.coordinates.x) {
347
+ changes.push(`x: ${this.formatCoord(e1.coordinates.x ?? 0)} -> ${this.formatCoord(e2.coordinates.x ?? 0)}`);
348
+ }
349
+ if (e1.coordinates.y !== e2.coordinates.y) {
350
+ changes.push(`y: ${this.formatCoord(e1.coordinates.y ?? 0)} -> ${this.formatCoord(e2.coordinates.y ?? 0)}`);
351
+ }
352
+ return `Position changes: ${changes.join(', ')}`;
353
+ }
354
+ return 'Values differ';
355
+ }
356
+ /**
357
+ * Translate multiple solutions and generate comparative descriptions.
358
+ */
359
+ translateMultipleSolutions(rawSolutions) {
360
+ const solutions = rawSolutions.map((raw, idx) => this.translateSolution(raw, idx));
361
+ if (solutions.length < 2) {
362
+ return {
363
+ solutions,
364
+ comparison: 'Only one solution available.',
365
+ };
366
+ }
367
+ // Generate pairwise comparison for first two solutions
368
+ const diff = this.compareSolutions(solutions[0], solutions[1]);
369
+ let comparison = `${solutions.length} solutions found.\n\n`;
370
+ comparison += `Solution 0 vs Solution 1:\n${diff.summary}\n\n`;
371
+ for (const entityDiff of diff.differingEntities) {
372
+ comparison += ` Entity ${entityDiff.entityId}: ${entityDiff.description}\n`;
373
+ }
374
+ return { solutions, comparison };
375
+ }
376
+ }
377
+ // =============================================================================
378
+ // Utility: Create Entity Registry from Array
379
+ // =============================================================================
380
+ /**
381
+ * Create an EntityRegistry from an array of metadata.
382
+ */
383
+ export function createEntityRegistry(entities) {
384
+ const map = new Map(entities.map(e => [e.entityId, e]));
385
+ return {
386
+ get: (id) => map.get(id),
387
+ getAll: () => entities,
388
+ };
389
+ }
390
+ /**
391
+ * Create an empty EntityRegistry (all entities will be "unknown" type).
392
+ */
393
+ export function createEmptyRegistry() {
394
+ return {
395
+ get: () => undefined,
396
+ getAll: () => [],
397
+ };
398
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@viewscript/renderer",
3
+ "version": "0.1.0-202605140639",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "test": "vitest run",
10
+ "test:e2e": "playwright test",
11
+ "test:e2e:visual": "playwright test --project=visual-regression",
12
+ "test:e2e:sync": "playwright test --project=bilayer-sync",
13
+ "test:e2e:perf": "playwright test --project=performance",
14
+ "test:e2e:async-race": "playwright test --project=async-race",
15
+ "serve:test": "npx serve tests/e2e -l 3000 -s",
16
+ "lint": "eslint src"
17
+ },
18
+ "dependencies": {
19
+ "canvaskit-wasm": "^0.39.0"
20
+ },
21
+ "devDependencies": {
22
+ "@playwright/test": "^1.44.0",
23
+ "@types/node": "^20.0.0",
24
+ "serve": "^14.2.0",
25
+ "typescript": "^5.4.0",
26
+ "vitest": "^1.6.0"
27
+ }
28
+ }
@@ -0,0 +1,226 @@
1
+ # Instructions
2
+
3
+ - Following Playwright test failed.
4
+ - Explain why, be concise, respect Playwright best practices.
5
+ - Provide a snippet of code with the fix, if possible.
6
+
7
+ # Test info
8
+
9
+ - Name: gradient-animation.spec.ts >> Gradient Rendering: P-Dimension Integrity >> clamps out-of-range colors while preserving order
10
+ - Location: tests/e2e/gradient-animation.spec.ts:467:3
11
+
12
+ # Error details
13
+
14
+ ```
15
+ Error: expect(received).toBe(expected) // Object.is equality
16
+
17
+ Expected: 0
18
+ Received: 14
19
+ ```
20
+
21
+ # Test source
22
+
23
+ ```ts
24
+ 383 | css: 'linear-gradient(to right, red 0%, blue 25%)',
25
+ 384 | tileMode: 'repeat',
26
+ 385 | bounds: { x: 0, y: 0, width: 200, height: 50 },
27
+ 386 | });
28
+ 387 |
29
+ 388 | // With repeat, the gradient at 50% should look like gradient at 0%
30
+ 389 | const at0 = getPixelColor(pixelBuffer, 200, 50, 5, 25);
31
+ 390 | const at50 = getPixelColor(pixelBuffer, 200, 50, 105, 25);
32
+ 391 |
33
+ 392 | // Both should be similar (both at start of repeat cycle)
34
+ 393 | expect(Math.abs(at0.r - at50.r)).toBeLessThan(30);
35
+ 394 |
36
+ 395 | await compareGolden(pixelBuffer, 'tile-repeat');
37
+ 396 | });
38
+ 397 |
39
+ 398 | /**
40
+ 399 | * Test: Mirror tile mode
41
+ 400 | *
42
+ 401 | * Gradient should reverse direction at 100%
43
+ 402 | */
44
+ 403 | test('applies mirror tile mode correctly', async ({ page }) => {
45
+ 404 | await setupDeterministicRenderer(page);
46
+ 405 |
47
+ 406 | const pixelBuffer = await renderGradient(page, {
48
+ 407 | type: 'linear-gradient',
49
+ 408 | css: 'linear-gradient(to right, red 0%, blue 50%)',
50
+ 409 | tileMode: 'mirror',
51
+ 410 | bounds: { x: 0, y: 0, width: 200, height: 50 },
52
+ 411 | });
53
+ 412 |
54
+ 413 | // With mirror, color at 75% of total width should be same as at 25%
55
+ 414 | const at25 = getPixelColor(pixelBuffer, 200, 50, 50, 25);
56
+ 415 | const at75 = getPixelColor(pixelBuffer, 200, 50, 150, 25);
57
+ 416 |
58
+ 417 | // Mirror means 75% in second half = 25% position
59
+ 418 | expect(Math.abs(at25.r - at75.r)).toBeLessThan(30);
60
+ 419 |
61
+ 420 | await compareGolden(pixelBuffer, 'tile-mirror');
62
+ 421 | });
63
+ 422 | });
64
+ 423 |
65
+ 424 | // =============================================================================
66
+ 425 | // P-Dimension Integrity Tests
67
+ 426 | // =============================================================================
68
+ 427 |
69
+ 428 | test.describe('Gradient Rendering: P-Dimension Integrity', () => {
70
+ 429 | test.beforeAll(async () => {
71
+ 430 | ensureDirectories();
72
+ 431 | });
73
+ 432 |
74
+ 433 | /**
75
+ 434 | * Test: Exact rational color preservation
76
+ 435 | *
77
+ 436 | * Color specified as rational 255/3 (~85) should not drift due to
78
+ 437 | * floating-point operations in constraint evaluation.
79
+ 438 | */
80
+ 439 | test('preserves exact rational color values', async ({ page }) => {
81
+ 440 | await setupDeterministicRenderer(page);
82
+ 441 |
83
+ 442 | // Specify color as exact rational: RGB(255/3, 255/3, 255/3) = gray ~85
84
+ 443 | const pixelBuffer = await renderGradient(page, {
85
+ 444 | type: 'solid',
86
+ 445 | rationalColor: {
87
+ 446 | r: { numerator: 255n, denominator: 3n },
88
+ 447 | g: { numerator: 255n, denominator: 3n },
89
+ 448 | b: { numerator: 255n, denominator: 3n },
90
+ 449 | },
91
+ 450 | bounds: { x: 0, y: 0, width: 100, height: 100 },
92
+ 451 | });
93
+ 452 |
94
+ 453 | // Sample center pixel
95
+ 454 | const center = getPixelColor(pixelBuffer, 100, 100, 50, 50);
96
+ 455 |
97
+ 456 | // 255/3 = 85 (floor) - should be exactly 85, not 84 or 86
98
+ 457 | expect(center.r).toBe(85);
99
+ 458 | expect(center.g).toBe(85);
100
+ 459 | expect(center.b).toBe(85);
101
+ 460 | });
102
+ 461 |
103
+ 462 | /**
104
+ 463 | * Test: Topology-preserving color clamping
105
+ 464 | *
106
+ 465 | * Colors outside [0, 255] should be clamped, preserving ordering.
107
+ 466 | */
108
+ 467 | test('clamps out-of-range colors while preserving order', async ({ page }) => {
109
+ 468 | await setupDeterministicRenderer(page);
110
+ 469 |
111
+ 470 | const pixelBuffer = await renderGradient(page, {
112
+ 471 | type: 'linear-gradient',
113
+ 472 | colors: [
114
+ 473 | { r: -10, g: 0, b: 0 }, // Should clamp to 0
115
+ 474 | { r: 300, g: 0, b: 0 }, // Should clamp to 255
116
+ 475 | ],
117
+ 476 | bounds: { x: 0, y: 0, width: 100, height: 50 },
118
+ 477 | });
119
+ 478 |
120
+ 479 | const left = getPixelColor(pixelBuffer, 100, 50, 5, 25);
121
+ 480 | const right = getPixelColor(pixelBuffer, 100, 50, 95, 25);
122
+ 481 |
123
+ 482 | // Left should be clamped to black (r=0)
124
+ > 483 | expect(left.r).toBe(0);
125
+ | ^ Error: expect(received).toBe(expected) // Object.is equality
126
+ 484 |
127
+ 485 | // Right should be clamped to bright red (r=255)
128
+ 486 | expect(right.r).toBe(255);
129
+ 487 | });
130
+ 488 | });
131
+ 489 |
132
+ 490 | // =============================================================================
133
+ 491 | // Helper Functions
134
+ 492 | // =============================================================================
135
+ 493 |
136
+ 494 | function ensureDirectories(): void {
137
+ 495 | if (!fs.existsSync(GOLDEN_DIR)) {
138
+ 496 | fs.mkdirSync(GOLDEN_DIR, { recursive: true });
139
+ 497 | }
140
+ 498 | if (!fs.existsSync(FAILURE_DIR)) {
141
+ 499 | fs.mkdirSync(FAILURE_DIR, { recursive: true });
142
+ 500 | }
143
+ 501 | }
144
+ 502 |
145
+ 503 | async function setupDeterministicRenderer(page: Page): Promise<void> {
146
+ 504 | await page.goto('/test-harness.html', { waitUntil: 'networkidle' });
147
+ 505 |
148
+ 506 | await page.evaluate((config) => {
149
+ 507 | (window as any).__VS_CANVASKIT_CONFIG__ = config;
150
+ 508 | }, CANVASKIT_DETERMINISTIC_CONFIG);
151
+ 509 |
152
+ 510 | await page.waitForFunction(() => (window as any).__VS_RENDERER_READY__ === true, {
153
+ 511 | timeout: 10000,
154
+ 512 | });
155
+ 513 | }
156
+ 514 |
157
+ 515 | async function renderGradient(
158
+ 516 | page: Page,
159
+ 517 | spec: Record<string, unknown>,
160
+ 518 | ): Promise<Uint8Array> {
161
+ 519 | const base64 = await page.evaluate(async (gradientSpec) => {
162
+ 520 | const renderer = (window as any).__VS_RENDERER__;
163
+ 521 | await renderer.renderGradient(gradientSpec);
164
+ 522 |
165
+ 523 | const canvas = document.getElementById('vs-canvas') as HTMLCanvasElement;
166
+ 524 | const ctx = canvas.getContext('2d');
167
+ 525 | if (!ctx) throw new Error('No 2D context');
168
+ 526 |
169
+ 527 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
170
+ 528 | const buffer = imageData.data;
171
+ 529 |
172
+ 530 | let binary = '';
173
+ 531 | const chunkSize = 8192;
174
+ 532 | for (let i = 0; i < buffer.length; i += chunkSize) {
175
+ 533 | const chunk = buffer.subarray(i, Math.min(i + chunkSize, buffer.length));
176
+ 534 | binary += String.fromCharCode.apply(null, Array.from(chunk));
177
+ 535 | }
178
+ 536 | return btoa(binary);
179
+ 537 | }, spec);
180
+ 538 |
181
+ 539 | const binary = atob(base64);
182
+ 540 | const bytes = new Uint8Array(binary.length);
183
+ 541 | for (let i = 0; i < binary.length; i++) {
184
+ 542 | bytes[i] = binary.charCodeAt(i);
185
+ 543 | }
186
+ 544 | return bytes;
187
+ 545 | }
188
+ 546 |
189
+ 547 | async function renderAnimatedGradient(
190
+ 548 | page: Page,
191
+ 549 | spec: Record<string, unknown>,
192
+ 550 | ): Promise<Uint8Array> {
193
+ 551 | return renderGradient(page, { ...spec, animated: true });
194
+ 552 | }
195
+ 553 |
196
+ 554 | function computeHash(buffer: Uint8Array): string {
197
+ 555 | return crypto.createHash('sha256').update(buffer).digest('hex');
198
+ 556 | }
199
+ 557 |
200
+ 558 | function getPixelColor(
201
+ 559 | buffer: Uint8Array,
202
+ 560 | width: number,
203
+ 561 | height: number,
204
+ 562 | x: number,
205
+ 563 | y: number,
206
+ 564 | ): { r: number; g: number; b: number; a: number } {
207
+ 565 | const offset = (y * width + x) * 4;
208
+ 566 | return {
209
+ 567 | r: buffer[offset],
210
+ 568 | g: buffer[offset + 1],
211
+ 569 | b: buffer[offset + 2],
212
+ 570 | a: buffer[offset + 3],
213
+ 571 | };
214
+ 572 | }
215
+ 573 |
216
+ 574 | function analyzeHorizontalGradient(
217
+ 575 | buffer: Uint8Array,
218
+ 576 | width: number,
219
+ 577 | height: number,
220
+ 578 | ): { leftEdgeColor: { r: number; g: number; b: number }; rightEdgeColor: { r: number; g: number; b: number } } {
221
+ 579 | const midY = Math.floor(height / 2);
222
+ 580 | return {
223
+ 581 | leftEdgeColor: getPixelColor(buffer, width, height, 5, midY),
224
+ 582 | rightEdgeColor: getPixelColor(buffer, width, height, width - 5, midY),
225
+ 583 | };
226
+ ```