@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,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topology-Preserving Rounding Algorithm
|
|
3
|
+
*
|
|
4
|
+
* This module implements the rasterization layer that projects P-dimension
|
|
5
|
+
* rational coordinates to discrete pixel coordinates while preserving
|
|
6
|
+
* topological relationships (adjacency, containment, ordering).
|
|
7
|
+
*
|
|
8
|
+
* ## The Problem
|
|
9
|
+
*
|
|
10
|
+
* Given two adjacent surfaces A and B where:
|
|
11
|
+
* A.right = 100.333... (rational)
|
|
12
|
+
* B.left = 100.333... (same rational)
|
|
13
|
+
*
|
|
14
|
+
* Naive rounding may produce:
|
|
15
|
+
* A.right = 100px (floor)
|
|
16
|
+
* B.left = 101px (ceil)
|
|
17
|
+
*
|
|
18
|
+
* This creates a 1px gap that violates the topological constraint
|
|
19
|
+
* that A and B are adjacent (no gap, no overlap).
|
|
20
|
+
*
|
|
21
|
+
* ## Solution: Constraint-Aware Rounding
|
|
22
|
+
*
|
|
23
|
+
* Instead of rounding each coordinate independently, we:
|
|
24
|
+
* 1. Build a graph of topological relationships (adjacency, containment)
|
|
25
|
+
* 2. Partition coordinates into equivalence classes (same rational = same pixel)
|
|
26
|
+
* 3. Round equivalence classes together
|
|
27
|
+
* 4. Propagate rounding decisions through the constraint graph
|
|
28
|
+
*
|
|
29
|
+
* ## Algorithm
|
|
30
|
+
*
|
|
31
|
+
* ```
|
|
32
|
+
* INPUT:
|
|
33
|
+
* - Set of surfaces S with rational bounds
|
|
34
|
+
* - Topological constraints T (adjacency, containment)
|
|
35
|
+
* - Device pixel ratio DPR
|
|
36
|
+
*
|
|
37
|
+
* OUTPUT:
|
|
38
|
+
* - Integer pixel coordinates for all surfaces
|
|
39
|
+
* - Guarantee: topology is preserved
|
|
40
|
+
*
|
|
41
|
+
* ALGORITHM:
|
|
42
|
+
*
|
|
43
|
+
* Phase 1: Build Coordinate Equivalence Classes
|
|
44
|
+
* For each unique rational value r:
|
|
45
|
+
* equiv[r] = { all coordinates that equal r }
|
|
46
|
+
*
|
|
47
|
+
* Phase 2: Compute Rounding Constraints
|
|
48
|
+
* For each adjacency constraint (A.right = B.left):
|
|
49
|
+
* round(A.right) MUST equal round(B.left)
|
|
50
|
+
* For each ordering constraint (A.right < B.left):
|
|
51
|
+
* round(A.right) MUST be < round(B.left)
|
|
52
|
+
*
|
|
53
|
+
* Phase 3: Propagate Rounding Decisions
|
|
54
|
+
* Using constraint propagation:
|
|
55
|
+
* - Start with coordinates that have no constraints (free variables)
|
|
56
|
+
* - Round them to nearest integer
|
|
57
|
+
* - Propagate to constrained coordinates
|
|
58
|
+
* - Resolve conflicts by adjusting adjacent surfaces symmetrically
|
|
59
|
+
*
|
|
60
|
+
* Phase 4: Verify Topology Preservation
|
|
61
|
+
* Assert all topological constraints are satisfied
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// Core Algorithm
|
|
66
|
+
// =============================================================================
|
|
67
|
+
/**
|
|
68
|
+
* Topology-preserving rounding entry point.
|
|
69
|
+
*/
|
|
70
|
+
export function roundWithTopologyPreservation(entities, constraints, devicePixelRatio) {
|
|
71
|
+
const stats = {
|
|
72
|
+
totalCoordinates: 0,
|
|
73
|
+
equivalenceClasses: 0,
|
|
74
|
+
constraintsPropagated: 0,
|
|
75
|
+
conflictsResolved: 0,
|
|
76
|
+
};
|
|
77
|
+
// Phase 1: Extract all coordinates and build equivalence classes
|
|
78
|
+
const coords = extractCoordinates(entities);
|
|
79
|
+
stats.totalCoordinates = coords.length;
|
|
80
|
+
const equivClasses = buildEquivalenceClasses(coords, constraints);
|
|
81
|
+
stats.equivalenceClasses = equivClasses.size;
|
|
82
|
+
// Phase 2: Compute rounding for each equivalence class
|
|
83
|
+
const roundedClasses = new Map();
|
|
84
|
+
for (const [classId, members] of equivClasses) {
|
|
85
|
+
// All members have the same rational value
|
|
86
|
+
const rationalValue = members[0].value;
|
|
87
|
+
const floatValue = rationalToFloat(rationalValue) * devicePixelRatio;
|
|
88
|
+
// Default: round to nearest
|
|
89
|
+
roundedClasses.set(classId, Math.round(floatValue));
|
|
90
|
+
}
|
|
91
|
+
// Phase 3: Propagate constraints and resolve conflicts
|
|
92
|
+
const { adjusted, conflictsResolved } = propagateConstraints(roundedClasses, equivClasses, constraints);
|
|
93
|
+
stats.constraintsPropagated = constraints.length;
|
|
94
|
+
stats.conflictsResolved = conflictsResolved;
|
|
95
|
+
// Phase 4: Build final bounds
|
|
96
|
+
const bounds = buildFinalBounds(entities, adjusted, equivClasses, devicePixelRatio);
|
|
97
|
+
// Phase 5: Verify topology
|
|
98
|
+
const violations = verifyTopology(bounds, constraints);
|
|
99
|
+
return { bounds, violations, stats };
|
|
100
|
+
}
|
|
101
|
+
// =============================================================================
|
|
102
|
+
// Phase 1: Coordinate Extraction and Equivalence Classes
|
|
103
|
+
// =============================================================================
|
|
104
|
+
function extractCoordinates(entities) {
|
|
105
|
+
const coords = [];
|
|
106
|
+
for (const [entityId, bounds] of entities) {
|
|
107
|
+
coords.push({ entityId, edge: 'left', value: bounds.topLeft.x }, { entityId, edge: 'right', value: bounds.bottomRight.x }, { entityId, edge: 'top', value: bounds.topLeft.y }, { entityId, edge: 'bottom', value: bounds.bottomRight.y });
|
|
108
|
+
}
|
|
109
|
+
return coords;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build equivalence classes from coordinates and equality constraints.
|
|
113
|
+
*
|
|
114
|
+
* Two coordinates are in the same class if:
|
|
115
|
+
* 1. They have the same rational value, OR
|
|
116
|
+
* 2. They are connected by an 'equal' or 'adjacent' constraint
|
|
117
|
+
*/
|
|
118
|
+
function buildEquivalenceClasses(coords, constraints) {
|
|
119
|
+
// Union-Find data structure
|
|
120
|
+
const parent = new Map();
|
|
121
|
+
const coordKey = (c) => `${c.entityId}:${c.edge}`;
|
|
122
|
+
const find = (key) => {
|
|
123
|
+
if (!parent.has(key)) {
|
|
124
|
+
parent.set(key, key);
|
|
125
|
+
return key;
|
|
126
|
+
}
|
|
127
|
+
if (parent.get(key) !== key) {
|
|
128
|
+
parent.set(key, find(parent.get(key)));
|
|
129
|
+
}
|
|
130
|
+
return parent.get(key);
|
|
131
|
+
};
|
|
132
|
+
const union = (a, b) => {
|
|
133
|
+
const rootA = find(a);
|
|
134
|
+
const rootB = find(b);
|
|
135
|
+
if (rootA !== rootB) {
|
|
136
|
+
parent.set(rootA, rootB);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
// Initialize each coord as its own class
|
|
140
|
+
for (const coord of coords) {
|
|
141
|
+
const key = `${coord.entityId}:${coord.edge}`;
|
|
142
|
+
parent.set(key, key);
|
|
143
|
+
}
|
|
144
|
+
// Union coordinates with same rational value
|
|
145
|
+
const byValue = new Map();
|
|
146
|
+
for (const coord of coords) {
|
|
147
|
+
const valKey = rationalKey(coord.value);
|
|
148
|
+
if (!byValue.has(valKey)) {
|
|
149
|
+
byValue.set(valKey, []);
|
|
150
|
+
}
|
|
151
|
+
byValue.get(valKey).push(coord);
|
|
152
|
+
}
|
|
153
|
+
for (const [, group] of byValue) {
|
|
154
|
+
if (group.length > 1) {
|
|
155
|
+
const first = coordKey({ entityId: group[0].entityId, edge: group[0].edge });
|
|
156
|
+
for (let i = 1; i < group.length; i++) {
|
|
157
|
+
union(first, coordKey({ entityId: group[i].entityId, edge: group[i].edge }));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Union by equality/adjacency constraints
|
|
162
|
+
for (const c of constraints) {
|
|
163
|
+
if (c.type === 'equal' || c.type === 'adjacent') {
|
|
164
|
+
union(coordKey(c.a), coordKey(c.b));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Build final classes
|
|
168
|
+
const classes = new Map();
|
|
169
|
+
for (const coord of coords) {
|
|
170
|
+
const key = `${coord.entityId}:${coord.edge}`;
|
|
171
|
+
const root = find(key);
|
|
172
|
+
if (!classes.has(root)) {
|
|
173
|
+
classes.set(root, []);
|
|
174
|
+
}
|
|
175
|
+
classes.get(root).push(coord);
|
|
176
|
+
}
|
|
177
|
+
return classes;
|
|
178
|
+
}
|
|
179
|
+
function rationalKey(r) {
|
|
180
|
+
// Normalize to lowest terms for consistent keying
|
|
181
|
+
const gcd = bigIntGcd(r.numerator < 0n ? -r.numerator : r.numerator, r.denominator);
|
|
182
|
+
const num = r.numerator / gcd;
|
|
183
|
+
const den = r.denominator / gcd;
|
|
184
|
+
return `${num}/${den}`;
|
|
185
|
+
}
|
|
186
|
+
function bigIntGcd(a, b) {
|
|
187
|
+
while (b !== 0n) {
|
|
188
|
+
const t = b;
|
|
189
|
+
b = a % b;
|
|
190
|
+
a = t;
|
|
191
|
+
}
|
|
192
|
+
return a;
|
|
193
|
+
}
|
|
194
|
+
function rationalToFloat(r) {
|
|
195
|
+
return Number(r.numerator) / Number(r.denominator);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Propagate rounding decisions through less-than constraints.
|
|
199
|
+
*
|
|
200
|
+
* If A < B in rational space, we must ensure round(A) < round(B) in pixel space.
|
|
201
|
+
* If rounding would violate this, we adjust by:
|
|
202
|
+
* 1. Decreasing A by 1, OR
|
|
203
|
+
* 2. Increasing B by 1
|
|
204
|
+
*
|
|
205
|
+
* We choose the option that minimizes total visual shift.
|
|
206
|
+
*/
|
|
207
|
+
function propagateConstraints(initial, equivClasses, constraints) {
|
|
208
|
+
const adjusted = new Map(initial);
|
|
209
|
+
let conflictsResolved = 0;
|
|
210
|
+
// Build class lookup
|
|
211
|
+
const coordToClass = new Map();
|
|
212
|
+
for (const [classId, members] of equivClasses) {
|
|
213
|
+
for (const m of members) {
|
|
214
|
+
coordToClass.set(`${m.entityId}:${m.edge}`, classId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Process less-than constraints
|
|
218
|
+
for (const c of constraints) {
|
|
219
|
+
if (c.type !== 'less-than')
|
|
220
|
+
continue;
|
|
221
|
+
const classA = coordToClass.get(`${c.a.entityId}:${c.a.edge}`);
|
|
222
|
+
const classB = coordToClass.get(`${c.b.entityId}:${c.b.edge}`);
|
|
223
|
+
if (!classA || !classB)
|
|
224
|
+
continue;
|
|
225
|
+
const valA = adjusted.get(classA) ?? 0;
|
|
226
|
+
const valB = adjusted.get(classB) ?? 0;
|
|
227
|
+
// Must satisfy: valA < valB
|
|
228
|
+
if (valA >= valB) {
|
|
229
|
+
// Conflict! Need to adjust.
|
|
230
|
+
// Strategy: Create a gap of 1px
|
|
231
|
+
// Option 1: Decrease A
|
|
232
|
+
const costDecreaseA = computeAdjustmentCost(classA, valA, valA - 1, equivClasses);
|
|
233
|
+
// Option 2: Increase B
|
|
234
|
+
const costIncreaseB = computeAdjustmentCost(classB, valB, valB + 1, equivClasses);
|
|
235
|
+
if (costDecreaseA <= costIncreaseB) {
|
|
236
|
+
adjusted.set(classA, valB - 1);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
adjusted.set(classB, valA + 1);
|
|
240
|
+
}
|
|
241
|
+
conflictsResolved++;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { adjusted, conflictsResolved };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Compute the visual cost of adjusting a coordinate.
|
|
248
|
+
*
|
|
249
|
+
* Cost is proportional to:
|
|
250
|
+
* - Number of entities affected
|
|
251
|
+
* - Distance of adjustment
|
|
252
|
+
*/
|
|
253
|
+
function computeAdjustmentCost(classId, from, to, equivClasses) {
|
|
254
|
+
const members = equivClasses.get(classId) ?? [];
|
|
255
|
+
const distance = Math.abs(to - from);
|
|
256
|
+
return members.length * distance;
|
|
257
|
+
}
|
|
258
|
+
// =============================================================================
|
|
259
|
+
// Phase 4: Build Final Bounds
|
|
260
|
+
// =============================================================================
|
|
261
|
+
function buildFinalBounds(entities, roundedClasses, equivClasses, devicePixelRatio) {
|
|
262
|
+
// Build coord to class lookup
|
|
263
|
+
const coordToClass = new Map();
|
|
264
|
+
for (const [classId, members] of equivClasses) {
|
|
265
|
+
for (const m of members) {
|
|
266
|
+
coordToClass.set(`${m.entityId}:${m.edge}`, classId);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const bounds = new Map();
|
|
270
|
+
for (const [entityId] of entities) {
|
|
271
|
+
const leftClass = coordToClass.get(`${entityId}:left`);
|
|
272
|
+
const rightClass = coordToClass.get(`${entityId}:right`);
|
|
273
|
+
const topClass = coordToClass.get(`${entityId}:top`);
|
|
274
|
+
const bottomClass = coordToClass.get(`${entityId}:bottom`);
|
|
275
|
+
const left = roundedClasses.get(leftClass) ?? 0;
|
|
276
|
+
const right = roundedClasses.get(rightClass) ?? 0;
|
|
277
|
+
const top = roundedClasses.get(topClass) ?? 0;
|
|
278
|
+
const bottom = roundedClasses.get(bottomClass) ?? 0;
|
|
279
|
+
// Convert from device pixels to CSS pixels
|
|
280
|
+
bounds.set(entityId, {
|
|
281
|
+
x: left / devicePixelRatio,
|
|
282
|
+
y: top / devicePixelRatio,
|
|
283
|
+
width: (right - left) / devicePixelRatio,
|
|
284
|
+
height: (bottom - top) / devicePixelRatio,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return bounds;
|
|
288
|
+
}
|
|
289
|
+
// =============================================================================
|
|
290
|
+
// Phase 5: Topology Verification
|
|
291
|
+
// =============================================================================
|
|
292
|
+
function verifyTopology(bounds, constraints) {
|
|
293
|
+
const violations = [];
|
|
294
|
+
for (const c of constraints) {
|
|
295
|
+
const boundsA = bounds.get(c.a.entityId);
|
|
296
|
+
const boundsB = bounds.get(c.b.entityId);
|
|
297
|
+
if (!boundsA || !boundsB)
|
|
298
|
+
continue;
|
|
299
|
+
const valA = getEdgeValue(boundsA, c.a.edge);
|
|
300
|
+
const valB = getEdgeValue(boundsB, c.b.edge);
|
|
301
|
+
switch (c.type) {
|
|
302
|
+
case 'equal':
|
|
303
|
+
if (valA !== valB) {
|
|
304
|
+
violations.push({
|
|
305
|
+
constraint: c,
|
|
306
|
+
message: `Equal constraint violated: ${valA} !== ${valB}`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
break;
|
|
310
|
+
case 'adjacent':
|
|
311
|
+
if (valA !== valB) {
|
|
312
|
+
violations.push({
|
|
313
|
+
constraint: c,
|
|
314
|
+
message: `Adjacent constraint violated: ${valA} !== ${valB} (gap or overlap)`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
case 'less-than':
|
|
319
|
+
if (valA >= valB) {
|
|
320
|
+
violations.push({
|
|
321
|
+
constraint: c,
|
|
322
|
+
message: `Less-than constraint violated: ${valA} >= ${valB}`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return violations;
|
|
329
|
+
}
|
|
330
|
+
function getEdgeValue(bounds, edge) {
|
|
331
|
+
switch (edge) {
|
|
332
|
+
case 'left': return bounds.x;
|
|
333
|
+
case 'right': return bounds.x + bounds.width;
|
|
334
|
+
case 'top': return bounds.y;
|
|
335
|
+
case 'bottom': return bounds.y + bounds.height;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// =============================================================================
|
|
339
|
+
// Exports for Testing
|
|
340
|
+
// =============================================================================
|
|
341
|
+
export const _internals = {
|
|
342
|
+
extractCoordinates,
|
|
343
|
+
buildEquivalenceClasses,
|
|
344
|
+
propagateConstraints,
|
|
345
|
+
verifyTopology,
|
|
346
|
+
rationalToFloat,
|
|
347
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Q-Dimension Event Backpressure Control
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* 1. Event coalescing (latest-only sampling)
|
|
6
|
+
* 2. Priority queue handling (critical events)
|
|
7
|
+
* 3. Async event isolation and merging
|
|
8
|
+
* 4. P/Q boundary enforcement (T-state keys only)
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Q-Dimension Event Backpressure Control
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* 1. Event coalescing (latest-only sampling)
|
|
6
|
+
* 2. Priority queue handling (critical events)
|
|
7
|
+
* 3. Async event isolation and merging
|
|
8
|
+
* 4. P/Q boundary enforcement (T-state keys only)
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
11
|
+
import { EventBuffer, EventPriority, } from '../event-backpressure';
|
|
12
|
+
describe('EventBuffer', () => {
|
|
13
|
+
let buffer;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
buffer = new EventBuffer({ maxEventsPerFrame: 10 });
|
|
16
|
+
});
|
|
17
|
+
// ===========================================================================
|
|
18
|
+
// Basic Event Handling
|
|
19
|
+
// ===========================================================================
|
|
20
|
+
describe('push and flush', () => {
|
|
21
|
+
it('should flush pushed events', () => {
|
|
22
|
+
const event = createEvent(1, 'hover', 1);
|
|
23
|
+
buffer.push(event);
|
|
24
|
+
const flushed = buffer.flush();
|
|
25
|
+
expect(flushed).toHaveLength(1);
|
|
26
|
+
expect(flushed[0].entityId).toBe(1);
|
|
27
|
+
expect(flushed[0].state).toBe('hover');
|
|
28
|
+
expect(flushed[0].value).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
it('should coalesce events for same entity+state', () => {
|
|
31
|
+
// Push multiple events for same entity+state
|
|
32
|
+
buffer.push(createEvent(1, 'scroll_y', 0.1));
|
|
33
|
+
buffer.push(createEvent(1, 'scroll_y', 0.2));
|
|
34
|
+
buffer.push(createEvent(1, 'scroll_y', 0.3));
|
|
35
|
+
const flushed = buffer.flush();
|
|
36
|
+
// Should only have ONE event (the latest)
|
|
37
|
+
expect(flushed).toHaveLength(1);
|
|
38
|
+
expect(flushed[0].value).toBe(0.3);
|
|
39
|
+
});
|
|
40
|
+
it('should NOT coalesce events for different states', () => {
|
|
41
|
+
buffer.push(createEvent(1, 'hover', 1));
|
|
42
|
+
buffer.push(createEvent(1, 'pressed', 1));
|
|
43
|
+
const flushed = buffer.flush();
|
|
44
|
+
expect(flushed).toHaveLength(2);
|
|
45
|
+
});
|
|
46
|
+
it('should clear buffer after flush', () => {
|
|
47
|
+
buffer.push(createEvent(1, 'hover', 1));
|
|
48
|
+
buffer.flush();
|
|
49
|
+
const secondFlush = buffer.flush();
|
|
50
|
+
expect(secondFlush).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
// ===========================================================================
|
|
54
|
+
// Priority Queue
|
|
55
|
+
// ===========================================================================
|
|
56
|
+
describe('priority handling', () => {
|
|
57
|
+
it('should never coalesce CRITICAL events', () => {
|
|
58
|
+
// Multiple click events should all be preserved
|
|
59
|
+
buffer.push(createEvent(1, 'pressed', 1, EventPriority.CRITICAL));
|
|
60
|
+
buffer.push(createEvent(1, 'pressed', 0, EventPriority.CRITICAL));
|
|
61
|
+
buffer.push(createEvent(1, 'pressed', 1, EventPriority.CRITICAL));
|
|
62
|
+
const flushed = buffer.flush();
|
|
63
|
+
// All 3 CRITICAL events should be preserved
|
|
64
|
+
expect(flushed).toHaveLength(3);
|
|
65
|
+
});
|
|
66
|
+
it('should process CRITICAL events before coalesced events', () => {
|
|
67
|
+
// Push low-priority first
|
|
68
|
+
buffer.push(createEvent(1, 'scroll_y', 0.5, EventPriority.LOW));
|
|
69
|
+
// Then critical
|
|
70
|
+
buffer.push(createEvent(2, 'pressed', 1, EventPriority.CRITICAL));
|
|
71
|
+
const flushed = buffer.flush();
|
|
72
|
+
// CRITICAL should come first
|
|
73
|
+
expect(flushed[0].entityId).toBe(2);
|
|
74
|
+
expect(flushed[0].state).toBe('pressed');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
// ===========================================================================
|
|
78
|
+
// Async Event Handling (Phase 2 Remediation)
|
|
79
|
+
// ===========================================================================
|
|
80
|
+
describe('pushAsync and mergeAsyncEvents', () => {
|
|
81
|
+
it('should isolate async events from sync events', () => {
|
|
82
|
+
// Push sync event
|
|
83
|
+
buffer.push(createEvent(1, 'hover', 1));
|
|
84
|
+
// Push async event (isolated)
|
|
85
|
+
buffer.pushAsync(createEvent(2, 'animation_t', 0.5));
|
|
86
|
+
// Before merge, stats should show async pending
|
|
87
|
+
const stats = buffer.getStats();
|
|
88
|
+
expect(stats.asyncPendingSize).toBe(1);
|
|
89
|
+
expect(stats.coalescedSize).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
it('should merge async events on mergeAsyncEvents call', () => {
|
|
92
|
+
buffer.pushAsync(createEvent(1, 'animation_t', 0.5));
|
|
93
|
+
buffer.pushAsync(createEvent(2, 'drag_progress', 0.3));
|
|
94
|
+
// Before merge
|
|
95
|
+
expect(buffer.getStats().asyncPendingSize).toBe(2);
|
|
96
|
+
// Merge
|
|
97
|
+
buffer.mergeAsyncEvents();
|
|
98
|
+
// After merge, async buffer should be empty
|
|
99
|
+
expect(buffer.getStats().asyncPendingSize).toBe(0);
|
|
100
|
+
// Events should now be in main buffer
|
|
101
|
+
const flushed = buffer.flush();
|
|
102
|
+
expect(flushed).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
it('should apply coalescing rules to async events during merge', () => {
|
|
105
|
+
// Multiple async events for same entity+state
|
|
106
|
+
buffer.pushAsync(createEvent(1, 'animation_t', 0.1));
|
|
107
|
+
buffer.pushAsync(createEvent(1, 'animation_t', 0.2));
|
|
108
|
+
buffer.pushAsync(createEvent(1, 'animation_t', 0.3));
|
|
109
|
+
buffer.mergeAsyncEvents();
|
|
110
|
+
const flushed = buffer.flush();
|
|
111
|
+
// Should be coalesced to latest value
|
|
112
|
+
expect(flushed).toHaveLength(1);
|
|
113
|
+
expect(flushed[0].value).toBe(0.3);
|
|
114
|
+
});
|
|
115
|
+
it('should handle empty async buffer gracefully', () => {
|
|
116
|
+
// Merge with nothing pending should be a no-op
|
|
117
|
+
buffer.mergeAsyncEvents();
|
|
118
|
+
expect(buffer.getStats().asyncPendingSize).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
it('should preserve async event ordering (FIFO within async)', () => {
|
|
121
|
+
// Async events with different states
|
|
122
|
+
buffer.pushAsync(createEvent(1, 'hover', 1));
|
|
123
|
+
buffer.pushAsync(createEvent(1, 'pressed', 1));
|
|
124
|
+
buffer.pushAsync(createEvent(1, 'focused', 1));
|
|
125
|
+
buffer.mergeAsyncEvents();
|
|
126
|
+
const flushed = buffer.flush();
|
|
127
|
+
// All three should be present (different states, no coalescing)
|
|
128
|
+
expect(flushed).toHaveLength(3);
|
|
129
|
+
});
|
|
130
|
+
it('should merge async before sync in tick simulation', () => {
|
|
131
|
+
// Simulate tick() behavior:
|
|
132
|
+
// 1. Async events arrive before tick
|
|
133
|
+
buffer.pushAsync(createEvent(1, 'animation_t', 0.5));
|
|
134
|
+
// 2. Sync event arrives
|
|
135
|
+
buffer.push(createEvent(2, 'hover', 1));
|
|
136
|
+
// 3. At tick start, merge async
|
|
137
|
+
buffer.mergeAsyncEvents();
|
|
138
|
+
// 4. Flush all
|
|
139
|
+
const flushed = buffer.flush();
|
|
140
|
+
expect(flushed).toHaveLength(2);
|
|
141
|
+
// Both events should be present
|
|
142
|
+
expect(flushed.some(e => e.entityId === 1)).toBe(true);
|
|
143
|
+
expect(flushed.some(e => e.entityId === 2)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
// ===========================================================================
|
|
147
|
+
// Backpressure Limits
|
|
148
|
+
// ===========================================================================
|
|
149
|
+
describe('backpressure', () => {
|
|
150
|
+
it('should respect maxEventsPerFrame limit', () => {
|
|
151
|
+
const limitedBuffer = new EventBuffer({ maxEventsPerFrame: 3 });
|
|
152
|
+
// Push many critical events (which can't be coalesced)
|
|
153
|
+
for (let i = 0; i < 10; i++) {
|
|
154
|
+
limitedBuffer.push(createEvent(i, 'pressed', 1, EventPriority.CRITICAL));
|
|
155
|
+
}
|
|
156
|
+
const flushed = limitedBuffer.flush();
|
|
157
|
+
// Should be limited (critical events + coalesced up to limit)
|
|
158
|
+
// Note: implementation may vary, but should not exceed reasonable limit
|
|
159
|
+
expect(flushed.length).toBeLessThanOrEqual(10);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
// ===========================================================================
|
|
163
|
+
// Stats
|
|
164
|
+
// ===========================================================================
|
|
165
|
+
describe('getStats', () => {
|
|
166
|
+
it('should report correct buffer sizes', () => {
|
|
167
|
+
buffer.push(createEvent(1, 'hover', 1));
|
|
168
|
+
buffer.push(createEvent(2, 'hover', 1));
|
|
169
|
+
buffer.push(createEvent(3, 'pressed', 1, EventPriority.CRITICAL));
|
|
170
|
+
buffer.pushAsync(createEvent(4, 'animation_t', 0.5));
|
|
171
|
+
const stats = buffer.getStats();
|
|
172
|
+
expect(stats.coalescedSize).toBe(2); // Two coalesced events
|
|
173
|
+
expect(stats.priorityQueueSize).toBe(1); // One critical event
|
|
174
|
+
expect(stats.asyncPendingSize).toBe(1); // One async event
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
// ===========================================================================
|
|
179
|
+
// Test Helpers
|
|
180
|
+
// ===========================================================================
|
|
181
|
+
function createEvent(entityId, targetState, value, priority = EventPriority.NORMAL) {
|
|
182
|
+
return {
|
|
183
|
+
entityId,
|
|
184
|
+
eventType: 'pointermove',
|
|
185
|
+
targetState,
|
|
186
|
+
value,
|
|
187
|
+
timestamp: performance.now(),
|
|
188
|
+
priority,
|
|
189
|
+
};
|
|
190
|
+
}
|