sketchmark 2.0.0
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/README.md +188 -0
- package/bin/sketchmark.cjs +2008 -0
- package/dist/src/builders/index.d.ts +74 -0
- package/dist/src/builders/index.js +230 -0
- package/dist/src/compounds.d.ts +13 -0
- package/dist/src/compounds.js +118 -0
- package/dist/src/deck.d.ts +4 -0
- package/dist/src/deck.js +91 -0
- package/dist/src/diagnostics.d.ts +5 -0
- package/dist/src/diagnostics.js +113 -0
- package/dist/src/export/index.d.ts +8 -0
- package/dist/src/export/index.js +15 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +35 -0
- package/dist/src/kernel.d.ts +8 -0
- package/dist/src/kernel.js +68 -0
- package/dist/src/normalize.d.ts +6 -0
- package/dist/src/normalize.js +191 -0
- package/dist/src/patch.d.ts +5 -0
- package/dist/src/patch.js +72 -0
- package/dist/src/path-sampling.d.ts +3 -0
- package/dist/src/path-sampling.js +275 -0
- package/dist/src/player/index.d.ts +68 -0
- package/dist/src/player/index.js +600 -0
- package/dist/src/project.d.ts +11 -0
- package/dist/src/project.js +107 -0
- package/dist/src/render/html.d.ts +2 -0
- package/dist/src/render/html.js +13 -0
- package/dist/src/render/raw-three.d.ts +7 -0
- package/dist/src/render/raw-three.js +17 -0
- package/dist/src/render/svg.d.ts +3 -0
- package/dist/src/render/svg.js +277 -0
- package/dist/src/render/three-html.d.ts +2 -0
- package/dist/src/render/three-html.js +303 -0
- package/dist/src/render/three-preview-svg.d.ts +3 -0
- package/dist/src/render/three-preview-svg.js +102 -0
- package/dist/src/scenes.d.ts +4 -0
- package/dist/src/scenes.js +25 -0
- package/dist/src/schema.d.ts +2 -0
- package/dist/src/schema.js +403 -0
- package/dist/src/sequences.d.ts +43 -0
- package/dist/src/sequences.js +109 -0
- package/dist/src/shapes/builtins.d.ts +2 -0
- package/dist/src/shapes/builtins.js +429 -0
- package/dist/src/shapes/common.d.ts +9 -0
- package/dist/src/shapes/common.js +75 -0
- package/dist/src/shapes/geometry.d.ts +22 -0
- package/dist/src/shapes/geometry.js +166 -0
- package/dist/src/shapes/index.d.ts +2 -0
- package/dist/src/shapes/index.js +18 -0
- package/dist/src/shapes/registry.d.ts +9 -0
- package/dist/src/shapes/registry.js +35 -0
- package/dist/src/shapes/types.d.ts +34 -0
- package/dist/src/shapes/types.js +2 -0
- package/dist/src/types.d.ts +439 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils.d.ts +25 -0
- package/dist/src/utils.js +157 -0
- package/dist/src/validate.d.ts +2 -0
- package/dist/src/validate.js +434 -0
- package/dist/tests/run.d.ts +1 -0
- package/dist/tests/run.js +651 -0
- package/package.json +52 -0
- package/schema/visual.schema.json +930 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const src_1 = require("../src");
|
|
6
|
+
const shapes_1 = require("../src/shapes");
|
|
7
|
+
const builders_1 = require("../src/builders");
|
|
8
|
+
function test(name, fn) {
|
|
9
|
+
try {
|
|
10
|
+
fn();
|
|
11
|
+
console.log(`ok - ${name}`);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
console.error(`not ok - ${name}`);
|
|
15
|
+
console.error(error);
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
test("validates primitive geometry", () => {
|
|
20
|
+
const doc = {
|
|
21
|
+
version: 1,
|
|
22
|
+
canvas: { width: 400, height: 240 },
|
|
23
|
+
elements: [{ id: "card", type: "rect", x: 20, y: 30, width: 100, height: 60 }]
|
|
24
|
+
};
|
|
25
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
26
|
+
assert(result.ok, "document should be valid");
|
|
27
|
+
});
|
|
28
|
+
test("rejects compound types in canonical JSON", () => {
|
|
29
|
+
const doc = {
|
|
30
|
+
version: 1,
|
|
31
|
+
canvas: { width: 400, height: 240 },
|
|
32
|
+
elements: [{ id: "bad", type: "node", label: "Browser", x: 0, y: 0, width: 100, height: 50 }]
|
|
33
|
+
};
|
|
34
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
35
|
+
assert(!result.ok, "document should be invalid");
|
|
36
|
+
assert(result.issues.some((item) => item.code === "compound_type_not_allowed"), "should report compound type");
|
|
37
|
+
});
|
|
38
|
+
test("builders expand compounds into primitives", () => {
|
|
39
|
+
const items = [
|
|
40
|
+
...(0, builders_1.node)({ id: "browser", label: "Browser", x: 120, y: 160, width: 180, height: 80 }),
|
|
41
|
+
...(0, builders_1.node)({ id: "resolver", label: "DNS Resolver", x: 380, y: 160, width: 180, height: 80 }),
|
|
42
|
+
...(0, builders_1.flow)({ id: "query", from: "browser_box.right", to: "resolver_box.left", label: "Query", labelX: 340, labelY: 145 }),
|
|
43
|
+
(0, builders_1.packet)({ id: "query_packet", on: "query", progress: (0, builders_1.animate)(0, 1, { duration: 1, delay: 1 }) }),
|
|
44
|
+
...(0, builders_1.callout)({ id: "note", text: "Cached result", x: 520, y: 80, width: 140, height: 48, target: "resolver_box.top" })
|
|
45
|
+
];
|
|
46
|
+
const compoundTypes = new Set(["node", "flow", "packet", "callout"]);
|
|
47
|
+
assert(items.every((item) => !compoundTypes.has(item.type)), "builders must return primitives");
|
|
48
|
+
});
|
|
49
|
+
test("compileCompounds is explicit and outputs primitives", () => {
|
|
50
|
+
const doc = (0, src_1.compileCompounds)({
|
|
51
|
+
version: 1,
|
|
52
|
+
canvas: { width: 640, height: 360 },
|
|
53
|
+
elements: [
|
|
54
|
+
{ id: "browser", type: "node", label: "Browser", x: 80, y: 120, width: 160, height: 70 },
|
|
55
|
+
{ id: "resolver", type: "node", label: "Resolver", x: 320, y: 120, width: 160, height: 70 },
|
|
56
|
+
{ id: "query", type: "flow", from: "browser_box.right", to: "resolver_box.left", label: "Query", labelX: 280, labelY: 110 }
|
|
57
|
+
]
|
|
58
|
+
});
|
|
59
|
+
const forbidden = new Set(["node", "flow"]);
|
|
60
|
+
assert(doc.elements?.every((item) => !forbidden.has(item.type)), "compiled document should contain only primitives");
|
|
61
|
+
assert((0, src_1.validateVisualDocument)(doc).ok, "compiled document should validate");
|
|
62
|
+
});
|
|
63
|
+
test("resolves references to numeric endpoints", () => {
|
|
64
|
+
const doc = (0, builders_1.scene)({
|
|
65
|
+
canvas: { width: 640, height: 360 },
|
|
66
|
+
elements: [
|
|
67
|
+
{ id: "left", type: "rect", x: 100, y: 100, width: 100, height: 60 },
|
|
68
|
+
{ id: "right", type: "rect", x: 300, y: 100, width: 100, height: 60 },
|
|
69
|
+
{ id: "arrow", type: "arrow", from: "left.right", to: "right.left", stroke: "#111827" }
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
const normalized = (0, src_1.normalizeVisualDocument)(doc);
|
|
73
|
+
const arrow = normalized.elements.find((item) => item.id === "arrow");
|
|
74
|
+
assert(JSON.stringify(arrow.from) === JSON.stringify([200, 130]), "from should resolve to right edge");
|
|
75
|
+
assert(JSON.stringify(arrow.to) === JSON.stringify([300, 130]), "to should resolve to left edge");
|
|
76
|
+
});
|
|
77
|
+
test("animated x requires a static x", () => {
|
|
78
|
+
const doc = {
|
|
79
|
+
version: 1,
|
|
80
|
+
canvas: { width: 400, height: 240 },
|
|
81
|
+
elements: [{ id: "label", type: "text", text: "Move", y: 40, animate: { x: (0, builders_1.animate)(0, 100, { duration: 1 }) } }]
|
|
82
|
+
};
|
|
83
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
84
|
+
assert(!result.ok, "document should be invalid");
|
|
85
|
+
assert(result.issues.some((item) => item.code === "missing_static_animation_property"), "should require static animated property");
|
|
86
|
+
});
|
|
87
|
+
test("rejects raw keyframe arrays in animate", () => {
|
|
88
|
+
const doc = {
|
|
89
|
+
version: 1,
|
|
90
|
+
canvas: { width: 400, height: 240, duration: 2 },
|
|
91
|
+
elements: [
|
|
92
|
+
{
|
|
93
|
+
id: "dot",
|
|
94
|
+
type: "circle",
|
|
95
|
+
cx: 20,
|
|
96
|
+
cy: 40,
|
|
97
|
+
radius: 6,
|
|
98
|
+
animate: { cx: [[0, 20], [1, 120], [2, 20]] }
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
};
|
|
102
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
103
|
+
assert(!result.ok, "document should be invalid");
|
|
104
|
+
assert(result.issues.some((item) => item.code === "invalid_animation"), "should reject raw keyframe arrays");
|
|
105
|
+
});
|
|
106
|
+
test("rejects effects arrays", () => {
|
|
107
|
+
const doc = {
|
|
108
|
+
version: 1,
|
|
109
|
+
canvas: { width: 400, height: 240 },
|
|
110
|
+
elements: [
|
|
111
|
+
{
|
|
112
|
+
id: "sun",
|
|
113
|
+
type: "circle",
|
|
114
|
+
cx: 200,
|
|
115
|
+
cy: 120,
|
|
116
|
+
radius: 30,
|
|
117
|
+
effects: [{ type: "shadow", blur: 20, color: "#facc15" }]
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
};
|
|
121
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
122
|
+
assert(!result.ok, "document should be invalid");
|
|
123
|
+
assert(result.issues.some((item) => item.code === "invalid_effects"), "should reject effects arrays");
|
|
124
|
+
});
|
|
125
|
+
test("rejects structured canvas backgrounds", () => {
|
|
126
|
+
const doc = {
|
|
127
|
+
version: 1,
|
|
128
|
+
canvas: {
|
|
129
|
+
width: 400,
|
|
130
|
+
height: 240,
|
|
131
|
+
background: {
|
|
132
|
+
type: "linearGradient",
|
|
133
|
+
from: [0, 0],
|
|
134
|
+
to: [0, 240],
|
|
135
|
+
stops: [[0, "#87ceeb"], [1, "#e8f4f8"]]
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
elements: []
|
|
139
|
+
};
|
|
140
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
141
|
+
assert(!result.ok, "document should be invalid");
|
|
142
|
+
assert(result.issues.some((item) => item.code === "invalid_canvas_background"), "should reject structured canvas backgrounds");
|
|
143
|
+
});
|
|
144
|
+
test("long text warns without changing layout", () => {
|
|
145
|
+
const doc = {
|
|
146
|
+
version: 1,
|
|
147
|
+
canvas: { width: 400, height: 240 },
|
|
148
|
+
elements: [{ id: "label", type: "text", text: "This is a long label that should warn instead of resizing anything automatically", x: 20, y: 20 }]
|
|
149
|
+
};
|
|
150
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
151
|
+
assert(result.ok, "warning should not invalidate document");
|
|
152
|
+
assert(result.warnings.some((item) => item.code === "long_text_no_wrap"), "should warn for long text");
|
|
153
|
+
});
|
|
154
|
+
test("row and column builders position explicit primitive children", () => {
|
|
155
|
+
const rowItems = (0, builders_1.row)({
|
|
156
|
+
x: 10,
|
|
157
|
+
y: 20,
|
|
158
|
+
gap: 10,
|
|
159
|
+
children: [
|
|
160
|
+
(0, builders_1.node)({ id: "a", label: "A", x: 0, y: 0, width: 100, height: 40 }),
|
|
161
|
+
(0, builders_1.node)({ id: "b", label: "B", x: 0, y: 0, width: 100, height: 40 })
|
|
162
|
+
]
|
|
163
|
+
});
|
|
164
|
+
const bBox = rowItems.find((item) => item.id === "b_box");
|
|
165
|
+
assert(bBox.x === 120 && bBox.y === 20, "row should move second box deterministically");
|
|
166
|
+
const columnItems = (0, builders_1.column)({
|
|
167
|
+
x: 10,
|
|
168
|
+
y: 20,
|
|
169
|
+
gap: 10,
|
|
170
|
+
children: [
|
|
171
|
+
(0, builders_1.node)({ id: "c", label: "C", x: 0, y: 0, width: 100, height: 40 }),
|
|
172
|
+
(0, builders_1.node)({ id: "d", label: "D", x: 0, y: 0, width: 100, height: 40 })
|
|
173
|
+
]
|
|
174
|
+
});
|
|
175
|
+
const dBox = columnItems.find((item) => item.id === "d_box");
|
|
176
|
+
assert(dBox.x === 10 && dBox.y === 70, "column should move second box deterministically");
|
|
177
|
+
});
|
|
178
|
+
test("renders primitive SVG", () => {
|
|
179
|
+
const doc = (0, builders_1.scene)({
|
|
180
|
+
canvas: { width: 320, height: 180, background: "#ffffff" },
|
|
181
|
+
elements: [
|
|
182
|
+
{ id: "box", type: "rect", x: 20, y: 20, width: 100, height: 60, fill: "#ffffff", stroke: "#111827" },
|
|
183
|
+
{ id: "label", type: "text", text: "Hello", x: 70, y: 50, align: "center", valign: "middle" }
|
|
184
|
+
]
|
|
185
|
+
});
|
|
186
|
+
const svg = (0, src_1.renderToSvg)(doc);
|
|
187
|
+
assert(svg.includes("<svg"), "should render svg");
|
|
188
|
+
assert(svg.includes("Hello"), "should include text");
|
|
189
|
+
assert(!(0, src_1.renderToSvg)(doc, { transparent: true }).includes('width="320" height="180" fill="#ffffff"'), "transparent SVG should omit background rect");
|
|
190
|
+
});
|
|
191
|
+
test("renders abstract canvas primitive styling", () => {
|
|
192
|
+
const doc = (0, builders_1.scene)({
|
|
193
|
+
canvas: { width: 520, height: 320, background: "#f8fafc" },
|
|
194
|
+
elements: [
|
|
195
|
+
{
|
|
196
|
+
id: "panel",
|
|
197
|
+
type: "rect",
|
|
198
|
+
x: 40,
|
|
199
|
+
y: 40,
|
|
200
|
+
width: 180,
|
|
201
|
+
height: 120,
|
|
202
|
+
radius: 14,
|
|
203
|
+
fill: { type: "linearGradient", from: [40, 40], to: [220, 160], stops: [[0, "#dbeafe"], [1, "#a7f3d0"]] },
|
|
204
|
+
stroke: "#2563eb",
|
|
205
|
+
strokeWidth: 3,
|
|
206
|
+
effects: { shadow: { dx: 0, dy: 8, blur: 16, color: "#64748b", opacity: 0.25 } },
|
|
207
|
+
rotation: -4
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "copy",
|
|
211
|
+
type: "text",
|
|
212
|
+
lines: ["Gradient panel", "with two lines"],
|
|
213
|
+
x: 130,
|
|
214
|
+
y: 100,
|
|
215
|
+
align: "center",
|
|
216
|
+
valign: "middle",
|
|
217
|
+
fontSize: 18,
|
|
218
|
+
lineHeight: 1.25,
|
|
219
|
+
letterSpacing: 0.2
|
|
220
|
+
},
|
|
221
|
+
{ id: "anchor", type: "point", x: 260, y: 100 },
|
|
222
|
+
{ id: "trend", type: "polyline", points: [[300, 170], [340, 130], [390, 150], [440, 90]], stroke: "#dc2626", strokeWidth: 4, strokeCap: "round", drawEnd: 0.6 },
|
|
223
|
+
{ id: "triangle", type: "polygon", points: [[330, 230], [390, 190], [450, 230]], fill: "#fef3c7", stroke: "#f59e0b", strokeJoin: "round", blendMode: "multiply" },
|
|
224
|
+
{ id: "link", type: "arrow", from: "panel.right", to: "anchor", stroke: "#16a34a", strokeWidth: 3, strokeCap: "round" }
|
|
225
|
+
]
|
|
226
|
+
});
|
|
227
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
228
|
+
assert(result.ok, `styled document should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
229
|
+
const svg = (0, src_1.renderToSvg)(doc);
|
|
230
|
+
assert(svg.includes("<linearGradient"), "should render gradients");
|
|
231
|
+
assert(svg.includes("feDropShadow"), "should render shadow filters");
|
|
232
|
+
assert(svg.includes("<tspan"), "should render multiline text");
|
|
233
|
+
assert(svg.includes("pathLength=\"1\""), "should render draw reveal");
|
|
234
|
+
assert(svg.includes("stroke-linecap=\"round\""), "should render stroke caps");
|
|
235
|
+
assert(svg.includes("mix-blend-mode:multiply"), "should render blend mode");
|
|
236
|
+
});
|
|
237
|
+
test("color animation resolves fill and stroke values", () => {
|
|
238
|
+
const doc = (0, builders_1.scene)({
|
|
239
|
+
canvas: { width: 200, height: 120 },
|
|
240
|
+
elements: [
|
|
241
|
+
{
|
|
242
|
+
id: "signal",
|
|
243
|
+
type: "circle",
|
|
244
|
+
cx: 60,
|
|
245
|
+
cy: 60,
|
|
246
|
+
radius: 20,
|
|
247
|
+
fill: "#ff0000",
|
|
248
|
+
stroke: "#0000ff",
|
|
249
|
+
animate: {
|
|
250
|
+
fill: (0, builders_1.animate)("#ff0000", "#00ff00", { duration: 2 }),
|
|
251
|
+
stroke: (0, builders_1.animate)("#0000ff", "#ff0000", { duration: 2 })
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
});
|
|
256
|
+
const frame = (0, src_1.resolveVisualFrame)(doc, 1);
|
|
257
|
+
const signal = frame.elements.find((item) => item.id === "signal");
|
|
258
|
+
assert(signal.fill === "#808000", `fill should interpolate, got ${signal.fill}`);
|
|
259
|
+
assert(signal.stroke === "#800080", `stroke should interpolate, got ${signal.stroke}`);
|
|
260
|
+
});
|
|
261
|
+
test("point references resolve after point animation", () => {
|
|
262
|
+
const doc = (0, builders_1.scene)({
|
|
263
|
+
canvas: { width: 240, height: 120 },
|
|
264
|
+
elements: [
|
|
265
|
+
{ id: "moving", type: "point", x: 20, y: 40, animate: { x: (0, builders_1.animate)(20, 120, { duration: 2 }) } },
|
|
266
|
+
{ id: "wire", type: "line", from: "moving", to: [200, 40], stroke: "#111827" }
|
|
267
|
+
]
|
|
268
|
+
});
|
|
269
|
+
const frame = (0, src_1.resolveVisualFrame)(doc, 1);
|
|
270
|
+
const wire = frame.elements.find((item) => item.id === "wire");
|
|
271
|
+
assert(JSON.stringify(wire.from) === JSON.stringify([70, 40]), `animated point reference should resolve, got ${JSON.stringify(wire.from)}`);
|
|
272
|
+
});
|
|
273
|
+
test("renders image fit, image crop, arc, curve, mask, and pattern paint", () => {
|
|
274
|
+
const texture = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Crect width='32' height='32' fill='%23dbeafe'/%3E%3Cpath d='M0 32 L32 0' stroke='%232563eb' stroke-width='6'/%3E%3C/svg%3E";
|
|
275
|
+
const photo = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='240'%3E%3Crect width='400' height='240' fill='%23fef3c7'/%3E%3Ccircle cx='260' cy='120' r='90' fill='%23fb923c'/%3E%3C/svg%3E";
|
|
276
|
+
const doc = (0, builders_1.scene)({
|
|
277
|
+
canvas: { width: 640, height: 360, background: "#ffffff" },
|
|
278
|
+
elements: [
|
|
279
|
+
{
|
|
280
|
+
id: "pattern_card",
|
|
281
|
+
type: "rect",
|
|
282
|
+
x: 40,
|
|
283
|
+
y: 40,
|
|
284
|
+
width: 150,
|
|
285
|
+
height: 110,
|
|
286
|
+
radius: 12,
|
|
287
|
+
fill: { type: "pattern", src: texture, width: 32, height: 32, fit: "cover" },
|
|
288
|
+
stroke: "#2563eb"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: "cropped_photo",
|
|
292
|
+
type: "image",
|
|
293
|
+
src: photo,
|
|
294
|
+
x: 230,
|
|
295
|
+
y: 40,
|
|
296
|
+
width: 160,
|
|
297
|
+
height: 110,
|
|
298
|
+
fit: "cover",
|
|
299
|
+
source: { x: 100, y: 40, width: 180, height: 120, imageWidth: 400, imageHeight: 240 },
|
|
300
|
+
mask: { type: "circle", cx: 310, cy: 95, radius: 55 }
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "angle",
|
|
304
|
+
type: "arc",
|
|
305
|
+
cx: 120,
|
|
306
|
+
cy: 250,
|
|
307
|
+
radius: 70,
|
|
308
|
+
startAngle: -20,
|
|
309
|
+
endAngle: 230,
|
|
310
|
+
stroke: "#dc2626",
|
|
311
|
+
strokeWidth: 5,
|
|
312
|
+
strokeCap: "round",
|
|
313
|
+
drawEnd: 0.75
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
id: "flow_curve",
|
|
317
|
+
type: "curve",
|
|
318
|
+
from: [250, 250],
|
|
319
|
+
control1: [340, 150],
|
|
320
|
+
control2: [440, 340],
|
|
321
|
+
to: [560, 230],
|
|
322
|
+
stroke: "#16a34a",
|
|
323
|
+
strokeWidth: 5,
|
|
324
|
+
strokeCap: "round",
|
|
325
|
+
fill: "none"
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
id: "curve_dot",
|
|
329
|
+
type: "circle",
|
|
330
|
+
radius: 7,
|
|
331
|
+
fill: "#111827",
|
|
332
|
+
follow: "flow_curve",
|
|
333
|
+
progress: 0.5
|
|
334
|
+
}
|
|
335
|
+
]
|
|
336
|
+
});
|
|
337
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
338
|
+
assert(result.ok, `canvas-like document should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
339
|
+
const frame = (0, src_1.resolveVisualFrame)(doc, 0);
|
|
340
|
+
const dot = frame.elements.find((item) => item.id === "curve_dot");
|
|
341
|
+
assert(dot.cx > 250 && dot.cx < 560, "circle should follow curve primitive");
|
|
342
|
+
const svg = (0, src_1.renderToSvg)(doc);
|
|
343
|
+
assert(svg.includes("<pattern"), "should render pattern paint");
|
|
344
|
+
assert(svg.includes("preserveAspectRatio=\"xMidYMid slice\""), "should render cover image fit");
|
|
345
|
+
assert(svg.includes("clip-path=\"url(#"), "should crop image with clip path");
|
|
346
|
+
assert(svg.includes("<mask"), "should render masks");
|
|
347
|
+
assert(svg.includes(" A "), "should render arc path");
|
|
348
|
+
assert(svg.includes(" C "), "should render cubic curve path");
|
|
349
|
+
});
|
|
350
|
+
test("lowers authoring primitives into the 2D kernel", () => {
|
|
351
|
+
const doc = (0, builders_1.scene)({
|
|
352
|
+
canvas: { width: 640, height: 360 },
|
|
353
|
+
elements: [
|
|
354
|
+
{ id: "box", type: "rect", x: 20, y: 20, width: 80, height: 50, radius: 8, fill: "#ffffff", clip: { type: "rect", x: 20, y: 20, width: 80, height: 50 } },
|
|
355
|
+
{ id: "dot", type: "circle", cx: 140, cy: 45, radius: 20, fill: "#2563eb" },
|
|
356
|
+
{ id: "oval", type: "ellipse", cx: 210, cy: 45, rx: 28, ry: 16 },
|
|
357
|
+
{ id: "wire", type: "line", from: [260, 20], to: [320, 80] },
|
|
358
|
+
{ id: "arrow", type: "arrow", from: [340, 20], to: [400, 80], stroke: "#dc2626" },
|
|
359
|
+
{ id: "angle", type: "arc", cx: 100, cy: 160, radius: 40, startAngle: 0, endAngle: 180 },
|
|
360
|
+
{ id: "curve", type: "curve", from: [170, 160], control1: [230, 80], to: [300, 160], stroke: "#16a34a" },
|
|
361
|
+
{ id: "poly", type: "polyline", points: [[330, 150], [360, 110], [390, 150]] },
|
|
362
|
+
{ id: "shape", type: "polygon", points: [[420, 150], [450, 110], [480, 150]] },
|
|
363
|
+
{ id: "raw", type: "path", d: "M 20 260 L 100 260" },
|
|
364
|
+
{ id: "label", type: "text", text: "Kernel", x: 160, y: 260 },
|
|
365
|
+
{ id: "anchor", type: "point", x: 240, y: 260 },
|
|
366
|
+
{ id: "photo", type: "image", src: "data:image/svg+xml,%3Csvg/%3E", x: 280, y: 230, width: 80, height: 50, mask: { type: "circle", cx: 320, cy: 255, radius: 24 } },
|
|
367
|
+
{ id: "group", type: "group", x: 420, y: 220, children: [{ id: "child", type: "rect", x: 0, y: 0, width: 40, height: 30 }] }
|
|
368
|
+
]
|
|
369
|
+
});
|
|
370
|
+
const lowered = (0, src_1.lowerVisualDocument)(doc);
|
|
371
|
+
const allowed = new Set(["group", "path", "text", "image", "point"]);
|
|
372
|
+
assert((0, src_1.validateKernelVisualDocument)(lowered).ok, "lowered 2D document should validate as kernel");
|
|
373
|
+
assert(lowered.elements.every((item) => allowed.has(item.type)), "top-level 2D kernel should contain only low primitives");
|
|
374
|
+
assert(lowered.elements.filter((item) => item.type === "path").length >= 10, "friendly shapes should lower to paths");
|
|
375
|
+
assert(lowered.elements.find((item) => item.id === "arrow").metadata.markerEnd === "arrow", "arrow marker metadata should survive lowering");
|
|
376
|
+
assert(lowered.elements.find((item) => item.id === "box").clip.type === "path", "clip shape should lower to path");
|
|
377
|
+
assert(lowered.elements.find((item) => item.id === "photo").mask.type === "path", "mask shape should lower to path");
|
|
378
|
+
});
|
|
379
|
+
test("uses an internal registry for authoring shape lowerers", () => {
|
|
380
|
+
const types = (0, shapes_1.registeredAuthoringShapeTypes)();
|
|
381
|
+
assert(types.includes("rect") && types.includes("circle") && types.includes("sphere"), "built-in shape registry should expose registered authoring shapes");
|
|
382
|
+
});
|
|
383
|
+
test("validates built-in shape geometry through the registry", () => {
|
|
384
|
+
const doc = {
|
|
385
|
+
version: 1,
|
|
386
|
+
canvas: { width: 200, height: 120 },
|
|
387
|
+
elements: [{ id: "bad_rect", type: "rect", x: 10, y: 10, height: 40 }]
|
|
388
|
+
};
|
|
389
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
390
|
+
assert(!result.ok, "document should be invalid");
|
|
391
|
+
assert(result.issues.some((item) => item.path === "/elements/0/width" && item.code === "missing_number"), "rect width should be validated by shape definition");
|
|
392
|
+
});
|
|
393
|
+
test("enforces shape-owned animation properties", () => {
|
|
394
|
+
const doc = {
|
|
395
|
+
version: 1,
|
|
396
|
+
canvas: { width: 200, height: 120 },
|
|
397
|
+
elements: [{ id: "label", type: "text", text: "Hello", x: 20, y: 20, animate: { radius: (0, builders_1.animate)(2, 10, { duration: 1 }) } }]
|
|
398
|
+
};
|
|
399
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
400
|
+
assert(!result.ok, "document should be invalid");
|
|
401
|
+
assert(result.issues.some((item) => item.code === "unsupported_animation_property"), "text should not accept circle-only animation properties");
|
|
402
|
+
});
|
|
403
|
+
test("generated schema matches the committed schema artifact", () => {
|
|
404
|
+
const schemaPath = path.resolve(__dirname, "..", "..", "schema", "visual.schema.json");
|
|
405
|
+
const committed = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
|
406
|
+
assert(stableStringify((0, src_1.generateVisualSchema)()) === stableStringify(committed), "committed visual.schema.json should match generated schema");
|
|
407
|
+
});
|
|
408
|
+
test("resolves animation before lowering kernel frames", () => {
|
|
409
|
+
const doc = (0, builders_1.scene)({
|
|
410
|
+
canvas: { width: 320, height: 180, duration: 2 },
|
|
411
|
+
elements: [
|
|
412
|
+
{ id: "moving_box", type: "rect", x: 20, y: 30, width: 40, height: 30, animate: { x: (0, builders_1.animate)(20, 120, { duration: 2 }) } }
|
|
413
|
+
]
|
|
414
|
+
});
|
|
415
|
+
const frame = (0, src_1.resolveKernelFrame)(doc, 1);
|
|
416
|
+
const pathElement = frame.elements.find((item) => item.id === "moving_box");
|
|
417
|
+
assert(pathElement.type === "path", "animated rect should lower to a kernel path");
|
|
418
|
+
assert(pathElement.d.startsWith("M 70 "), `lowered frame should use resolved animated x, got ${pathElement.d}`);
|
|
419
|
+
assert(!("animate" in pathElement), "resolved kernel frame should not retain authoring animation instructions");
|
|
420
|
+
});
|
|
421
|
+
test("lowered kernel paths keep only kernel-compatible animations", () => {
|
|
422
|
+
const doc = (0, builders_1.scene)({
|
|
423
|
+
canvas: { width: 320, height: 180, duration: 2 },
|
|
424
|
+
elements: [
|
|
425
|
+
{
|
|
426
|
+
id: "moving_box",
|
|
427
|
+
type: "rect",
|
|
428
|
+
x: 20,
|
|
429
|
+
y: 30,
|
|
430
|
+
width: 40,
|
|
431
|
+
height: 30,
|
|
432
|
+
opacity: 0.4,
|
|
433
|
+
animate: {
|
|
434
|
+
x: (0, builders_1.animate)(20, 120, { duration: 2 }),
|
|
435
|
+
opacity: (0, builders_1.animate)(0.4, 1, { duration: 2 })
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
]
|
|
439
|
+
});
|
|
440
|
+
const lowered = (0, src_1.lowerVisualDocument)(doc);
|
|
441
|
+
const pathElement = lowered.elements.find((item) => item.id === "moving_box");
|
|
442
|
+
assert(pathElement.type === "path", "rect should lower to a kernel path");
|
|
443
|
+
assert(!("x" in (pathElement.animate ?? {})), "path lowering should remove authoring-only x animation");
|
|
444
|
+
assert(pathElement.animate?.opacity, "path lowering should keep compatible opacity animation");
|
|
445
|
+
});
|
|
446
|
+
test("lowers structured 3D authoring primitives into mesh kernel", () => {
|
|
447
|
+
const doc = {
|
|
448
|
+
version: 1,
|
|
449
|
+
canvas: { width: 320, height: 180, space: "3d", renderer: "three" },
|
|
450
|
+
elements: [
|
|
451
|
+
{ id: "cube", type: "cuboid", position: [0, 0, 0], size: [1, 1, 1], fill: "#2563eb" },
|
|
452
|
+
{ id: "ball", type: "sphere", position: [2, 0, 0], radius: 0.5, fill: "#ef4444" },
|
|
453
|
+
{ id: "floor", type: "plane", position: [0, -1, 0], size: [4, 3], fill: "#dbeafe" },
|
|
454
|
+
{ id: "axis", type: "line3d", from: [-1, 0, 0], to: [1, 0, 0], stroke: "#111827" },
|
|
455
|
+
{ id: "caption", type: "text3d", text: "3D", position: [0, 1, 0] },
|
|
456
|
+
{ id: "lamp", type: "light", kind: "directional", position: [2, 4, 3] }
|
|
457
|
+
]
|
|
458
|
+
};
|
|
459
|
+
const lowered = (0, src_1.lowerVisualDocument)(doc);
|
|
460
|
+
assert((0, src_1.validateKernelVisualDocument)(lowered).ok, "lowered 3D document should validate as kernel");
|
|
461
|
+
assert(lowered.elements.filter((item) => item.type === "mesh3d").length === 3, "cuboid, sphere, and plane should lower to mesh3d");
|
|
462
|
+
assert(lowered.elements.some((item) => item.type === "line3d"), "line3d should remain a kernel line");
|
|
463
|
+
assert(lowered.elements.find((item) => item.id === "cube").vertices.length === 8, "cuboid mesh should expose vertices");
|
|
464
|
+
});
|
|
465
|
+
test("circle followers can move along paths", () => {
|
|
466
|
+
const doc = (0, builders_1.scene)({
|
|
467
|
+
canvas: { width: 320, height: 180, duration: 2 },
|
|
468
|
+
elements: [
|
|
469
|
+
{ id: "route", type: "path", d: "M 20 120 C 90 20 180 160 300 60", fill: "none", stroke: "#2563eb" },
|
|
470
|
+
{ id: "dot", type: "circle", radius: 6, fill: "#ef4444", follow: "route", progress: (0, builders_1.animate)(0, 1, { duration: 2 }) }
|
|
471
|
+
]
|
|
472
|
+
});
|
|
473
|
+
const start = (0, src_1.resolveVisualFrame)(doc, 0).elements.find((item) => item.id === "dot");
|
|
474
|
+
const middle = (0, src_1.resolveVisualFrame)(doc, 1).elements.find((item) => item.id === "dot");
|
|
475
|
+
const end = (0, src_1.resolveVisualFrame)(doc, 2).elements.find((item) => item.id === "dot");
|
|
476
|
+
assert(near(start.cx, 20) && near(start.cy, 120), "path follower should start at path start");
|
|
477
|
+
assert(near(end.cx, 300) && near(end.cy, 60), "path follower should end at path end");
|
|
478
|
+
assert(middle.cx > 20 && middle.cx < 300, "path follower should move through sampled path");
|
|
479
|
+
});
|
|
480
|
+
test("circle followers sample lowered path-like authoring shapes", () => {
|
|
481
|
+
const doc = (0, builders_1.scene)({
|
|
482
|
+
canvas: { width: 320, height: 220 },
|
|
483
|
+
elements: [
|
|
484
|
+
{ id: "arc_route", type: "arc", cx: 90, cy: 100, radius: 50, startAngle: 0, endAngle: 180, stroke: "#2563eb" },
|
|
485
|
+
{ id: "arc_dot", type: "circle", radius: 4, fill: "#ef4444", follow: "arc_route", progress: 0.5 },
|
|
486
|
+
{ id: "poly_route", type: "polyline", points: [[180, 160], [220, 80], [280, 160]], stroke: "#16a34a" },
|
|
487
|
+
{ id: "poly_dot", type: "circle", radius: 4, fill: "#111827", follow: "poly_route", progress: 1 }
|
|
488
|
+
]
|
|
489
|
+
});
|
|
490
|
+
const frame = (0, src_1.resolveVisualFrame)(doc, 0);
|
|
491
|
+
const arcDot = frame.elements.find((item) => item.id === "arc_dot");
|
|
492
|
+
const polyDot = frame.elements.find((item) => item.id === "poly_dot");
|
|
493
|
+
assert(near(arcDot.cx, 90) && near(arcDot.cy, 150), `arc follower should sample the lowered arc path, got ${arcDot.cx},${arcDot.cy}`);
|
|
494
|
+
assert(near(polyDot.cx, 280) && near(polyDot.cy, 160), "polyline follower should sample lowered polyline path");
|
|
495
|
+
});
|
|
496
|
+
test("applies primitive id patches", () => {
|
|
497
|
+
const doc = (0, builders_1.scene)({
|
|
498
|
+
canvas: { width: 320, height: 180 },
|
|
499
|
+
elements: [{ id: "label", type: "text", text: "Old", x: 20, y: 20 }]
|
|
500
|
+
});
|
|
501
|
+
const patched = (0, src_1.applyVisualPatch)(doc, { op: "update", id: "label", set: { text: "New" } }).document;
|
|
502
|
+
const label = patched.elements?.find((item) => item.id === "label");
|
|
503
|
+
assert(label.text === "New", "patch should update by id");
|
|
504
|
+
});
|
|
505
|
+
test("resolves scenes and sequences", () => {
|
|
506
|
+
const doc = {
|
|
507
|
+
version: 1,
|
|
508
|
+
canvas: { width: 320, height: 180 },
|
|
509
|
+
scenes: {
|
|
510
|
+
intro: { elements: [{ id: "intro_title", type: "text", text: "Intro", x: 20, y: 20 }] },
|
|
511
|
+
outro: { elements: [{ id: "outro_title", type: "text", text: "Outro", x: 20, y: 20 }] }
|
|
512
|
+
},
|
|
513
|
+
sequences: {
|
|
514
|
+
main: { id: "main", clips: [{ scene: "intro", duration: 2 }, { scene: "outro", duration: 3 }] }
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
assert((0, src_1.documentForScene)(doc, "intro").elements?.[0]?.id === "intro_title", "scene should resolve");
|
|
518
|
+
assert((0, src_1.resolvedFrameForScene)(doc, "intro", 0).elements[0]?.id === "intro_title", "scene resolved frame should resolve");
|
|
519
|
+
assert((0, src_1.compileVisualSequence)(doc, "main").duration === 5, "sequence duration should compile");
|
|
520
|
+
const frame = (0, src_1.documentForSequenceTime)(doc, "main", 2.5);
|
|
521
|
+
assert(frame.scene === "outro" && frame.localTime === 0.5, "sequence frame should map global to local time");
|
|
522
|
+
assert((0, src_1.resolvedFrameForSequenceTime)(doc, "main", 2.5).document.elements[0]?.id === "outro_title", "sequence resolved frame should expose resolved authoring frame");
|
|
523
|
+
});
|
|
524
|
+
test("sequence fade transitions produce inspectable frames and timeline", () => {
|
|
525
|
+
const doc = {
|
|
526
|
+
version: 1,
|
|
527
|
+
canvas: { width: 320, height: 180, duration: 2, fps: 10 },
|
|
528
|
+
scenes: {
|
|
529
|
+
a: { elements: [{ id: "a_title", type: "text", text: "A", x: 20, y: 20 }] },
|
|
530
|
+
b: { elements: [{ id: "b_title", type: "text", text: "B", x: 20, y: 20 }] }
|
|
531
|
+
},
|
|
532
|
+
sequences: {
|
|
533
|
+
main: { id: "main", clips: [{ scene: "a", duration: 1 }, { scene: "b", duration: 1, transition: { type: "fade", duration: 0.5 } }] }
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
const frame = (0, src_1.documentForSequenceTime)(doc, "main", 1.25);
|
|
537
|
+
assert(frame.transition?.type === "fade", "frame should expose fade transition");
|
|
538
|
+
assert(frame.document.elements?.some((item) => item.id === "fade_from_a_title"), "fade should include previous primitive clone");
|
|
539
|
+
assert(frame.document.elements?.some((item) => item.id === "fade_to_b_title"), "fade should include next primitive clone");
|
|
540
|
+
assert((0, src_1.sequenceTimeline)(doc, "main", 10).some((item) => item.transition?.type === "fade"), "timeline should expose transition frames");
|
|
541
|
+
});
|
|
542
|
+
test("applies deck step visibility without mutating primitives", () => {
|
|
543
|
+
const doc = {
|
|
544
|
+
version: 1,
|
|
545
|
+
canvas: { width: 320, height: 180 },
|
|
546
|
+
scenes: {
|
|
547
|
+
slide: {
|
|
548
|
+
elements: [
|
|
549
|
+
{ id: "title", type: "text", text: "Title", x: 20, y: 20 },
|
|
550
|
+
{ id: "detail", type: "text", text: "Detail", x: 20, y: 60, opacity: 0 }
|
|
551
|
+
],
|
|
552
|
+
steps: [{ id: "show_detail", show: ["detail"] }]
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
const frame = (0, src_1.documentForDeckStep)(doc, "slide", 0);
|
|
557
|
+
const detail = frame.elements?.find((item) => item.id === "detail");
|
|
558
|
+
assert(detail?.opacity === 1, "deck step should show explicit ids");
|
|
559
|
+
assert((0, src_1.resolvedFrameForDeckStep)(doc, "slide", 0).elements.find((item) => item.id === "detail")?.opacity === 1, "deck resolved frame should expose step visibility");
|
|
560
|
+
assert((0, src_1.renderDeckToHtml)(doc, "slide").includes("Next"), "deck html should include controls");
|
|
561
|
+
});
|
|
562
|
+
test("reports visual diagnostics without moving elements", () => {
|
|
563
|
+
const doc = {
|
|
564
|
+
version: 1,
|
|
565
|
+
canvas: { width: 100, height: 100 },
|
|
566
|
+
elements: [
|
|
567
|
+
{ id: "outside", type: "rect", x: 80, y: 80, width: 40, height: 40 },
|
|
568
|
+
{ id: "panel", type: "rect", x: 10, y: 10, width: 50, height: 20, fill: "#ffffff" },
|
|
569
|
+
{ id: "low", type: "text", text: "Low contrast text", x: 35, y: 20, align: "center", valign: "middle", fill: "#eeeeee" }
|
|
570
|
+
]
|
|
571
|
+
};
|
|
572
|
+
const report = (0, src_1.lintVisualDocument)(doc);
|
|
573
|
+
assert(report.warnings.some((item) => item.code === "element_outside_canvas"), "should warn about off-canvas elements");
|
|
574
|
+
assert(report.warnings.some((item) => item.code === "low_text_contrast"), "should warn about contrast");
|
|
575
|
+
});
|
|
576
|
+
test("builds project symbol index and validates project scopes", () => {
|
|
577
|
+
const doc = {
|
|
578
|
+
version: 1,
|
|
579
|
+
canvas: { width: 320, height: 180 },
|
|
580
|
+
scenes: {
|
|
581
|
+
intro: { elements: [{ id: "title", type: "text", text: "Intro", x: 20, y: 20 }] }
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
const project = { document: doc, files: { "project.visual.json": doc }, symbols: [] };
|
|
585
|
+
project.symbols = (0, src_1.buildSymbolIndex)(project);
|
|
586
|
+
assert(project.symbols.some((symbol) => symbol.id === "title" && symbol.scene === "intro"), "symbol index should include scene ids");
|
|
587
|
+
assert((0, src_1.validateVisualProject)(project).ok, "project should validate");
|
|
588
|
+
});
|
|
589
|
+
test("renders structured three html", () => {
|
|
590
|
+
const doc = {
|
|
591
|
+
version: 1,
|
|
592
|
+
canvas: { width: 320, height: 180, space: "3d", renderer: "three", background: "#ffffff" },
|
|
593
|
+
elements: [{ id: "cube", type: "cuboid", position: [0, 0, 0], size: [1, 1, 1], fill: "#2563eb" }]
|
|
594
|
+
};
|
|
595
|
+
const html = (0, src_1.renderToThreeHtml)(doc);
|
|
596
|
+
assert(html.includes("THREE") && html.includes("mesh3d"), "should render three html from kernel mesh");
|
|
597
|
+
assert((0, src_1.renderToHtml)(doc).includes("THREE"), "html renderer should delegate to three");
|
|
598
|
+
assert((0, src_1.renderThreePreviewSvg)(doc).includes("<polygon"), "structured three should have deterministic svg preview");
|
|
599
|
+
});
|
|
600
|
+
test("renders raw three module escape hatch html outside primitive docs", () => {
|
|
601
|
+
const html = (0, src_1.renderRawThreeModuleHtml)({ width: 320, height: 180, moduleUrl: "./scene.js" });
|
|
602
|
+
assert(html.includes("createSketchmarkThreeScene"), "raw module html should use explicit module entrypoint");
|
|
603
|
+
});
|
|
604
|
+
test("all good examples validate and render", () => {
|
|
605
|
+
const examplesRoot = path.resolve(__dirname, "..", "..", "examples");
|
|
606
|
+
const files = collectVisualFiles(examplesRoot).filter((file) => !normalizePath(file).includes("/bad/"));
|
|
607
|
+
const featureFiles = files.filter((file) => normalizePath(file).includes("/features/"));
|
|
608
|
+
assert(featureFiles.length >= 20, "should include at least 20 feature examples");
|
|
609
|
+
for (const file of files) {
|
|
610
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
611
|
+
const doc = raw.imports ? (0, src_1.loadVisualProject)(file).document : raw;
|
|
612
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
613
|
+
const relative = normalizePath(path.relative(examplesRoot, file));
|
|
614
|
+
assert(result.ok, `${relative} should validate: ${result.issues.map((issue) => issue.message).join("; ")}`);
|
|
615
|
+
const frame = raw.sequences ? (0, src_1.documentForSequenceTime)(doc, Object.keys(raw.sequences)[0], 0).document : raw.scenes ? (0, src_1.documentForScene)(doc, Object.keys(raw.scenes)[0]) : doc;
|
|
616
|
+
const output = frame.canvas.renderer === "three" ? (0, src_1.renderThreePreviewSvg)(frame) : (0, src_1.renderToSvg)(frame);
|
|
617
|
+
assert(output.includes("<svg"), `${relative} should render an SVG preview`);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
console.log("All primitive visual language tests passed.");
|
|
621
|
+
function assert(condition, message) {
|
|
622
|
+
if (!condition)
|
|
623
|
+
throw new Error(message);
|
|
624
|
+
}
|
|
625
|
+
function collectVisualFiles(directory) {
|
|
626
|
+
const out = [];
|
|
627
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
628
|
+
const fullPath = path.join(directory, entry.name);
|
|
629
|
+
if (entry.isDirectory())
|
|
630
|
+
out.push(...collectVisualFiles(fullPath));
|
|
631
|
+
else if (entry.name.endsWith(".visual.json"))
|
|
632
|
+
out.push(fullPath);
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
function normalizePath(value) {
|
|
637
|
+
return value.replace(/\\/g, "/");
|
|
638
|
+
}
|
|
639
|
+
function near(left, right) {
|
|
640
|
+
return Math.abs(left - right) < 0.0001;
|
|
641
|
+
}
|
|
642
|
+
function stableStringify(value) {
|
|
643
|
+
return JSON.stringify(sortJson(value));
|
|
644
|
+
}
|
|
645
|
+
function sortJson(value) {
|
|
646
|
+
if (Array.isArray(value))
|
|
647
|
+
return value.map(sortJson);
|
|
648
|
+
if (!value || typeof value !== "object")
|
|
649
|
+
return value;
|
|
650
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sortJson(item)]));
|
|
651
|
+
}
|