sketchmark 2.0.0 → 2.1.1
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 +274 -188
- package/bin/editor-ui.cjs +2285 -0
- package/bin/preview-ui.cjs +74 -0
- package/bin/sketchmark.cjs +648 -2008
- package/dist/src/animatable.d.ts +21 -0
- package/dist/src/animatable.js +439 -0
- package/dist/src/builders/index.d.ts +1 -11
- package/dist/src/builders/index.js +1 -19
- package/dist/src/diagnostics.js +1 -64
- package/dist/src/edit.d.ts +27 -0
- package/dist/src/edit.js +162 -0
- package/dist/src/index.d.ts +4 -13
- package/dist/src/index.js +4 -13
- package/dist/src/keyframes.d.ts +48 -0
- package/dist/src/keyframes.js +182 -0
- package/dist/src/motion.d.ts +4 -0
- package/dist/src/motion.js +262 -0
- package/dist/src/normalize.js +120 -151
- package/dist/src/presets/characters.d.ts +15 -0
- package/dist/src/presets/characters.js +113 -0
- package/dist/src/presets/compose.d.ts +5 -0
- package/dist/src/presets/compose.js +80 -0
- package/dist/src/presets/effects.d.ts +40 -0
- package/dist/src/presets/effects.js +79 -0
- package/dist/src/presets/helpers.d.ts +33 -0
- package/dist/src/presets/helpers.js +165 -0
- package/dist/src/presets/index.d.ts +9 -0
- package/dist/src/presets/index.js +48 -0
- package/dist/src/presets/motions.d.ts +33 -0
- package/dist/src/presets/motions.js +75 -0
- package/dist/src/presets/scenes.d.ts +35 -0
- package/dist/src/presets/scenes.js +134 -0
- package/dist/src/presets/shapes.d.ts +71 -0
- package/dist/src/presets/shapes.js +96 -0
- package/dist/src/presets/transitions.d.ts +29 -0
- package/dist/src/presets/transitions.js +113 -0
- package/dist/src/presets/types.d.ts +34 -0
- package/dist/src/presets/types.js +2 -0
- package/dist/src/render/html.js +1 -4
- package/dist/src/render/svg.d.ts +2 -2
- package/dist/src/render/svg.js +86 -82
- package/dist/src/render/three-html.js +67 -113
- package/dist/src/scenes.js +1 -0
- package/dist/src/schema.js +218 -280
- package/dist/src/shapes/builtins.js +11 -47
- package/dist/src/shapes/common.js +12 -11
- package/dist/src/shapes/registry.d.ts +0 -1
- package/dist/src/shapes/registry.js +0 -4
- package/dist/src/shapes/types.d.ts +1 -3
- package/dist/src/types.d.ts +57 -288
- package/dist/src/utils.d.ts +2 -11
- package/dist/src/utils.js +13 -70
- package/dist/src/validate.js +321 -275
- package/dist/tests/run.js +576 -510
- package/package.json +46 -52
- package/schema/visual.schema.json +1086 -930
package/dist/tests/run.js
CHANGED
|
@@ -3,8 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
const fs = require("node:fs");
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
const src_1 = require("../src");
|
|
6
|
-
const
|
|
7
|
-
const builders_1 = require("../src/builders");
|
|
6
|
+
const presets_1 = require("../src/presets");
|
|
8
7
|
function test(name, fn) {
|
|
9
8
|
try {
|
|
10
9
|
fn();
|
|
@@ -16,629 +15,696 @@ function test(name, fn) {
|
|
|
16
15
|
throw error;
|
|
17
16
|
}
|
|
18
17
|
}
|
|
19
|
-
test("validates
|
|
18
|
+
test("validates minimal render-kernel documents", () => {
|
|
20
19
|
const doc = {
|
|
21
20
|
version: 1,
|
|
22
|
-
canvas: { width:
|
|
23
|
-
elements: [
|
|
21
|
+
canvas: { width: 320, height: 180, background: "#ffffff" },
|
|
22
|
+
elements: [
|
|
23
|
+
{ id: "line", type: "path", d: "M 20 120 L 300 40", stroke: "#111827", strokeWidth: 4 },
|
|
24
|
+
{ id: "label", type: "text", text: "Kernel", x: 160, y: 90, align: "center", valign: "middle" },
|
|
25
|
+
{ id: "pin", type: "point", x: 20, y: 120 }
|
|
26
|
+
]
|
|
24
27
|
};
|
|
25
28
|
const result = (0, src_1.validateVisualDocument)(doc);
|
|
26
|
-
assert(result.ok,
|
|
29
|
+
assert(result.ok, `document should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
27
30
|
});
|
|
28
|
-
test("rejects
|
|
31
|
+
test("rejects non-kernel fields and types", () => {
|
|
29
32
|
const doc = {
|
|
30
33
|
version: 1,
|
|
31
|
-
canvas: { width:
|
|
32
|
-
elements: [
|
|
34
|
+
canvas: { width: 320, height: 180, space: "3d", renderer: "three" },
|
|
35
|
+
elements: [
|
|
36
|
+
{ id: "box", type: "rect", x: 20, y: 20, width: 80, height: 40 },
|
|
37
|
+
{ id: "cube", type: "cuboid", position: [0, 0, 0], size: [1, 1, 1] }
|
|
38
|
+
],
|
|
39
|
+
motion: { drivers: [] },
|
|
40
|
+
scenes: {},
|
|
41
|
+
imports: {},
|
|
42
|
+
assets: {},
|
|
43
|
+
exports: {}
|
|
33
44
|
};
|
|
34
45
|
const result = (0, src_1.validateVisualDocument)(doc);
|
|
35
46
|
assert(!result.ok, "document should be invalid");
|
|
36
|
-
assert(result.issues.some((item) => item.code === "
|
|
37
|
-
|
|
38
|
-
|
|
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");
|
|
47
|
+
assert(result.issues.some((item) => item.code === "non_kernel_field" && item.path === "/motion"), "motion should be rejected");
|
|
48
|
+
assert(result.issues.some((item) => item.code === "non_kernel_canvas_field" && item.path === "/canvas/space"), "3D canvas fields should be rejected");
|
|
49
|
+
assert(result.issues.some((item) => item.code === "unsupported_type" && item.path.endsWith("/type")), "non-kernel element types should be rejected");
|
|
48
50
|
});
|
|
49
|
-
test("
|
|
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", () => {
|
|
51
|
+
test("rejects non-kernel fields on supported elements", () => {
|
|
78
52
|
const doc = {
|
|
79
53
|
version: 1,
|
|
80
|
-
canvas: { width:
|
|
81
|
-
elements: [{ id: "
|
|
54
|
+
canvas: { width: 320, height: 180 },
|
|
55
|
+
elements: [{ id: "old_line", type: "path", d: "M 0 0 L 10 10", from: [0, 0], to: [10, 10] }]
|
|
82
56
|
};
|
|
83
57
|
const result = (0, src_1.validateVisualDocument)(doc);
|
|
84
|
-
assert(!result.ok, "
|
|
85
|
-
assert(result.issues.some((item) => item.code === "
|
|
58
|
+
assert(!result.ok, "extra authoring fields should be invalid");
|
|
59
|
+
assert(result.issues.some((item) => item.code === "non_kernel_element_field" && item.path.endsWith("/from")), "should reject old element fields");
|
|
60
|
+
});
|
|
61
|
+
test("rejects project/editor metadata and image cornerRadius", () => {
|
|
62
|
+
const metadataDoc = {
|
|
63
|
+
version: 1,
|
|
64
|
+
canvas: { width: 320, height: 180 },
|
|
65
|
+
elements: [{ id: "dot", type: "point", x: 10, y: 10, metadata: { selected: true } }]
|
|
66
|
+
};
|
|
67
|
+
const metadata = (0, src_1.validateVisualDocument)(metadataDoc);
|
|
68
|
+
assert(!metadata.ok, "metadata should not be a kernel element field");
|
|
69
|
+
assert(metadata.issues.some((item) => item.code === "non_kernel_element_field" && item.path.endsWith("/metadata")), "metadata should be rejected");
|
|
70
|
+
const src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3C/svg%3E";
|
|
71
|
+
const roundedDoc = {
|
|
72
|
+
version: 1,
|
|
73
|
+
canvas: { width: 320, height: 180 },
|
|
74
|
+
elements: [{ id: "photo", type: "image", src, x: 20, y: 20, width: 80, height: 50, cornerRadius: 8 }]
|
|
75
|
+
};
|
|
76
|
+
const rounded = (0, src_1.validateVisualDocument)(roundedDoc);
|
|
77
|
+
assert(!rounded.ok, "image cornerRadius should not be a kernel field");
|
|
78
|
+
assert(rounded.issues.some((item) => item.code === "non_kernel_element_field" && item.path.endsWith("/cornerRadius")), "cornerRadius should be rejected");
|
|
79
|
+
const clipDoc = {
|
|
80
|
+
version: 1,
|
|
81
|
+
canvas: { width: 320, height: 180 },
|
|
82
|
+
elements: [{ id: "photo", type: "image", src, x: 20, y: 20, width: 80, height: 50, clip: (0, src_1.imageRoundedClip)({ x: 20, y: 20, width: 80, height: 50 }, 8) }]
|
|
83
|
+
};
|
|
84
|
+
assert((0, src_1.validateVisualDocument)(clipDoc).ok, "rounded image clips should compile through clip.d");
|
|
86
85
|
});
|
|
87
|
-
test("
|
|
86
|
+
test("renders path, text, image, and group to SVG and HTML", () => {
|
|
87
|
+
const src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='10' height='10' fill='%232563eb'/%3E%3C/svg%3E";
|
|
88
88
|
const doc = {
|
|
89
89
|
version: 1,
|
|
90
|
-
canvas: { width:
|
|
90
|
+
canvas: { width: 240, height: 140, background: "#f8fafc" },
|
|
91
91
|
elements: [
|
|
92
92
|
{
|
|
93
|
-
id: "
|
|
94
|
-
type: "
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
id: "card",
|
|
94
|
+
type: "group",
|
|
95
|
+
x: 20,
|
|
96
|
+
y: 20,
|
|
97
|
+
width: 200,
|
|
98
|
+
height: 100,
|
|
99
|
+
children: [
|
|
100
|
+
{ id: "panel", type: "path", d: "M 0 0 H 200 V 100 H 0 Z", fill: "#ffffff", stroke: "#cbd5e1" },
|
|
101
|
+
{ id: "photo", type: "image", src, x: 16, y: 16, width: 40, height: 40 },
|
|
102
|
+
{ id: "copy", type: "text", text: "Render kernel", x: 116, y: 38, align: "center", valign: "middle" }
|
|
103
|
+
]
|
|
99
104
|
}
|
|
100
105
|
]
|
|
101
106
|
};
|
|
102
|
-
const
|
|
103
|
-
assert(
|
|
104
|
-
assert(
|
|
107
|
+
const svg = (0, src_1.renderToSvg)(doc);
|
|
108
|
+
assert(svg.includes("<svg"), "should render SVG root");
|
|
109
|
+
assert(svg.includes("<path") && svg.includes("<image") && svg.includes("Render kernel"), "should render kernel elements");
|
|
110
|
+
assert(svg.includes('stroke-width="1"'), "path stroke should default to width 1 when stroke is set");
|
|
111
|
+
assert((0, src_1.renderToHtml)(doc).includes("Sketchmark Kernel Visual"), "should render HTML shell");
|
|
105
112
|
});
|
|
106
|
-
test("
|
|
113
|
+
test("resolves element-local timeline tracks", () => {
|
|
107
114
|
const doc = {
|
|
108
115
|
version: 1,
|
|
109
|
-
canvas: { width:
|
|
116
|
+
canvas: { width: 320, height: 180, duration: 3 },
|
|
110
117
|
elements: [
|
|
111
118
|
{
|
|
112
|
-
id: "
|
|
113
|
-
type: "
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
id: "dot",
|
|
120
|
+
type: "point",
|
|
121
|
+
x: 0,
|
|
122
|
+
y: 0,
|
|
123
|
+
timeline: {
|
|
124
|
+
start: 1,
|
|
125
|
+
end: 3,
|
|
126
|
+
tracks: {
|
|
127
|
+
position: { keyframes: [[0, [20, 30]], [2, [120, 90]]], ease: "linear" },
|
|
128
|
+
opacity: { keyframes: [[0, 0], [1, 1]] }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "mark",
|
|
134
|
+
type: "path",
|
|
135
|
+
d: "M 10 10 L 30 10",
|
|
136
|
+
stroke: "#000000",
|
|
137
|
+
timeline: {
|
|
138
|
+
tracks: {
|
|
139
|
+
stroke: { keyframes: [[0, "#000000"], [2, "#ffffff"]], ease: "linear" }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
118
142
|
}
|
|
119
143
|
]
|
|
120
144
|
};
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
145
|
+
assert((0, src_1.resolveVisualFrame)(doc, 0.5).elements.every((item) => item.id !== "dot"), "timeline start should hide early elements");
|
|
146
|
+
const mid = (0, src_1.resolveVisualFrame)(doc, 2);
|
|
147
|
+
const dot = mid.elements.find((item) => item.id === "dot");
|
|
148
|
+
const mark = mid.elements.find((item) => item.id === "mark");
|
|
149
|
+
assert(near(dot.x, 70) && near(dot.y, 60), `position should interpolate, got ${dot.x},${dot.y}`);
|
|
150
|
+
assert(dot.opacity === 1, "opacity should clamp after the last keyframe");
|
|
151
|
+
assert(mark.stroke === "#ffffff", `color should interpolate/clamp, got ${mark.stroke}`);
|
|
152
|
+
assert(!("timeline" in dot), "resolved frame should not keep timeline instructions");
|
|
153
|
+
});
|
|
154
|
+
test("resolves timeline interpolation graphs", () => {
|
|
126
155
|
const doc = {
|
|
127
156
|
version: 1,
|
|
128
|
-
canvas: {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
type: "
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
157
|
+
canvas: { width: 320, height: 180, duration: 1 },
|
|
158
|
+
elements: [
|
|
159
|
+
{
|
|
160
|
+
id: "graph",
|
|
161
|
+
type: "point",
|
|
162
|
+
x: 0,
|
|
163
|
+
y: 0,
|
|
164
|
+
timeline: {
|
|
165
|
+
tracks: {
|
|
166
|
+
position: {
|
|
167
|
+
keyframes: [[0, [0, 0]], [1, [100, 0]]],
|
|
168
|
+
curve: { type: "graph", points: [[0, 0], [0.5, 0], [1, 1]] }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "hold",
|
|
175
|
+
type: "point",
|
|
176
|
+
x: 0,
|
|
177
|
+
y: 20,
|
|
178
|
+
timeline: {
|
|
179
|
+
tracks: {
|
|
180
|
+
position: {
|
|
181
|
+
keyframes: [[0, [0, 20]], [1, [100, 20]]],
|
|
182
|
+
curve: { type: "hold" }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
136
186
|
}
|
|
137
|
-
|
|
138
|
-
elements: []
|
|
187
|
+
]
|
|
139
188
|
};
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
});
|
|
144
|
-
|
|
189
|
+
const mid = (0, src_1.resolveVisualFrame)(doc, 0.5);
|
|
190
|
+
const graph = mid.elements.find((item) => item.id === "graph");
|
|
191
|
+
const hold = mid.elements.find((item) => item.id === "hold");
|
|
192
|
+
assert(near(graph.x, 0), `graph curve should map halfway time to zero progress, got ${graph.x}`);
|
|
193
|
+
assert(near(hold.x, 0), `hold curve should stay at previous value, got ${hold.x}`);
|
|
194
|
+
const late = (0, src_1.resolveVisualFrame)(doc, 0.75).elements.find((item) => item.id === "graph");
|
|
195
|
+
assert(near(late.x, 50), `graph curve should interpolate along normalized graph, got ${late.x}`);
|
|
196
|
+
});
|
|
197
|
+
test("resolves object keyframes and per-segment curves", () => {
|
|
145
198
|
const doc = {
|
|
146
199
|
version: 1,
|
|
147
|
-
canvas: { width:
|
|
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" },
|
|
200
|
+
canvas: { width: 320, height: 180, duration: 2 },
|
|
181
201
|
elements: [
|
|
182
|
-
{
|
|
183
|
-
|
|
202
|
+
{
|
|
203
|
+
id: "dot",
|
|
204
|
+
type: "point",
|
|
205
|
+
x: 0,
|
|
206
|
+
y: 0,
|
|
207
|
+
timeline: {
|
|
208
|
+
tracks: {
|
|
209
|
+
position: {
|
|
210
|
+
curve: { type: "graph", points: [[0, 0], [1, 1]] },
|
|
211
|
+
keyframes: [
|
|
212
|
+
{ time: 0, value: [0, 0], out: { type: "hold" } },
|
|
213
|
+
{ time: 1, value: [100, 0] },
|
|
214
|
+
{ time: 2, value: [200, 0] }
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
184
220
|
]
|
|
185
|
-
}
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
assert(
|
|
189
|
-
assert(
|
|
221
|
+
};
|
|
222
|
+
const held = (0, src_1.resolveVisualFrame)(doc, 0.5).elements[0];
|
|
223
|
+
const linear = (0, src_1.resolveVisualFrame)(doc, 1.5).elements[0];
|
|
224
|
+
assert(near(held.x, 0), `object keyframe out curve should override track curve, got ${held.x}`);
|
|
225
|
+
assert(near(linear.x, 150), `track curve should apply when segment has no keyframe curve, got ${linear.x}`);
|
|
190
226
|
});
|
|
191
|
-
test("
|
|
192
|
-
const
|
|
193
|
-
|
|
227
|
+
test("resolves nested animatable property tracks", () => {
|
|
228
|
+
const imageSrc = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23fff'/%3E%3C/svg%3E";
|
|
229
|
+
const doc = {
|
|
230
|
+
version: 1,
|
|
231
|
+
canvas: { width: 320, height: 180, duration: 2 },
|
|
194
232
|
elements: [
|
|
195
233
|
{
|
|
196
|
-
id: "
|
|
197
|
-
type: "
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
234
|
+
id: "shape",
|
|
235
|
+
type: "path",
|
|
236
|
+
d: "M 0 0 H 100 V 100 H 0 Z",
|
|
237
|
+
fill: { type: "linearGradient", from: [0, 0], to: [100, 0], stops: [[0, "#000000"], [1, "#ffffff"]] },
|
|
238
|
+
stroke: "#000000",
|
|
239
|
+
dashArray: [0, 10],
|
|
240
|
+
timeline: {
|
|
241
|
+
tracks: {
|
|
242
|
+
"fill.to": { keyframes: [[0, [100, 0]], [2, [100, 100]]], ease: "linear" },
|
|
243
|
+
"fill.stops.0.color": { keyframes: [[0, "#000000"], [2, "#ffffff"]], ease: "linear" },
|
|
244
|
+
"effects.blur": { keyframes: [[0, 0], [2, 10]], ease: "linear" },
|
|
245
|
+
"effects.shadow.opacity": { keyframes: [[0, 1], [2, 0]], ease: "linear" },
|
|
246
|
+
"mask.opacity": { keyframes: [[0, 1], [2, 0]], ease: "linear" },
|
|
247
|
+
dashArray: { keyframes: [[0, [0, 10]], [2, [10, 20]]], ease: "linear" }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
208
250
|
},
|
|
209
251
|
{
|
|
210
|
-
id: "
|
|
211
|
-
type: "
|
|
212
|
-
|
|
213
|
-
x:
|
|
214
|
-
y:
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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" }
|
|
252
|
+
id: "photo",
|
|
253
|
+
type: "image",
|
|
254
|
+
src: imageSrc,
|
|
255
|
+
x: 120,
|
|
256
|
+
y: 20,
|
|
257
|
+
width: 100,
|
|
258
|
+
height: 100,
|
|
259
|
+
timeline: {
|
|
260
|
+
tracks: {
|
|
261
|
+
"source.width": { keyframes: [[0, 100], [2, 50]], ease: "linear" }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
225
265
|
]
|
|
226
|
-
}
|
|
266
|
+
};
|
|
227
267
|
const result = (0, src_1.validateVisualDocument)(doc);
|
|
228
|
-
assert(result.ok, `
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
assert(
|
|
233
|
-
assert(
|
|
234
|
-
assert(
|
|
235
|
-
assert(
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
268
|
+
assert(result.ok, `nested animatable tracks should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
269
|
+
const mid = (0, src_1.resolveVisualFrame)(doc, 1);
|
|
270
|
+
const shape = mid.elements.find((item) => item.id === "shape");
|
|
271
|
+
const photo = mid.elements.find((item) => item.id === "photo");
|
|
272
|
+
assert(shape.fill.to[1] === 50, `linear gradient endpoint should interpolate, got ${shape.fill.to}`);
|
|
273
|
+
assert(shape.fill.stops[0][1] === "#808080", `gradient color stop should interpolate, got ${shape.fill.stops[0][1]}`);
|
|
274
|
+
assert(shape.effects.blur === 5, `effects.blur should interpolate, got ${shape.effects.blur}`);
|
|
275
|
+
assert(shape.effects.shadow.opacity === 0.5, `shadow opacity should interpolate, got ${shape.effects.shadow.opacity}`);
|
|
276
|
+
assert(shape.mask.opacity === 0.5, `mask opacity should interpolate, got ${shape.mask.opacity}`);
|
|
277
|
+
assert(shape.dashArray[0] === 5 && shape.dashArray[1] === 15, `dash arrays should interpolate, got ${shape.dashArray}`);
|
|
278
|
+
assert(photo.source.width === 75, `image source crop should interpolate, got ${photo.source.width}`);
|
|
279
|
+
});
|
|
280
|
+
test("rejects unknown tracks and invalid known track values", () => {
|
|
281
|
+
const unknownDoc = {
|
|
282
|
+
version: 1,
|
|
283
|
+
canvas: { width: 320, height: 180 },
|
|
240
284
|
elements: [
|
|
241
285
|
{
|
|
242
|
-
id: "
|
|
243
|
-
type: "
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
}
|
|
286
|
+
id: "dot",
|
|
287
|
+
type: "point",
|
|
288
|
+
x: 0,
|
|
289
|
+
y: 0,
|
|
290
|
+
timeline: { tracks: { "future.magic": { keyframes: [{ time: 0, value: { enabled: true, amount: 1 } }] } } }
|
|
253
291
|
}
|
|
254
292
|
]
|
|
255
|
-
}
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
assert(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const doc = (0, builders_1.scene)({
|
|
263
|
-
canvas: { width: 240, height: 120 },
|
|
293
|
+
};
|
|
294
|
+
const unknown = (0, src_1.validateVisualDocument)(unknownDoc);
|
|
295
|
+
assert(!unknown.ok, "unknown compatibility track should be invalid in the frozen kernel");
|
|
296
|
+
assert(unknown.issues.some((item) => item.code === "unknown_timeline_track"), "unknown compatibility track should be rejected");
|
|
297
|
+
const invalidKnown = {
|
|
298
|
+
version: 1,
|
|
299
|
+
canvas: { width: 320, height: 180 },
|
|
264
300
|
elements: [
|
|
265
|
-
{
|
|
266
|
-
|
|
301
|
+
{
|
|
302
|
+
id: "shape",
|
|
303
|
+
type: "path",
|
|
304
|
+
d: "M 0 0 L 100 0",
|
|
305
|
+
timeline: { tracks: { "effects.blur": { keyframes: [[0, "#fff"]] } } }
|
|
306
|
+
}
|
|
267
307
|
]
|
|
268
|
-
}
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
assert(
|
|
308
|
+
};
|
|
309
|
+
const invalid = (0, src_1.validateVisualDocument)(invalidKnown);
|
|
310
|
+
assert(!invalid.ok, "invalid value for a known supported property should fail");
|
|
311
|
+
assert(invalid.issues.some((item) => item.code === "invalid_timeline_value_for_property"), "known property value mismatch should be reported");
|
|
272
312
|
});
|
|
273
|
-
test("
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
canvas: { width:
|
|
313
|
+
test("resolves newly supported animatable kernel properties", () => {
|
|
314
|
+
const patternSrc = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='10' height='10' fill='%23fff'/%3E%3C/svg%3E";
|
|
315
|
+
const doc = {
|
|
316
|
+
version: 1,
|
|
317
|
+
canvas: { width: 320, height: 180, duration: 1 },
|
|
278
318
|
elements: [
|
|
279
319
|
{
|
|
280
|
-
id: "
|
|
281
|
-
type: "
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 }
|
|
320
|
+
id: "path",
|
|
321
|
+
type: "path",
|
|
322
|
+
d: "M 0 0 L 20 0",
|
|
323
|
+
stroke: "#000000",
|
|
324
|
+
timeline: {
|
|
325
|
+
tracks: {
|
|
326
|
+
d: { keyframes: [[0, "M 0 0 L 20 0"], [1, "M 0 0 L 40 40"]] }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
301
329
|
},
|
|
302
330
|
{
|
|
303
|
-
id: "
|
|
304
|
-
type: "
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
331
|
+
id: "text",
|
|
332
|
+
type: "text",
|
|
333
|
+
text: "A",
|
|
334
|
+
x: 10,
|
|
335
|
+
y: 20,
|
|
336
|
+
align: "left",
|
|
337
|
+
valign: "top",
|
|
338
|
+
timeline: {
|
|
339
|
+
tracks: {
|
|
340
|
+
align: { keyframes: [[0, "left"], [1, "right"]] },
|
|
341
|
+
valign: { keyframes: [[0, "top"], [1, "bottom"]] },
|
|
342
|
+
fontStyle: { keyframes: [[0, "normal"], [1, "italic"]] }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
314
345
|
},
|
|
315
346
|
{
|
|
316
|
-
id: "
|
|
317
|
-
type: "
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
347
|
+
id: "pattern",
|
|
348
|
+
type: "path",
|
|
349
|
+
d: "M 0 0 H 100 V 100 H 0 Z",
|
|
350
|
+
fill: { type: "pattern", src: patternSrc, x: 0, y: 0, width: 10, height: 10, opacity: 1 },
|
|
351
|
+
timeline: {
|
|
352
|
+
tracks: {
|
|
353
|
+
"fill.x": { keyframes: [[0, 0], [1, 12]] },
|
|
354
|
+
"fill.width": { keyframes: [[0, 10], [1, 20]] },
|
|
355
|
+
"fill.opacity": { keyframes: [[0, 1], [1, 0.5]] }
|
|
356
|
+
}
|
|
357
|
+
}
|
|
326
358
|
},
|
|
327
359
|
{
|
|
328
|
-
id: "
|
|
329
|
-
type: "
|
|
330
|
-
|
|
331
|
-
fill: "#
|
|
332
|
-
|
|
333
|
-
|
|
360
|
+
id: "paint",
|
|
361
|
+
type: "path",
|
|
362
|
+
d: "M 0 0 H 10 V 10 H 0 Z",
|
|
363
|
+
fill: "#000000",
|
|
364
|
+
timeline: {
|
|
365
|
+
tracks: {
|
|
366
|
+
fill: {
|
|
367
|
+
keyframes: [
|
|
368
|
+
[0, "#000000"],
|
|
369
|
+
[1, { type: "linearGradient", from: [0, 0], to: [10, 0], stops: [[0, "#000000"], [1, "#ffffff"]] }]
|
|
370
|
+
]
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
334
374
|
}
|
|
335
375
|
]
|
|
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
376
|
};
|
|
389
377
|
const result = (0, src_1.validateVisualDocument)(doc);
|
|
390
|
-
assert(
|
|
391
|
-
|
|
392
|
-
});
|
|
393
|
-
|
|
378
|
+
assert(result.ok, `new animatable properties should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
379
|
+
const midPath = (0, src_1.resolveVisualFrame)(doc, 0.5).elements.find((item) => item.id === "path");
|
|
380
|
+
assert(midPath.d === "M 0 0 L 20 0", `path.d should be discrete before the target keyframe, got ${midPath.d}`);
|
|
381
|
+
const end = (0, src_1.resolveVisualFrame)(doc, 1);
|
|
382
|
+
const text = end.elements.find((item) => item.id === "text");
|
|
383
|
+
const pattern = end.elements.find((item) => item.id === "pattern");
|
|
384
|
+
const paint = end.elements.find((item) => item.id === "paint");
|
|
385
|
+
assert(text.align === "right" && text.valign === "bottom" && text.fontStyle === "italic", "text layout/style tracks should resolve discretely");
|
|
386
|
+
assert(pattern.fill.x === 12 && pattern.fill.width === 20 && pattern.fill.opacity === 0.5, "pattern internals should resolve");
|
|
387
|
+
assert(paint.fill.type === "linearGradient", "whole paint tracks should switch structured paints");
|
|
388
|
+
});
|
|
389
|
+
test("warns about overlapping timeline representations", () => {
|
|
394
390
|
const doc = {
|
|
395
391
|
version: 1,
|
|
396
|
-
canvas: { width:
|
|
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 },
|
|
392
|
+
canvas: { width: 320, height: 180 },
|
|
424
393
|
elements: [
|
|
425
394
|
{
|
|
426
|
-
id: "
|
|
427
|
-
type: "
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
x: (0, builders_1.animate)(20, 120, { duration: 2 }),
|
|
435
|
-
opacity: (0, builders_1.animate)(0.4, 1, { duration: 2 })
|
|
395
|
+
id: "shape",
|
|
396
|
+
type: "path",
|
|
397
|
+
d: "M 0 0 L 100 0",
|
|
398
|
+
timeline: {
|
|
399
|
+
tracks: {
|
|
400
|
+
position: { keyframes: [[0, [0, 0]]] },
|
|
401
|
+
x: { keyframes: [[0, 10]] }
|
|
402
|
+
}
|
|
436
403
|
}
|
|
437
404
|
}
|
|
438
405
|
]
|
|
439
|
-
}
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
assert(
|
|
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");
|
|
406
|
+
};
|
|
407
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
408
|
+
assert(result.ok, "overlapping tracks should remain valid");
|
|
409
|
+
assert(result.warnings.some((item) => item.code === "conflicting_timeline_tracks"), "overlapping tracks should warn");
|
|
445
410
|
});
|
|
446
|
-
test("
|
|
411
|
+
test("compiles visual keyframe states to kernel timelines", () => {
|
|
447
412
|
const doc = {
|
|
448
413
|
version: 1,
|
|
449
|
-
canvas: { width: 320, height: 180,
|
|
414
|
+
canvas: { width: 320, height: 180, duration: 1 },
|
|
450
415
|
elements: [
|
|
451
|
-
{
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
416
|
+
{
|
|
417
|
+
id: "card",
|
|
418
|
+
type: "group",
|
|
419
|
+
x: 20,
|
|
420
|
+
y: 80,
|
|
421
|
+
width: 80,
|
|
422
|
+
height: 40,
|
|
423
|
+
children: [{ id: "card_bg", type: "path", d: "M 0 0 H 80 V 40 H 0 Z", fill: "#ffffff" }]
|
|
424
|
+
}
|
|
457
425
|
]
|
|
458
426
|
};
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
assert(
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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", () => {
|
|
427
|
+
const animated = (0, src_1.compileKeyframeStates)(doc, [
|
|
428
|
+
{ time: 1, set: { card: { position: [220, 80], scale: 1.2, opacity: 0.5 } }, ease: "linear" }
|
|
429
|
+
]);
|
|
430
|
+
const result = (0, src_1.validateVisualDocument)(animated);
|
|
431
|
+
assert(result.ok, `compiled keyframes should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
432
|
+
const card = animated.elements?.[0];
|
|
433
|
+
assert(card.timeline.tracks.position.keyframes.length === 2, "compiler should include the base position keyframe");
|
|
434
|
+
assert(card.timeline.tracks.position.keyframes[0].value[0] === 20, "base position should come from the element");
|
|
435
|
+
assert(card.timeline.tracks.position.keyframes[0].out?.type === "graph", "named linear ease should compile to an explicit graph curve");
|
|
436
|
+
const mid = (0, src_1.resolveVisualFrame)(animated, 0.5).elements[0];
|
|
437
|
+
assert(near(mid.x, 120) && near(mid.scale, 1.1) && near(mid.opacity, 0.75), `compiled timeline should interpolate, got ${mid.x}, ${mid.scale}, ${mid.opacity}`);
|
|
438
|
+
});
|
|
439
|
+
test("compiles per-property curves and timing offsets", () => {
|
|
506
440
|
const doc = {
|
|
507
441
|
version: 1,
|
|
508
|
-
canvas: { width: 320, height: 180 },
|
|
509
|
-
|
|
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
|
-
}
|
|
442
|
+
canvas: { width: 320, height: 180, duration: 2 },
|
|
443
|
+
elements: [{ id: "dot", type: "point", x: 0, y: 0 }]
|
|
516
444
|
};
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
445
|
+
const animated = (0, src_1.compileKeyframeStates)(doc, [
|
|
446
|
+
{
|
|
447
|
+
time: 1,
|
|
448
|
+
set: {
|
|
449
|
+
dot: {
|
|
450
|
+
position: {
|
|
451
|
+
value: [100, 0],
|
|
452
|
+
curve: { type: "hold" },
|
|
453
|
+
offset: 0.25
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
], { offsets: { dot: { position: 0.25 } } });
|
|
459
|
+
const dot = animated.elements?.[0];
|
|
460
|
+
const keyframes = dot.timeline.tracks.position.keyframes;
|
|
461
|
+
assert(keyframes[0].time === 0 && keyframes[0].out.type === "hold", "base keyframe should receive the property curve");
|
|
462
|
+
assert(keyframes[1].time === 1.5, `global and property offsets should shift the target keyframe, got ${keyframes[1].time}`);
|
|
463
|
+
assert(near((0, src_1.resolveVisualFrame)(animated, 1).elements[0].x, 0), "hold curve should keep the point at the base value before the target");
|
|
464
|
+
});
|
|
465
|
+
test("edits nested element properties and timeline keyframes", () => {
|
|
525
466
|
const doc = {
|
|
526
467
|
version: 1,
|
|
527
|
-
canvas: { width: 320, height: 180, duration: 2
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
468
|
+
canvas: { width: 320, height: 180, duration: 2 },
|
|
469
|
+
elements: [
|
|
470
|
+
{
|
|
471
|
+
id: "root",
|
|
472
|
+
type: "group",
|
|
473
|
+
x: 10,
|
|
474
|
+
y: 20,
|
|
475
|
+
children: [{ id: "nested", type: "point", x: 5, y: 6 }]
|
|
476
|
+
}
|
|
477
|
+
]
|
|
535
478
|
};
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
assert(
|
|
539
|
-
assert(
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
479
|
+
assert((0, src_1.listElementReferences)(doc).some((item) => item.id === "nested" && item.depth === 1), "nested element should be listed");
|
|
480
|
+
const moved = (0, src_1.setElementProperty)(doc, "nested", "position", [30, 40]);
|
|
481
|
+
assert((0, src_1.findElementById)(moved, "nested").x === 30, "nested position should update immutably");
|
|
482
|
+
assert((0, src_1.findElementById)(doc, "nested").x === 5, "original document should not mutate");
|
|
483
|
+
const animated = (0, src_1.setTimelineKeyframe)(moved, "nested", "position", 1, [90, 40], { out: { type: "hold" } });
|
|
484
|
+
const tracks = (0, src_1.listTimelineTracks)(animated, "nested");
|
|
485
|
+
assert(tracks.length === 1 && tracks[0].property === "position", "timeline track should be listed");
|
|
486
|
+
assert((0, src_1.resolveVisualFrame)(animated, 1).elements[0].children[0].x === 90, "timeline keyframe should resolve");
|
|
487
|
+
const cleaned = (0, src_1.removeTimelineKeyframe)(animated, "nested", "position", 1);
|
|
488
|
+
assert(!(0, src_1.listTimelineTracks)(cleaned, "nested").length, "timeline keyframe removal should prune empty tracks");
|
|
489
|
+
});
|
|
490
|
+
test("edits and renders path position offsets", () => {
|
|
543
491
|
const doc = {
|
|
544
492
|
version: 1,
|
|
545
|
-
canvas: { width:
|
|
546
|
-
|
|
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
|
-
}
|
|
493
|
+
canvas: { width: 120, height: 80 },
|
|
494
|
+
elements: [{ id: "mark", type: "path", d: "M 0 0 L 20 0", stroke: "#000000" }]
|
|
555
495
|
};
|
|
556
|
-
const
|
|
557
|
-
const
|
|
558
|
-
assert(
|
|
559
|
-
assert((0, src_1.
|
|
560
|
-
assert((0, src_1.renderDeckToHtml)(doc, "slide").includes("Next"), "deck html should include controls");
|
|
496
|
+
const moved = (0, src_1.setElementProperty)(doc, "mark", "position", [30, 40]);
|
|
497
|
+
const mark = (0, src_1.findElementById)(moved, "mark");
|
|
498
|
+
assert(mark.x === 30 && mark.y === 40, "path position should edit x/y offsets");
|
|
499
|
+
assert((0, src_1.renderToSvg)(moved).includes("translate(30 40)"), "path position should render as translation");
|
|
561
500
|
});
|
|
562
|
-
test("
|
|
501
|
+
test("validates timeline graph mistakes", () => {
|
|
563
502
|
const doc = {
|
|
564
503
|
version: 1,
|
|
565
|
-
canvas: { width:
|
|
504
|
+
canvas: { width: 320, height: 180 },
|
|
566
505
|
elements: [
|
|
567
|
-
{
|
|
568
|
-
|
|
569
|
-
|
|
506
|
+
{
|
|
507
|
+
id: "dot",
|
|
508
|
+
type: "point",
|
|
509
|
+
x: 0,
|
|
510
|
+
y: 0,
|
|
511
|
+
timeline: {
|
|
512
|
+
tracks: {
|
|
513
|
+
position: {
|
|
514
|
+
keyframes: [[0, [0, 0]], [1, [100, 0]]],
|
|
515
|
+
curve: { type: "graph", points: [[0, 0], [0.25, 1], [0.2, 0.5], [1, 1]] }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
570
520
|
]
|
|
571
521
|
};
|
|
572
|
-
const
|
|
573
|
-
assert(
|
|
574
|
-
assert(
|
|
522
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
523
|
+
assert(!result.ok, "unsorted graph points should be invalid");
|
|
524
|
+
assert(result.issues.some((item) => item.code === "unsorted_curve_points"), "should report unsorted curve points");
|
|
575
525
|
});
|
|
576
|
-
test("
|
|
526
|
+
test("validates timeline mistakes", () => {
|
|
577
527
|
const doc = {
|
|
578
528
|
version: 1,
|
|
579
529
|
canvas: { width: 320, height: 180 },
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
530
|
+
elements: [
|
|
531
|
+
{ id: "dot", type: "point", x: 0, y: 0, timeline: { tracks: { x: { keyframes: [[1, 10], [0, 0]] } } } }
|
|
532
|
+
]
|
|
583
533
|
};
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
assert(
|
|
587
|
-
assert((0, src_1.validateVisualProject)(project).ok, "project should validate");
|
|
534
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
535
|
+
assert(!result.ok, "unsorted timeline should be invalid");
|
|
536
|
+
assert(result.issues.some((item) => item.code === "unsorted_timeline_keyframes"), "should report unsorted timeline keyframes");
|
|
588
537
|
});
|
|
589
|
-
test("
|
|
538
|
+
test("diagnostics report off-canvas elements", () => {
|
|
590
539
|
const doc = {
|
|
591
540
|
version: 1,
|
|
592
|
-
canvas: { width:
|
|
593
|
-
elements: [{ id: "
|
|
541
|
+
canvas: { width: 100, height: 100 },
|
|
542
|
+
elements: [{ id: "outside", type: "text", text: "Outside", x: 120, y: 20 }]
|
|
594
543
|
};
|
|
595
|
-
|
|
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");
|
|
544
|
+
assert((0, src_1.lintVisualDocument)(doc).warnings.some((item) => item.code === "element_outside_canvas"), "should warn about off-canvas text");
|
|
599
545
|
});
|
|
600
|
-
test("
|
|
601
|
-
const
|
|
602
|
-
|
|
546
|
+
test("generated schema matches the committed schema artifact", () => {
|
|
547
|
+
const schemaPath = path.resolve(__dirname, "..", "..", "schema", "visual.schema.json");
|
|
548
|
+
const committed = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
|
549
|
+
assert(stableStringify((0, src_1.generateVisualSchema)()) === stableStringify(committed), "committed visual.schema.json should match generated schema");
|
|
603
550
|
});
|
|
604
|
-
test("all
|
|
605
|
-
const
|
|
606
|
-
const files =
|
|
607
|
-
|
|
608
|
-
|
|
551
|
+
test("all example visual documents validate against the frozen kernel", () => {
|
|
552
|
+
const examplesDir = path.resolve(__dirname, "..", "..", "examples");
|
|
553
|
+
const files = fs.readdirSync(examplesDir).filter((file) => file.endsWith(".visual.json")).sort();
|
|
554
|
+
assert(files.length > 0, "expected at least one example visual document");
|
|
555
|
+
const failures = [];
|
|
609
556
|
for (const file of files) {
|
|
610
|
-
const
|
|
611
|
-
const
|
|
557
|
+
const fullPath = path.join(examplesDir, file);
|
|
558
|
+
const document = JSON.parse(fs.readFileSync(fullPath, "utf8"));
|
|
559
|
+
const result = (0, src_1.validateVisualDocument)(document);
|
|
560
|
+
if (!result.ok) {
|
|
561
|
+
const details = result.issues.map((item) => `${item.path} ${item.code}: ${item.message}`).join("; ");
|
|
562
|
+
failures.push(`${file}: ${details}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
assert(!failures.length, failures.join("\n"));
|
|
566
|
+
});
|
|
567
|
+
test("root export stays kernel-focused and presets live under the presets entrypoint", () => {
|
|
568
|
+
const root = require("../src");
|
|
569
|
+
assert(root.shapes === undefined && root.characters === undefined && root.applyPresetFragments === undefined, "root package export should not expose preset namespaces");
|
|
570
|
+
assert(typeof presets_1.shapes.rect === "function" && typeof presets_1.motions.fadeIn === "function", "presets entrypoint should expose official presets");
|
|
571
|
+
});
|
|
572
|
+
test("built-in shape, character, and scene presets compile to valid kernel elements", () => {
|
|
573
|
+
const doc = (0, presets_1.applyPresetFragments)(emptyPresetDocument(), [
|
|
574
|
+
presets_1.shapes.rect({ id: "shape.rect", x: 20, y: 20, width: 90, height: 50, fill: "#ffffff", stroke: "#111827" }),
|
|
575
|
+
presets_1.shapes.roundedRect({ id: "shape.rounded", x: 130, y: 20, width: 110, height: 50, radius: 12, fill: "#eff6ff", stroke: "#2563eb" }),
|
|
576
|
+
presets_1.shapes.ellipse({ id: "shape.ellipse", cx: 310, cy: 45, rx: 48, ry: 25, fill: "#dcfce7", stroke: "#16a34a" }),
|
|
577
|
+
presets_1.shapes.circle({ id: "shape.circle", cx: 400, cy: 45, radius: 24, fill: "#fef3c7", stroke: "#ca8a04" }),
|
|
578
|
+
presets_1.shapes.line({ id: "shape.line", from: [20, 100], to: [140, 120], stroke: "#0f172a" }),
|
|
579
|
+
presets_1.shapes.polyline({ id: "shape.polyline", points: [[170, 115], [205, 90], [240, 125]], stroke: "#7c3aed" }),
|
|
580
|
+
presets_1.shapes.arrow({ id: "shape.arrow", from: [280, 110], to: [410, 95], stroke: "#dc2626" }),
|
|
581
|
+
presets_1.shapes.regularPolygon({ id: "shape.polygon", cx: 500, cy: 100, radius: 30, sides: 6, fill: "#e0f2fe", stroke: "#0284c7" }),
|
|
582
|
+
presets_1.shapes.star({ id: "shape.star", cx: 590, cy: 100, outerRadius: 32, fill: "#fde68a", stroke: "#92400e" }),
|
|
583
|
+
presets_1.shapes.speechBubble({ id: "shape.speech", x: 650, y: 65, width: 190, height: 70, text: "Hello" }),
|
|
584
|
+
presets_1.characters.stickPerson({ id: "hero", x: 40, y: 190 }),
|
|
585
|
+
presets_1.characters.talkingHead({ id: "speaker", x: 170, y: 190 }),
|
|
586
|
+
presets_1.characters.simpleDog({ id: "dog", x: 330, y: 220 }),
|
|
587
|
+
presets_1.characters.simpleSpider({ id: "spider", x: 510, y: 200 }),
|
|
588
|
+
presets_1.characters.cursorHand({ id: "cursor", x: 680, y: 210 }),
|
|
589
|
+
presets_1.characters.simpleMascot({ id: "mascot", x: 790, y: 190 }),
|
|
590
|
+
presets_1.scenes.titleCard({ id: "scene.title", x: 20, y: 380, width: 220, height: 110, title: "Title", subtitle: "Preset" }),
|
|
591
|
+
presets_1.scenes.lowerThird({ id: "scene.lower", x: 260, y: 400, width: 210, height: 72, title: "Lower", subtitle: "Third" }),
|
|
592
|
+
presets_1.scenes.captionBubble({ id: "scene.caption", x: 500, y: 405, width: 180, height: 58, text: "Caption" }),
|
|
593
|
+
presets_1.scenes.comparisonSplit({ id: "scene.compare", x: 705, y: 380, width: 220, height: 110 }),
|
|
594
|
+
presets_1.scenes.deviceFrame({ id: "scene.device", x: 20, y: 510, width: 120, height: 160, label: "App" }),
|
|
595
|
+
presets_1.scenes.gridBackground({ id: "scene.grid", x: 170, y: 520, width: 180, height: 120, step: 30 })
|
|
596
|
+
]);
|
|
597
|
+
const result = (0, src_1.validateVisualDocument)(doc);
|
|
598
|
+
assert(result.ok, `preset element output should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
599
|
+
assert(JSON.stringify(doc).includes("hero.head"), "character presets should use dot-separated namespaced ids");
|
|
600
|
+
});
|
|
601
|
+
test("built-in motion presets compile to explicit kernel timelines", () => {
|
|
602
|
+
const cases = [
|
|
603
|
+
presets_1.motions.fadeIn({ id: "card", start: 0, duration: 0.3 }),
|
|
604
|
+
presets_1.motions.fadeOut({ id: "card", start: 0.4, duration: 0.3 }),
|
|
605
|
+
presets_1.motions.slideIn({ id: "card", from: [-80, 20], to: [20, 20] }),
|
|
606
|
+
presets_1.motions.riseIn({ id: "card", to: [20, 20] }),
|
|
607
|
+
presets_1.motions.scaleIn({ id: "card" }),
|
|
608
|
+
presets_1.motions.pulse({ id: "card" }),
|
|
609
|
+
presets_1.motions.bob({ id: "panel", to: [180, 20] }),
|
|
610
|
+
presets_1.motions.shake({ id: "card" }),
|
|
611
|
+
presets_1.motions.drawOn({ id: "line" }),
|
|
612
|
+
presets_1.motions.stagger({ ids: ["card", "panel"], each: 0.05 })
|
|
613
|
+
];
|
|
614
|
+
for (const fragment of cases) {
|
|
615
|
+
assertNoLegacyEase(fragment);
|
|
616
|
+
assert((0, src_1.validateVisualDocument)((0, presets_1.applyPresetFragments)(presetTargetDocument(), fragment)).ok, "motion preset should validate on a target document");
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
test("built-in effect presets compile to kernel effects, paint, clip, and mask tracks", () => {
|
|
620
|
+
const cases = [
|
|
621
|
+
presets_1.effects.dropShadow({ id: "card" }),
|
|
622
|
+
presets_1.effects.softBlur({ id: "card", amount: 4 }),
|
|
623
|
+
presets_1.effects.glow({ id: "card", color: "#38bdf8" }),
|
|
624
|
+
presets_1.effects.dim({ id: "card", opacity: 0.45 }),
|
|
625
|
+
presets_1.effects.tintFill({ id: "card", color: "#fee2e2" }),
|
|
626
|
+
presets_1.effects.gradientSweep({ id: "card" }),
|
|
627
|
+
presets_1.effects.roundedImageClip({ id: "photo", x: 320, y: 20, width: 120, height: 90, radius: 18 }),
|
|
628
|
+
presets_1.effects.maskReveal({ id: "card", x: 20, y: 20, width: 120, height: 70 })
|
|
629
|
+
];
|
|
630
|
+
for (const fragment of cases) {
|
|
631
|
+
assertNoLegacyEase(fragment);
|
|
632
|
+
const doc = (0, presets_1.applyPresetFragments)(presetTargetDocument(), fragment);
|
|
612
633
|
const result = (0, src_1.validateVisualDocument)(doc);
|
|
613
|
-
|
|
614
|
-
assert(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
634
|
+
assert(result.ok, `effect preset should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
635
|
+
assert(!JSON.stringify(doc).includes("cornerRadius"), "rounded image clip preset must not write cornerRadius");
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
test("built-in transition presets compile to coordinated kernel timelines", () => {
|
|
639
|
+
const cases = [
|
|
640
|
+
presets_1.transitions.crossfade({ fromId: "outgoing", toId: "incoming" }),
|
|
641
|
+
presets_1.transitions.pushLeft({ fromId: "outgoing", toId: "incoming" }),
|
|
642
|
+
presets_1.transitions.pushRight({ fromId: "outgoing", toId: "incoming" }),
|
|
643
|
+
presets_1.transitions.slideUp({ fromId: "outgoing", toId: "incoming" }),
|
|
644
|
+
presets_1.transitions.wipeLeft({ fromId: "outgoing", toId: "incoming", x: 0, y: 0, width: 160, height: 90 }),
|
|
645
|
+
presets_1.transitions.wipeRight({ fromId: "outgoing", toId: "incoming", x: 0, y: 0, width: 160, height: 90 }),
|
|
646
|
+
presets_1.transitions.zoomCut({ fromId: "outgoing", toId: "incoming" }),
|
|
647
|
+
presets_1.transitions.fadeThroughBlack({ fromId: "outgoing", toId: "incoming", width: 640, height: 360 }),
|
|
648
|
+
presets_1.transitions.irisIn({ id: "incoming", x: 0, y: 0, width: 160, height: 90 }),
|
|
649
|
+
presets_1.transitions.irisOut({ id: "outgoing", x: 0, y: 0, width: 160, height: 90 })
|
|
650
|
+
];
|
|
651
|
+
for (const fragment of cases) {
|
|
652
|
+
assertNoLegacyEase(fragment);
|
|
653
|
+
const result = (0, src_1.validateVisualDocument)((0, presets_1.applyPresetFragments)(presetTargetDocument(), fragment));
|
|
654
|
+
assert(result.ok, `transition preset should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
test("preset fragment composition validates targets and can prefix ids to avoid collisions", () => {
|
|
658
|
+
const duplicateA = (0, presets_1.prefixPresetFragment)(presets_1.shapes.rect({ id: "box", x: 0, y: 0, width: 40, height: 40 }), "a");
|
|
659
|
+
const duplicateB = (0, presets_1.prefixPresetFragment)(presets_1.shapes.rect({ id: "box", x: 50, y: 0, width: 40, height: 40 }), "b");
|
|
660
|
+
const doc = (0, presets_1.applyPresetFragments)(emptyPresetDocument(), [duplicateA, duplicateB]);
|
|
661
|
+
assert((0, src_1.validateVisualDocument)(doc).ok, "prefixed duplicate fragments should validate");
|
|
662
|
+
assert(JSON.stringify(doc).includes("a.box") && JSON.stringify(doc).includes("b.box"), "prefixing should rewrite element ids");
|
|
663
|
+
let threw = false;
|
|
664
|
+
try {
|
|
665
|
+
(0, presets_1.applyPresetFragments)(emptyPresetDocument(), presets_1.motions.fadeIn({ id: "missing" }));
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
threw = true;
|
|
618
669
|
}
|
|
670
|
+
assert(threw, "applying a timeline fragment to an unknown target should throw");
|
|
619
671
|
});
|
|
620
|
-
|
|
672
|
+
test("preset example visual documents validate as pure kernel output", () => {
|
|
673
|
+
for (const file of ["presets-demo.visual.json", "preset-character-motion.visual.json"]) {
|
|
674
|
+
const document = JSON.parse(fs.readFileSync(path.resolve(__dirname, "..", "..", "examples", file), "utf8"));
|
|
675
|
+
const result = (0, src_1.validateVisualDocument)(document);
|
|
676
|
+
assert(result.ok, `${file} should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
console.log("All render-kernel tests passed.");
|
|
621
680
|
function assert(condition, message) {
|
|
622
681
|
if (!condition)
|
|
623
682
|
throw new Error(message);
|
|
624
683
|
}
|
|
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
684
|
function near(left, right) {
|
|
640
685
|
return Math.abs(left - right) < 0.0001;
|
|
641
686
|
}
|
|
687
|
+
function emptyPresetDocument() {
|
|
688
|
+
return { version: 1, canvas: { width: 960, height: 720, background: "#f8fafc", duration: 2, fps: 30 }, elements: [] };
|
|
689
|
+
}
|
|
690
|
+
function presetTargetDocument() {
|
|
691
|
+
const imageSrc = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='90'%3E%3Crect width='120' height='90' fill='%23bfdbfe'/%3E%3C/svg%3E";
|
|
692
|
+
return {
|
|
693
|
+
version: 1,
|
|
694
|
+
canvas: { width: 640, height: 360, background: "#f8fafc", duration: 2, fps: 30 },
|
|
695
|
+
elements: [
|
|
696
|
+
{ id: "card", type: "path", d: "M 20 20 H 140 V 90 H 20 Z", fill: "#ffffff", stroke: "#111827", strokeWidth: 2 },
|
|
697
|
+
{ id: "line", type: "path", d: "M 20 130 C 80 80 150 170 230 120", fill: "none", stroke: "#2563eb", strokeWidth: 5 },
|
|
698
|
+
{ id: "panel", type: "group", x: 180, y: 20, width: 100, height: 70, children: [{ id: "panel.bg", type: "path", d: "M 0 0 H 100 V 70 H 0 Z", fill: "#dcfce7" }] },
|
|
699
|
+
{ id: "photo", type: "image", src: imageSrc, x: 320, y: 20, width: 120, height: 90 },
|
|
700
|
+
{ id: "outgoing", type: "group", x: 40, y: 200, width: 120, height: 80, children: [{ id: "outgoing.bg", type: "path", d: "M 0 0 H 120 V 80 H 0 Z", fill: "#fee2e2" }] },
|
|
701
|
+
{ id: "incoming", type: "group", x: 220, y: 200, width: 120, height: 80, opacity: 0, children: [{ id: "incoming.bg", type: "path", d: "M 0 0 H 120 V 80 H 0 Z", fill: "#dbeafe" }] }
|
|
702
|
+
]
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function assertNoLegacyEase(fragment) {
|
|
706
|
+
assert(!JSON.stringify(fragment).includes("\"ease\""), "preset fragments should emit explicit curves instead of legacy ease strings");
|
|
707
|
+
}
|
|
642
708
|
function stableStringify(value) {
|
|
643
709
|
return JSON.stringify(sortJson(value));
|
|
644
710
|
}
|