@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,340 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>ViewScript Visual Demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
body {
|
|
14
|
+
width: 800px;
|
|
15
|
+
height: 600px;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
background: #1a1a2e;
|
|
18
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
19
|
+
}
|
|
20
|
+
#vs-canvas {
|
|
21
|
+
position: absolute;
|
|
22
|
+
top: 0;
|
|
23
|
+
left: 0;
|
|
24
|
+
width: 800px;
|
|
25
|
+
height: 600px;
|
|
26
|
+
}
|
|
27
|
+
#vs-dom-layer {
|
|
28
|
+
position: absolute;
|
|
29
|
+
top: 0;
|
|
30
|
+
left: 0;
|
|
31
|
+
width: 800px;
|
|
32
|
+
height: 600px;
|
|
33
|
+
pointer-events: auto;
|
|
34
|
+
}
|
|
35
|
+
.vs-dom-element {
|
|
36
|
+
position: absolute;
|
|
37
|
+
pointer-events: auto;
|
|
38
|
+
will-change: transform;
|
|
39
|
+
}
|
|
40
|
+
#info-overlay {
|
|
41
|
+
position: absolute;
|
|
42
|
+
bottom: 10px;
|
|
43
|
+
left: 10px;
|
|
44
|
+
color: rgba(255, 255, 255, 0.7);
|
|
45
|
+
font-size: 12px;
|
|
46
|
+
font-family: monospace;
|
|
47
|
+
z-index: 1000;
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<canvas id="vs-canvas" width="800" height="600"></canvas>
|
|
53
|
+
<div id="vs-dom-layer"></div>
|
|
54
|
+
<div id="info-overlay"></div>
|
|
55
|
+
|
|
56
|
+
<script type="module">
|
|
57
|
+
/**
|
|
58
|
+
* ViewScript Visual Demo
|
|
59
|
+
*
|
|
60
|
+
* Demonstrates the complete rendering pipeline:
|
|
61
|
+
* 1. IR (Intermediate Representation) construction
|
|
62
|
+
* 2. Constraint-based P-dimension positioning
|
|
63
|
+
* 3. T-vector driven animations
|
|
64
|
+
* 4. Canvas + DOM bilayer rendering
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
const canvas = document.getElementById('vs-canvas');
|
|
68
|
+
const ctx = canvas.getContext('2d');
|
|
69
|
+
const domLayer = document.getElementById('vs-dom-layer');
|
|
70
|
+
const infoOverlay = document.getElementById('info-overlay');
|
|
71
|
+
|
|
72
|
+
// ==========================================================================
|
|
73
|
+
// IR Construction (Simulating vsc CLI output)
|
|
74
|
+
// ==========================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* This IR represents what `vsc build` would produce.
|
|
78
|
+
* Structure matches the vsc-core IR schema.
|
|
79
|
+
*/
|
|
80
|
+
const ir = {
|
|
81
|
+
schema_version: 1,
|
|
82
|
+
project: {
|
|
83
|
+
name: "visual-demo",
|
|
84
|
+
version: "0.1.0"
|
|
85
|
+
},
|
|
86
|
+
entities: [
|
|
87
|
+
// Title text
|
|
88
|
+
{
|
|
89
|
+
id: 1,
|
|
90
|
+
type: "text",
|
|
91
|
+
content: "ViewScript P-Dimension Demo",
|
|
92
|
+
bounds: { x: 50, y: 50, width: 400, height: 40 },
|
|
93
|
+
fill: "#ffffff",
|
|
94
|
+
font: { family: "sans-serif", size: 28, weight: "bold" }
|
|
95
|
+
},
|
|
96
|
+
// Main rectangle (constrained to center)
|
|
97
|
+
{
|
|
98
|
+
id: 2,
|
|
99
|
+
type: "rect",
|
|
100
|
+
bounds: { x: 300, y: 200, width: 200, height: 200 },
|
|
101
|
+
fill: "#6366f1",
|
|
102
|
+
interactive: true,
|
|
103
|
+
stroke: { color: "#818cf8", width: 2 }
|
|
104
|
+
},
|
|
105
|
+
// Satellite rectangles (constrained relative to main)
|
|
106
|
+
{
|
|
107
|
+
id: 3,
|
|
108
|
+
type: "rect",
|
|
109
|
+
bounds: { x: 150, y: 250, width: 80, height: 80 },
|
|
110
|
+
fill: "#22c55e",
|
|
111
|
+
interactive: true
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 4,
|
|
115
|
+
type: "rect",
|
|
116
|
+
bounds: { x: 570, y: 250, width: 80, height: 80 },
|
|
117
|
+
fill: "#f59e0b",
|
|
118
|
+
interactive: true
|
|
119
|
+
},
|
|
120
|
+
// Constraint visualization lines
|
|
121
|
+
{
|
|
122
|
+
id: 5,
|
|
123
|
+
type: "line",
|
|
124
|
+
from: { x: 230, y: 290 },
|
|
125
|
+
to: { x: 300, y: 300 },
|
|
126
|
+
stroke: { color: "rgba(255, 255, 255, 0.3)", width: 2, dash: [5, 5] }
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 6,
|
|
130
|
+
type: "line",
|
|
131
|
+
from: { x: 500, y: 300 },
|
|
132
|
+
to: { x: 570, y: 290 },
|
|
133
|
+
stroke: { color: "rgba(255, 255, 255, 0.3)", width: 2, dash: [5, 5] }
|
|
134
|
+
},
|
|
135
|
+
// Labels
|
|
136
|
+
{
|
|
137
|
+
id: 7,
|
|
138
|
+
type: "text",
|
|
139
|
+
content: "Entity #2",
|
|
140
|
+
bounds: { x: 350, y: 420, width: 100, height: 20 },
|
|
141
|
+
fill: "rgba(255, 255, 255, 0.6)",
|
|
142
|
+
font: { family: "monospace", size: 12 }
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 8,
|
|
146
|
+
type: "text",
|
|
147
|
+
content: "Entity #3",
|
|
148
|
+
bounds: { x: 150, y: 350, width: 80, height: 20 },
|
|
149
|
+
fill: "rgba(255, 255, 255, 0.6)",
|
|
150
|
+
font: { family: "monospace", size: 12 }
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 9,
|
|
154
|
+
type: "text",
|
|
155
|
+
content: "Entity #4",
|
|
156
|
+
bounds: { x: 570, y: 350, width: 80, height: 20 },
|
|
157
|
+
fill: "rgba(255, 255, 255, 0.6)",
|
|
158
|
+
font: { family: "monospace", size: 12 }
|
|
159
|
+
},
|
|
160
|
+
// Constraint info box
|
|
161
|
+
{
|
|
162
|
+
id: 10,
|
|
163
|
+
type: "rect",
|
|
164
|
+
bounds: { x: 50, y: 450, width: 700, height: 120 },
|
|
165
|
+
fill: "rgba(255, 255, 255, 0.05)",
|
|
166
|
+
stroke: { color: "rgba(255, 255, 255, 0.2)", width: 1 }
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 11,
|
|
170
|
+
type: "text",
|
|
171
|
+
content: "Active Constraints (P-Dimension):",
|
|
172
|
+
bounds: { x: 70, y: 475, width: 300, height: 20 },
|
|
173
|
+
fill: "#a5b4fc",
|
|
174
|
+
font: { family: "monospace", size: 14 }
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 12,
|
|
178
|
+
type: "text",
|
|
179
|
+
content: "E2.x = 300/1 | E3.x = E2.x - 150/1 | E4.x = E2.x + 270/1",
|
|
180
|
+
bounds: { x: 70, y: 500, width: 600, height: 16 },
|
|
181
|
+
fill: "rgba(255, 255, 255, 0.7)",
|
|
182
|
+
font: { family: "monospace", size: 13 }
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 13,
|
|
186
|
+
type: "text",
|
|
187
|
+
content: "T-Vector State: hover=0, animation_t=0.00",
|
|
188
|
+
bounds: { x: 70, y: 530, width: 400, height: 16 },
|
|
189
|
+
fill: "rgba(255, 255, 255, 0.5)",
|
|
190
|
+
font: { family: "monospace", size: 12 }
|
|
191
|
+
}
|
|
192
|
+
],
|
|
193
|
+
constraints: [
|
|
194
|
+
// E2 (main) positioned at center
|
|
195
|
+
{ id: 1, target: 2, component: "x", relation: "eq", term: { type: "const", value: "300/1" } },
|
|
196
|
+
{ id: 2, target: 2, component: "y", relation: "eq", term: { type: "const", value: "200/1" } },
|
|
197
|
+
// E3 positioned relative to E2
|
|
198
|
+
{ id: 3, target: 3, component: "x", relation: "eq", term: { type: "linear", coefficient: "1/1", entity_id: 2, component: "x", offset: "-150/1" } },
|
|
199
|
+
// E4 positioned relative to E2
|
|
200
|
+
{ id: 4, target: 4, component: "x", relation: "eq", term: { type: "linear", coefficient: "1/1", entity_id: 2, component: "x", offset: "270/1" } }
|
|
201
|
+
],
|
|
202
|
+
containments: []
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// ==========================================================================
|
|
206
|
+
// Rendering Pipeline
|
|
207
|
+
// ==========================================================================
|
|
208
|
+
|
|
209
|
+
function render() {
|
|
210
|
+
// Clear canvas
|
|
211
|
+
ctx.fillStyle = '#1a1a2e';
|
|
212
|
+
ctx.fillRect(0, 0, 800, 600);
|
|
213
|
+
|
|
214
|
+
// Render grid background
|
|
215
|
+
renderGrid();
|
|
216
|
+
|
|
217
|
+
// Render each entity
|
|
218
|
+
for (const entity of ir.entities) {
|
|
219
|
+
renderEntity(entity);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Update info overlay
|
|
223
|
+
updateInfo();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderGrid() {
|
|
227
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
|
|
228
|
+
ctx.lineWidth = 1;
|
|
229
|
+
|
|
230
|
+
// Vertical lines
|
|
231
|
+
for (let x = 0; x <= 800; x += 50) {
|
|
232
|
+
ctx.beginPath();
|
|
233
|
+
ctx.moveTo(x, 0);
|
|
234
|
+
ctx.lineTo(x, 600);
|
|
235
|
+
ctx.stroke();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Horizontal lines
|
|
239
|
+
for (let y = 0; y <= 600; y += 50) {
|
|
240
|
+
ctx.beginPath();
|
|
241
|
+
ctx.moveTo(0, y);
|
|
242
|
+
ctx.lineTo(800, y);
|
|
243
|
+
ctx.stroke();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderEntity(entity) {
|
|
248
|
+
const { type, bounds, fill, stroke, content, font } = entity;
|
|
249
|
+
|
|
250
|
+
switch (type) {
|
|
251
|
+
case 'rect':
|
|
252
|
+
// Fill
|
|
253
|
+
ctx.fillStyle = fill || '#ffffff';
|
|
254
|
+
ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
255
|
+
|
|
256
|
+
// Stroke
|
|
257
|
+
if (stroke) {
|
|
258
|
+
ctx.strokeStyle = stroke.color;
|
|
259
|
+
ctx.lineWidth = stroke.width;
|
|
260
|
+
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
261
|
+
}
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
case 'text':
|
|
265
|
+
ctx.fillStyle = fill || '#ffffff';
|
|
266
|
+
const fontSize = font?.size || 16;
|
|
267
|
+
const fontFamily = font?.family || 'sans-serif';
|
|
268
|
+
const fontWeight = font?.weight || 'normal';
|
|
269
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
270
|
+
ctx.fillText(content, bounds.x, bounds.y + fontSize);
|
|
271
|
+
break;
|
|
272
|
+
|
|
273
|
+
case 'line':
|
|
274
|
+
ctx.strokeStyle = entity.stroke?.color || '#ffffff';
|
|
275
|
+
ctx.lineWidth = entity.stroke?.width || 1;
|
|
276
|
+
if (entity.stroke?.dash) {
|
|
277
|
+
ctx.setLineDash(entity.stroke.dash);
|
|
278
|
+
}
|
|
279
|
+
ctx.beginPath();
|
|
280
|
+
ctx.moveTo(entity.from.x, entity.from.y);
|
|
281
|
+
ctx.lineTo(entity.to.x, entity.to.y);
|
|
282
|
+
ctx.stroke();
|
|
283
|
+
ctx.setLineDash([]);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function updateInfo() {
|
|
289
|
+
const now = new Date().toISOString();
|
|
290
|
+
infoOverlay.textContent = `ViewScript Renderer | Entities: ${ir.entities.length} | Constraints: ${ir.constraints.length} | ${now}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ==========================================================================
|
|
294
|
+
// Animation Loop (T-Vector driven)
|
|
295
|
+
// ==========================================================================
|
|
296
|
+
|
|
297
|
+
let animationT = 0;
|
|
298
|
+
let hoverState = 0;
|
|
299
|
+
|
|
300
|
+
function animate(timestamp) {
|
|
301
|
+
animationT = (timestamp / 1000) % 10; // 10-second cycle
|
|
302
|
+
|
|
303
|
+
// Update T-vector display
|
|
304
|
+
const tVectorText = ir.entities.find(e => e.id === 13);
|
|
305
|
+
if (tVectorText) {
|
|
306
|
+
tVectorText.content = `T-Vector State: hover=${hoverState}, animation_t=${animationT.toFixed(2)}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Animate main rectangle color based on T
|
|
310
|
+
const mainRect = ir.entities.find(e => e.id === 2);
|
|
311
|
+
if (mainRect) {
|
|
312
|
+
const hue = (animationT / 10) * 360;
|
|
313
|
+
mainRect.fill = `hsl(${hue}, 70%, 60%)`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Render
|
|
317
|
+
render();
|
|
318
|
+
|
|
319
|
+
requestAnimationFrame(animate);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ==========================================================================
|
|
323
|
+
// Initialize
|
|
324
|
+
// ==========================================================================
|
|
325
|
+
|
|
326
|
+
// Initial render
|
|
327
|
+
render();
|
|
328
|
+
|
|
329
|
+
// Start animation
|
|
330
|
+
requestAnimationFrame(animate);
|
|
331
|
+
|
|
332
|
+
// Signal ready for screenshot
|
|
333
|
+
window.__VS_DEMO_READY__ = true;
|
|
334
|
+
|
|
335
|
+
console.log('[ViewScript] Visual Demo initialized');
|
|
336
|
+
console.log('[ViewScript] IR entities:', ir.entities.length);
|
|
337
|
+
console.log('[ViewScript] Constraints:', ir.constraints.length);
|
|
338
|
+
</script>
|
|
339
|
+
</body>
|
|
340
|
+
</html>
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic Visual Regression Tests
|
|
3
|
+
*
|
|
4
|
+
* This module ensures bit-perfect reproducibility of Canvas rendering
|
|
5
|
+
* by forcing the GPU renderer into CPU-only software rendering mode.
|
|
6
|
+
*
|
|
7
|
+
* ## The Problem: GPU Non-Determinism
|
|
8
|
+
*
|
|
9
|
+
* Different GPUs (NVIDIA vs AMD vs Intel) produce subtly different
|
|
10
|
+
* anti-aliasing patterns. Even the same GPU can produce different
|
|
11
|
+
* results across driver versions. This is unacceptable for a
|
|
12
|
+
* mathematically rigorous GUI framework.
|
|
13
|
+
*
|
|
14
|
+
* ## Solution: Software Rendering + Hash Comparison
|
|
15
|
+
*
|
|
16
|
+
* 1. Force wgpu renderer to use CPU rasterizer (no GPU)
|
|
17
|
+
* 2. Disable subpixel text positioning
|
|
18
|
+
* 3. Use fixed-width fonts for text tests
|
|
19
|
+
* 4. Compare output against golden snapshots via SHA-256 hash
|
|
20
|
+
*
|
|
21
|
+
* ## Test Artifact Storage
|
|
22
|
+
*
|
|
23
|
+
* Golden snapshots are stored in:
|
|
24
|
+
* tests/e2e/golden/<test-name>-<platform>.png
|
|
25
|
+
*
|
|
26
|
+
* Failed diffs are output to:
|
|
27
|
+
* tests/e2e/failures/<test-name>-diff.png
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { test, expect, type Page } from '@playwright/test';
|
|
31
|
+
import * as crypto from 'crypto';
|
|
32
|
+
import * as fs from 'fs';
|
|
33
|
+
import * as path from 'path';
|
|
34
|
+
import { fileURLToPath } from 'url';
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Test Configuration
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
41
|
+
const __dirname = path.dirname(__filename);
|
|
42
|
+
|
|
43
|
+
const GOLDEN_DIR = path.join(__dirname, 'golden');
|
|
44
|
+
const FAILURE_DIR = path.join(__dirname, 'failures');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* GPU renderer initialization options for deterministic rendering.
|
|
48
|
+
*/
|
|
49
|
+
const WGPU_DETERMINISTIC_CONFIG = {
|
|
50
|
+
// Force CPU-only rendering (no WebGL)
|
|
51
|
+
disableWebGL: true,
|
|
52
|
+
|
|
53
|
+
// Use software rasterizer
|
|
54
|
+
preferLowPowerToHighPerformance: false,
|
|
55
|
+
|
|
56
|
+
// Disable subpixel antialiasing (font-dependent)
|
|
57
|
+
useSubpixelText: false,
|
|
58
|
+
|
|
59
|
+
// Fixed DPI for consistent sizing
|
|
60
|
+
devicePixelRatio: 1.0,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// Test Fixtures
|
|
65
|
+
// =============================================================================
|
|
66
|
+
|
|
67
|
+
test.describe('Visual Regression: Bit-Perfect Rendering', () => {
|
|
68
|
+
test.beforeAll(async () => {
|
|
69
|
+
// Ensure directories exist
|
|
70
|
+
if (!fs.existsSync(GOLDEN_DIR)) {
|
|
71
|
+
fs.mkdirSync(GOLDEN_DIR, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
if (!fs.existsSync(FAILURE_DIR)) {
|
|
74
|
+
fs.mkdirSync(FAILURE_DIR, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Test: Simple Rectangle Rendering
|
|
80
|
+
*
|
|
81
|
+
* Constraint Graph (IR):
|
|
82
|
+
* Entity #1: rect at (10, 10) size (100, 50), fill: #FF0000
|
|
83
|
+
*
|
|
84
|
+
* Expected: Red rectangle, bit-perfect match with golden snapshot.
|
|
85
|
+
*/
|
|
86
|
+
test('renders simple rectangle with bit-perfect hash match', async ({ page }) => {
|
|
87
|
+
// Setup: Load VS renderer in deterministic mode
|
|
88
|
+
await setupDeterministicRenderer(page);
|
|
89
|
+
|
|
90
|
+
// Act: Render a simple constraint graph
|
|
91
|
+
const pixelBuffer = await renderConstraintGraph(page, {
|
|
92
|
+
entities: [
|
|
93
|
+
{
|
|
94
|
+
id: 1,
|
|
95
|
+
type: 'rect',
|
|
96
|
+
bounds: { x: 10, y: 10, width: 100, height: 50 },
|
|
97
|
+
fill: '#FF0000',
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
constraints: [],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Assert: Hash match
|
|
104
|
+
const hash = computeHash(pixelBuffer);
|
|
105
|
+
const goldenHash = loadGoldenHash('simple-rectangle');
|
|
106
|
+
|
|
107
|
+
if (goldenHash === null) {
|
|
108
|
+
// First run: save as golden
|
|
109
|
+
saveGolden('simple-rectangle', pixelBuffer, hash);
|
|
110
|
+
console.log(`[GOLDEN] Saved new golden snapshot: simple-rectangle (${hash})`);
|
|
111
|
+
} else {
|
|
112
|
+
expect(hash).toBe(goldenHash);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Test: Adjacent Rectangles (Topology Preservation)
|
|
118
|
+
*
|
|
119
|
+
* This tests the core topology-preserving rounding:
|
|
120
|
+
* Three adjacent rects of 33.333...px width in 100px container.
|
|
121
|
+
*
|
|
122
|
+
* Expected: No gaps, no overlaps, bit-perfect.
|
|
123
|
+
*/
|
|
124
|
+
test('renders adjacent rectangles without subpixel gaps', async ({ page }) => {
|
|
125
|
+
await setupDeterministicRenderer(page);
|
|
126
|
+
|
|
127
|
+
const pixelBuffer = await renderConstraintGraph(page, {
|
|
128
|
+
entities: [
|
|
129
|
+
// Container
|
|
130
|
+
{ id: 0, type: 'rect', bounds: { x: 0, y: 0, width: 100, height: 30 }, fill: '#000000' },
|
|
131
|
+
// Three children with irrational widths
|
|
132
|
+
{ id: 1, type: 'rect', bounds: { x: 0, y: 0, width: 100/3, height: 30 }, fill: '#FF0000' },
|
|
133
|
+
{ id: 2, type: 'rect', bounds: { x: 100/3, y: 0, width: 100/3, height: 30 }, fill: '#00FF00' },
|
|
134
|
+
{ id: 3, type: 'rect', bounds: { x: 200/3, y: 0, width: 100/3, height: 30 }, fill: '#0000FF' },
|
|
135
|
+
],
|
|
136
|
+
constraints: [
|
|
137
|
+
{ type: 'adjacent', a: { entityId: 1, edge: 'right' }, b: { entityId: 2, edge: 'left' } },
|
|
138
|
+
{ type: 'adjacent', a: { entityId: 2, edge: 'right' }, b: { entityId: 3, edge: 'left' } },
|
|
139
|
+
],
|
|
140
|
+
containments: [
|
|
141
|
+
{ parentId: 0, childIds: [1, 2, 3], axis: 'horizontal' },
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Verify no black pixels (gaps) between colored rectangles
|
|
146
|
+
const hasGaps = detectGaps(pixelBuffer, 100, 30);
|
|
147
|
+
expect(hasGaps).toBe(false);
|
|
148
|
+
|
|
149
|
+
// Hash comparison
|
|
150
|
+
const hash = computeHash(pixelBuffer);
|
|
151
|
+
const goldenHash = loadGoldenHash('adjacent-rectangles');
|
|
152
|
+
|
|
153
|
+
if (goldenHash === null) {
|
|
154
|
+
saveGolden('adjacent-rectangles', pixelBuffer, hash);
|
|
155
|
+
} else {
|
|
156
|
+
expect(hash).toBe(goldenHash);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Test: Text Rendering (Font Determinism)
|
|
162
|
+
*
|
|
163
|
+
* CRITICAL: Uses embedded WOFF2 font to guarantee cross-platform consistency.
|
|
164
|
+
* Generic font families (monospace, serif) resolve to different fonts per OS.
|
|
165
|
+
*/
|
|
166
|
+
test('renders text with deterministic glyph placement', async ({ page }) => {
|
|
167
|
+
await setupDeterministicRenderer(page);
|
|
168
|
+
|
|
169
|
+
// Load embedded font before rendering
|
|
170
|
+
await page.evaluate(async () => {
|
|
171
|
+
// Wait for VS embedded font to load
|
|
172
|
+
await (window as any).__VS_RENDERER__.loadFont('vs-mono');
|
|
173
|
+
await document.fonts.ready;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const pixelBuffer = await renderConstraintGraph(page, {
|
|
177
|
+
entities: [
|
|
178
|
+
{
|
|
179
|
+
id: 1,
|
|
180
|
+
type: 'text',
|
|
181
|
+
bounds: { x: 10, y: 10, width: 200, height: 20 },
|
|
182
|
+
content: 'ViewScript',
|
|
183
|
+
font: {
|
|
184
|
+
family: 'vs-mono', // Embedded font for determinism
|
|
185
|
+
size: 16,
|
|
186
|
+
weight: 400,
|
|
187
|
+
},
|
|
188
|
+
fill: '#000000',
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
constraints: [],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const hash = computeHash(pixelBuffer);
|
|
195
|
+
const goldenHash = loadGoldenHash(`text-deterministic-${process.platform}`);
|
|
196
|
+
|
|
197
|
+
if (goldenHash === null) {
|
|
198
|
+
saveGolden(`text-deterministic-${process.platform}`, pixelBuffer, hash);
|
|
199
|
+
} else {
|
|
200
|
+
expect(hash).toBe(goldenHash);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// =============================================================================
|
|
206
|
+
// Helper Functions
|
|
207
|
+
// =============================================================================
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Setup Playwright page with deterministic wgpu renderer configuration.
|
|
211
|
+
*/
|
|
212
|
+
async function setupDeterministicRenderer(page: Page): Promise<void> {
|
|
213
|
+
// Navigate to test harness (follow redirect if needed)
|
|
214
|
+
await page.goto('/test-harness.html', { waitUntil: 'networkidle' });
|
|
215
|
+
|
|
216
|
+
// Inject deterministic configuration
|
|
217
|
+
await page.evaluate((config) => {
|
|
218
|
+
(window as any).__VS_RENDERER_CONFIG__ = config;
|
|
219
|
+
}, WGPU_DETERMINISTIC_CONFIG);
|
|
220
|
+
|
|
221
|
+
// Wait for renderer initialization
|
|
222
|
+
await page.waitForFunction(() => (window as any).__VS_RENDERER_READY__ === true, {
|
|
223
|
+
timeout: 10000,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Render a constraint graph and return the pixel buffer.
|
|
229
|
+
*/
|
|
230
|
+
async function renderConstraintGraph(
|
|
231
|
+
page: Page,
|
|
232
|
+
ir: unknown,
|
|
233
|
+
): Promise<Uint8Array> {
|
|
234
|
+
const base64 = await page.evaluate(async (constraintGraph) => {
|
|
235
|
+
const renderer = (window as any).__VS_RENDERER__;
|
|
236
|
+
|
|
237
|
+
// Render the constraint graph
|
|
238
|
+
await renderer.render(constraintGraph);
|
|
239
|
+
|
|
240
|
+
// Force flush and extract pixel buffer
|
|
241
|
+
const canvas = document.getElementById('vs-canvas') as HTMLCanvasElement;
|
|
242
|
+
const ctx = canvas.getContext('2d');
|
|
243
|
+
|
|
244
|
+
if (!ctx) throw new Error('No 2D context');
|
|
245
|
+
|
|
246
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
247
|
+
const buffer = imageData.data;
|
|
248
|
+
|
|
249
|
+
// Convert to base64 in chunks to avoid stack overflow
|
|
250
|
+
let binary = '';
|
|
251
|
+
const chunkSize = 8192;
|
|
252
|
+
for (let i = 0; i < buffer.length; i += chunkSize) {
|
|
253
|
+
const chunk = buffer.subarray(i, Math.min(i + chunkSize, buffer.length));
|
|
254
|
+
binary += String.fromCharCode.apply(null, Array.from(chunk));
|
|
255
|
+
}
|
|
256
|
+
return btoa(binary);
|
|
257
|
+
}, ir);
|
|
258
|
+
|
|
259
|
+
// Decode base64 to Uint8Array
|
|
260
|
+
const binary = atob(base64);
|
|
261
|
+
const bytes = new Uint8Array(binary.length);
|
|
262
|
+
for (let i = 0; i < binary.length; i++) {
|
|
263
|
+
bytes[i] = binary.charCodeAt(i);
|
|
264
|
+
}
|
|
265
|
+
return bytes;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Compute SHA-256 hash of pixel buffer.
|
|
270
|
+
*/
|
|
271
|
+
function computeHash(buffer: Uint8Array): string {
|
|
272
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Load golden hash from file.
|
|
277
|
+
*/
|
|
278
|
+
function loadGoldenHash(testName: string): string | null {
|
|
279
|
+
const hashFile = path.join(GOLDEN_DIR, `${testName}.sha256`);
|
|
280
|
+
if (fs.existsSync(hashFile)) {
|
|
281
|
+
return fs.readFileSync(hashFile, 'utf-8').trim();
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Save golden snapshot and hash.
|
|
288
|
+
*/
|
|
289
|
+
function saveGolden(testName: string, buffer: Uint8Array, hash: string): void {
|
|
290
|
+
const pngFile = path.join(GOLDEN_DIR, `${testName}.raw`);
|
|
291
|
+
const hashFile = path.join(GOLDEN_DIR, `${testName}.sha256`);
|
|
292
|
+
|
|
293
|
+
fs.writeFileSync(pngFile, buffer);
|
|
294
|
+
fs.writeFileSync(hashFile, hash);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Detect gaps between adjacent colored rectangles.
|
|
299
|
+
*
|
|
300
|
+
* Strategy: Scan horizontal lines and detect transitions.
|
|
301
|
+
* A gap exists if we see: [Color A] -> [Black] -> [Color B]
|
|
302
|
+
* The container background is expected to be black, so we only flag
|
|
303
|
+
* black pixels that appear BETWEEN colored regions.
|
|
304
|
+
*/
|
|
305
|
+
function detectGaps(buffer: Uint8Array, width: number, height: number): boolean {
|
|
306
|
+
// In RGBA format, check each row for gap patterns
|
|
307
|
+
for (let y = 0; y < height; y++) {
|
|
308
|
+
let inColoredRegion = false;
|
|
309
|
+
let sawBlackAfterColor = false;
|
|
310
|
+
|
|
311
|
+
for (let x = 0; x < width; x++) {
|
|
312
|
+
const offset = (y * width + x) * 4;
|
|
313
|
+
const r = buffer[offset];
|
|
314
|
+
const g = buffer[offset + 1];
|
|
315
|
+
const b = buffer[offset + 2];
|
|
316
|
+
const a = buffer[offset + 3];
|
|
317
|
+
|
|
318
|
+
const isBlack = r === 0 && g === 0 && b === 0 && a === 255;
|
|
319
|
+
const isColored = !isBlack && a === 255;
|
|
320
|
+
|
|
321
|
+
if (isColored) {
|
|
322
|
+
if (sawBlackAfterColor) {
|
|
323
|
+
// Found: [Color] -> [Black] -> [Color] = gap!
|
|
324
|
+
console.warn(`[GAP DETECTED] black pixel between colors at row ${y}, near x=${x}`);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
inColoredRegion = true;
|
|
328
|
+
sawBlackAfterColor = false;
|
|
329
|
+
} else if (isBlack && inColoredRegion) {
|
|
330
|
+
sawBlackAfterColor = true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
package/tsconfig.json
ADDED