@zseven-w/pen-core 0.6.0 → 0.7.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/LICENSE +21 -0
- package/README.md +177 -25
- package/package.json +19 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +131 -142
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +75 -81
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +98 -106
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import type { PenNode } from '@zseven-w/pen-types'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
3
|
import {
|
|
4
4
|
resolvePadding,
|
|
5
5
|
isNodeVisible,
|
|
@@ -7,147 +7,233 @@ import {
|
|
|
7
7
|
getNodeWidth,
|
|
8
8
|
getNodeHeight,
|
|
9
9
|
computeLayoutPositions,
|
|
10
|
-
} from '../layout/engine'
|
|
10
|
+
} from '../layout/engine';
|
|
11
11
|
|
|
12
|
-
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
|
|
13
|
+
({
|
|
14
|
+
id: 'f1',
|
|
15
|
+
type: 'frame',
|
|
16
|
+
x: 0,
|
|
17
|
+
y: 0,
|
|
18
|
+
...props,
|
|
19
|
+
}) as PenNode;
|
|
15
20
|
|
|
16
21
|
const rect = (id: string, w = 50, h = 30): PenNode => ({
|
|
17
|
-
id,
|
|
18
|
-
|
|
22
|
+
id,
|
|
23
|
+
type: 'rectangle',
|
|
24
|
+
x: 0,
|
|
25
|
+
y: 0,
|
|
26
|
+
width: w,
|
|
27
|
+
height: h,
|
|
28
|
+
});
|
|
19
29
|
|
|
20
30
|
describe('layout-engine', () => {
|
|
21
31
|
describe('resolvePadding', () => {
|
|
22
32
|
it('returns zero for undefined', () => {
|
|
23
|
-
expect(resolvePadding(undefined)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
|
|
24
|
-
})
|
|
33
|
+
expect(resolvePadding(undefined)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
34
|
+
});
|
|
25
35
|
|
|
26
36
|
it('resolves uniform padding', () => {
|
|
27
|
-
expect(resolvePadding(10)).toEqual({ top: 10, right: 10, bottom: 10, left: 10 })
|
|
28
|
-
})
|
|
37
|
+
expect(resolvePadding(10)).toEqual({ top: 10, right: 10, bottom: 10, left: 10 });
|
|
38
|
+
});
|
|
29
39
|
|
|
30
40
|
it('resolves [vertical, horizontal]', () => {
|
|
31
|
-
expect(resolvePadding([10, 20])).toEqual({ top: 10, right: 20, bottom: 10, left: 20 })
|
|
32
|
-
})
|
|
41
|
+
expect(resolvePadding([10, 20])).toEqual({ top: 10, right: 20, bottom: 10, left: 20 });
|
|
42
|
+
});
|
|
33
43
|
|
|
34
44
|
it('resolves [top, right, bottom, left]', () => {
|
|
35
|
-
expect(resolvePadding([1, 2, 3, 4])).toEqual({ top: 1, right: 2, bottom: 3, left: 4 })
|
|
36
|
-
})
|
|
45
|
+
expect(resolvePadding([1, 2, 3, 4])).toEqual({ top: 1, right: 2, bottom: 3, left: 4 });
|
|
46
|
+
});
|
|
37
47
|
|
|
38
48
|
it('returns zero for string (variable ref)', () => {
|
|
39
|
-
expect(resolvePadding('$spacing')).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
|
|
40
|
-
})
|
|
41
|
-
})
|
|
49
|
+
expect(resolvePadding('$spacing')).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
42
52
|
|
|
43
53
|
describe('isNodeVisible', () => {
|
|
44
54
|
it('returns true by default', () => {
|
|
45
|
-
expect(isNodeVisible(rect('a'))).toBe(true)
|
|
46
|
-
})
|
|
55
|
+
expect(isNodeVisible(rect('a'))).toBe(true);
|
|
56
|
+
});
|
|
47
57
|
|
|
48
58
|
it('returns false when visible is false', () => {
|
|
49
|
-
expect(isNodeVisible({ ...rect('a'), visible: false })).toBe(false)
|
|
50
|
-
})
|
|
51
|
-
|
|
59
|
+
expect(isNodeVisible({ ...rect('a'), visible: false })).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns false when enabled is false', () => {
|
|
63
|
+
expect(isNodeVisible({ ...rect('a'), enabled: false } as PenNode)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
52
66
|
|
|
53
67
|
describe('inferLayout', () => {
|
|
54
68
|
it('returns undefined for non-frame nodes', () => {
|
|
55
|
-
expect(inferLayout(rect('a'))).toBeUndefined()
|
|
56
|
-
})
|
|
69
|
+
expect(inferLayout(rect('a'))).toBeUndefined();
|
|
70
|
+
});
|
|
57
71
|
|
|
58
72
|
it('infers horizontal when gap is set', () => {
|
|
59
|
-
expect(inferLayout(frame({ gap: 10, children: [] }))).toBe('horizontal')
|
|
60
|
-
})
|
|
73
|
+
expect(inferLayout(frame({ gap: 10, children: [] }))).toBe('horizontal');
|
|
74
|
+
});
|
|
61
75
|
|
|
62
76
|
it('infers horizontal when padding is set', () => {
|
|
63
|
-
expect(inferLayout(frame({ padding: 10, children: [] }))).toBe('horizontal')
|
|
64
|
-
})
|
|
77
|
+
expect(inferLayout(frame({ padding: 10, children: [] }))).toBe('horizontal');
|
|
78
|
+
});
|
|
65
79
|
|
|
66
80
|
it('returns undefined when no layout hints', () => {
|
|
67
|
-
expect(inferLayout(frame({ children: [rect('a')] }))).toBeUndefined()
|
|
68
|
-
})
|
|
69
|
-
})
|
|
81
|
+
expect(inferLayout(frame({ children: [rect('a')] }))).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
70
84
|
|
|
71
85
|
describe('getNodeWidth / getNodeHeight', () => {
|
|
72
86
|
it('returns explicit width', () => {
|
|
73
|
-
expect(getNodeWidth(rect('a', 200))).toBe(200)
|
|
74
|
-
})
|
|
87
|
+
expect(getNodeWidth(rect('a', 200))).toBe(200);
|
|
88
|
+
});
|
|
75
89
|
|
|
76
90
|
it('returns explicit height', () => {
|
|
77
|
-
expect(getNodeHeight(rect('a', 50, 100))).toBe(100)
|
|
78
|
-
})
|
|
91
|
+
expect(getNodeHeight(rect('a', 50, 100))).toBe(100);
|
|
92
|
+
});
|
|
79
93
|
|
|
80
94
|
it('estimates text width', () => {
|
|
81
|
-
const text: PenNode = { id: 't', type: 'text', content: 'Hello World', fontSize: 16 }
|
|
82
|
-
expect(getNodeWidth(text)).toBeGreaterThan(0)
|
|
83
|
-
})
|
|
84
|
-
})
|
|
95
|
+
const text: PenNode = { id: 't', type: 'text', content: 'Hello World', fontSize: 16 };
|
|
96
|
+
expect(getNodeWidth(text)).toBeGreaterThan(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
85
99
|
|
|
86
100
|
describe('computeLayoutPositions', () => {
|
|
87
101
|
it('positions children horizontally', () => {
|
|
88
102
|
const parent = frame({
|
|
89
|
-
width: 300,
|
|
90
|
-
|
|
103
|
+
width: 300,
|
|
104
|
+
height: 100,
|
|
105
|
+
layout: 'horizontal',
|
|
106
|
+
gap: 10,
|
|
91
107
|
children: [rect('a', 50, 30), rect('b', 50, 30)],
|
|
92
|
-
})
|
|
93
|
-
const result = computeLayoutPositions(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
});
|
|
109
|
+
const result = computeLayoutPositions(
|
|
110
|
+
parent,
|
|
111
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
112
|
+
);
|
|
113
|
+
expect(result[0].x).toBe(0);
|
|
114
|
+
expect(result[0].y).toBe(0);
|
|
115
|
+
expect(result[1].x).toBe(60); // 50 + 10 gap
|
|
116
|
+
});
|
|
98
117
|
|
|
99
118
|
it('positions children vertically', () => {
|
|
100
119
|
const parent = frame({
|
|
101
|
-
width: 100,
|
|
102
|
-
|
|
120
|
+
width: 100,
|
|
121
|
+
height: 300,
|
|
122
|
+
layout: 'vertical',
|
|
123
|
+
gap: 10,
|
|
103
124
|
children: [rect('a', 50, 30), rect('b', 50, 30)],
|
|
104
|
-
})
|
|
105
|
-
const result = computeLayoutPositions(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
});
|
|
126
|
+
const result = computeLayoutPositions(
|
|
127
|
+
parent,
|
|
128
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
129
|
+
);
|
|
130
|
+
expect(result[0].x).toBe(0);
|
|
131
|
+
expect(result[0].y).toBe(0);
|
|
132
|
+
expect(result[1].y).toBe(40); // 30 + 10 gap
|
|
133
|
+
});
|
|
110
134
|
|
|
111
135
|
it('applies padding', () => {
|
|
112
136
|
const parent = frame({
|
|
113
|
-
width: 300,
|
|
114
|
-
|
|
137
|
+
width: 300,
|
|
138
|
+
height: 100,
|
|
139
|
+
layout: 'horizontal',
|
|
140
|
+
padding: 20,
|
|
115
141
|
children: [rect('a', 50, 30)],
|
|
116
|
-
})
|
|
117
|
-
const result = computeLayoutPositions(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
142
|
+
});
|
|
143
|
+
const result = computeLayoutPositions(
|
|
144
|
+
parent,
|
|
145
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
146
|
+
);
|
|
147
|
+
expect(result[0].x).toBe(20);
|
|
148
|
+
expect(result[0].y).toBe(20);
|
|
149
|
+
});
|
|
121
150
|
|
|
122
151
|
it('centers children on cross axis', () => {
|
|
123
152
|
const parent = frame({
|
|
124
|
-
width: 300,
|
|
125
|
-
|
|
153
|
+
width: 300,
|
|
154
|
+
height: 100,
|
|
155
|
+
layout: 'horizontal',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
children: [rect('a', 50, 30)],
|
|
158
|
+
});
|
|
159
|
+
const result = computeLayoutPositions(
|
|
160
|
+
parent,
|
|
161
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
162
|
+
);
|
|
163
|
+
expect(result[0].y).toBe(35); // (100 - 30) / 2
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('maps alignItems="baseline" to end-alignment (engine has no baseline metric)', () => {
|
|
167
|
+
// LLMs routinely emit alignItems: 'baseline' from web CSS reflex
|
|
168
|
+
// for "big number + small unit" patterns like "72 BPM". The
|
|
169
|
+
// layout engine doesn't compute text baselines; the closest
|
|
170
|
+
// visually correct fallback is end-alignment (both children
|
|
171
|
+
// bottom-pinned). Locks in that `baseline` no longer falls
|
|
172
|
+
// through to the `start` default.
|
|
173
|
+
//
|
|
174
|
+
// `baseline` is not part of the TS `alignItems` union, so we
|
|
175
|
+
// cast through unknown — the normalizer accepts any string and
|
|
176
|
+
// that's exactly the behavior we want to exercise here.
|
|
177
|
+
const parent = frame({
|
|
178
|
+
width: 300,
|
|
179
|
+
height: 100,
|
|
180
|
+
layout: 'horizontal',
|
|
181
|
+
alignItems: 'baseline' as unknown as 'start' | 'center' | 'end',
|
|
182
|
+
children: [rect('big', 120, 80), rect('unit', 60, 20)],
|
|
183
|
+
});
|
|
184
|
+
const result = computeLayoutPositions(
|
|
185
|
+
parent,
|
|
186
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
187
|
+
);
|
|
188
|
+
expect(result[0].y).toBe(20); // 100 - 80 (big pinned to bottom)
|
|
189
|
+
expect(result[1].y).toBe(80); // 100 - 20 (unit pinned to bottom)
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('maps alignItems="flex-end" and "bottom" to end (CSS/alias passthrough)', () => {
|
|
193
|
+
// `flex-end` is a CSS alias that the normalizer accepts but the
|
|
194
|
+
// TS union doesn't — cast through unknown same as the baseline
|
|
195
|
+
// test above.
|
|
196
|
+
const parent = frame({
|
|
197
|
+
width: 300,
|
|
198
|
+
height: 100,
|
|
199
|
+
layout: 'horizontal',
|
|
200
|
+
alignItems: 'flex-end' as unknown as 'start' | 'center' | 'end',
|
|
126
201
|
children: [rect('a', 50, 30)],
|
|
127
|
-
})
|
|
128
|
-
const result = computeLayoutPositions(
|
|
129
|
-
|
|
130
|
-
|
|
202
|
+
});
|
|
203
|
+
const result = computeLayoutPositions(
|
|
204
|
+
parent,
|
|
205
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
206
|
+
);
|
|
207
|
+
expect(result[0].y).toBe(70); // 100 - 30
|
|
208
|
+
});
|
|
131
209
|
|
|
132
210
|
it('filters invisible children', () => {
|
|
133
211
|
const parent = frame({
|
|
134
|
-
width: 300,
|
|
212
|
+
width: 300,
|
|
213
|
+
height: 100,
|
|
135
214
|
layout: 'horizontal',
|
|
136
215
|
children: [rect('a', 50, 30), { ...rect('b', 50, 30), visible: false }],
|
|
137
|
-
})
|
|
138
|
-
const result = computeLayoutPositions(
|
|
139
|
-
|
|
140
|
-
|
|
216
|
+
});
|
|
217
|
+
const result = computeLayoutPositions(
|
|
218
|
+
parent,
|
|
219
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
220
|
+
);
|
|
221
|
+
expect(result).toHaveLength(1);
|
|
222
|
+
});
|
|
141
223
|
|
|
142
224
|
it('returns visible children as-is when layout is none', () => {
|
|
143
225
|
const parent = frame({
|
|
144
|
-
width: 300,
|
|
226
|
+
width: 300,
|
|
227
|
+
height: 100,
|
|
145
228
|
layout: 'none',
|
|
146
229
|
children: [rect('a', 50, 30)],
|
|
147
|
-
})
|
|
148
|
-
const result = computeLayoutPositions(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
230
|
+
});
|
|
231
|
+
const result = computeLayoutPositions(
|
|
232
|
+
parent,
|
|
233
|
+
(parent as PenNode & { children: PenNode[] }).children,
|
|
234
|
+
);
|
|
235
|
+
expect(result).toHaveLength(1);
|
|
236
|
+
expect(result[0].id).toBe('a');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// packages/pen-core/src/__tests__/merge-helpers.test.ts
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import type { PenDocument, PenNode } from '@zseven-w/pen-types';
|
|
4
|
+
import {
|
|
5
|
+
indexNodesById,
|
|
6
|
+
getAllPages,
|
|
7
|
+
nodeFieldsEqual,
|
|
8
|
+
jsonEqual,
|
|
9
|
+
stripChildren,
|
|
10
|
+
} from '../merge/merge-helpers';
|
|
11
|
+
|
|
12
|
+
const rect = (id: string, props: Partial<PenNode> = {}): PenNode =>
|
|
13
|
+
({ id, type: 'rectangle', x: 0, y: 0, width: 10, height: 10, ...props }) as PenNode;
|
|
14
|
+
|
|
15
|
+
const frame = (id: string, children: PenNode[] = [], props: Partial<PenNode> = {}): PenNode =>
|
|
16
|
+
({
|
|
17
|
+
id,
|
|
18
|
+
type: 'frame',
|
|
19
|
+
x: 0,
|
|
20
|
+
y: 0,
|
|
21
|
+
width: 100,
|
|
22
|
+
height: 100,
|
|
23
|
+
children,
|
|
24
|
+
...props,
|
|
25
|
+
}) as PenNode;
|
|
26
|
+
|
|
27
|
+
const docWithPages = (pages: Array<{ id: string; children: PenNode[] }>): PenDocument => ({
|
|
28
|
+
version: '1.0.0',
|
|
29
|
+
pages: pages.map((p) => ({ id: p.id, name: p.id, children: p.children })),
|
|
30
|
+
children: [],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const docLegacy = (children: PenNode[]): PenDocument => ({
|
|
34
|
+
version: '1.0.0',
|
|
35
|
+
children,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('merge-helpers', () => {
|
|
39
|
+
describe('indexNodesById', () => {
|
|
40
|
+
it('indexes a flat top-level node with pageId and null parentId', () => {
|
|
41
|
+
const doc = docWithPages([{ id: 'page-1', children: [rect('r1')] }]);
|
|
42
|
+
const idx = indexNodesById(doc);
|
|
43
|
+
expect(idx.size).toBe(1);
|
|
44
|
+
const r1 = idx.get('r1');
|
|
45
|
+
expect(r1).toBeDefined();
|
|
46
|
+
expect(r1!.pageId).toBe('page-1');
|
|
47
|
+
expect(r1!.parentId).toBeNull();
|
|
48
|
+
expect(r1!.index).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('indexes nested nodes with the correct parent id', () => {
|
|
52
|
+
const inner = rect('inner');
|
|
53
|
+
const outer = frame('outer', [inner]);
|
|
54
|
+
const doc = docWithPages([{ id: 'page-1', children: [outer] }]);
|
|
55
|
+
const idx = indexNodesById(doc);
|
|
56
|
+
expect(idx.size).toBe(2);
|
|
57
|
+
expect(idx.get('outer')!.parentId).toBeNull();
|
|
58
|
+
expect(idx.get('inner')!.parentId).toBe('outer');
|
|
59
|
+
expect(idx.get('inner')!.pageId).toBe('page-1');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('uses pageId === null for legacy single-page documents', () => {
|
|
63
|
+
const doc = docLegacy([rect('r1')]);
|
|
64
|
+
const idx = indexNodesById(doc);
|
|
65
|
+
const r1 = idx.get('r1');
|
|
66
|
+
expect(r1!.pageId).toBeNull();
|
|
67
|
+
expect(r1!.parentId).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('records correct index within parent.children', () => {
|
|
71
|
+
const doc = docWithPages([{ id: 'page-1', children: [rect('a'), rect('b'), rect('c')] }]);
|
|
72
|
+
const idx = indexNodesById(doc);
|
|
73
|
+
expect(idx.get('a')!.index).toBe(0);
|
|
74
|
+
expect(idx.get('b')!.index).toBe(1);
|
|
75
|
+
expect(idx.get('c')!.index).toBe(2);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('getAllPages', () => {
|
|
80
|
+
it('returns explicit pages when the document has them', () => {
|
|
81
|
+
const doc = docWithPages([
|
|
82
|
+
{ id: 'p1', children: [rect('a')] },
|
|
83
|
+
{ id: 'p2', children: [rect('b')] },
|
|
84
|
+
]);
|
|
85
|
+
const pages = getAllPages(doc);
|
|
86
|
+
expect(pages).toHaveLength(2);
|
|
87
|
+
expect(pages[0].id).toBe('p1');
|
|
88
|
+
expect(pages[1].id).toBe('p2');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns one synthetic page with id null for legacy documents', () => {
|
|
92
|
+
const doc = docLegacy([rect('r1'), rect('r2')]);
|
|
93
|
+
const pages = getAllPages(doc);
|
|
94
|
+
expect(pages).toHaveLength(1);
|
|
95
|
+
expect(pages[0].id).toBeNull();
|
|
96
|
+
expect(pages[0].children).toHaveLength(2);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('nodeFieldsEqual / stripChildren', () => {
|
|
101
|
+
it('returns true for two nodes that differ only in children', () => {
|
|
102
|
+
const a = frame('f', [rect('a')]);
|
|
103
|
+
const b = frame('f', [rect('b'), rect('c')]);
|
|
104
|
+
expect(nodeFieldsEqual(a, b)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns false when an atomic field differs', () => {
|
|
108
|
+
const a = rect('r', { width: 10 });
|
|
109
|
+
const b = rect('r', { width: 20 });
|
|
110
|
+
expect(nodeFieldsEqual(a, b)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('stripChildren removes the children field without mutating the input', () => {
|
|
114
|
+
const original = frame('f', [rect('child')]);
|
|
115
|
+
const stripped = stripChildren(original);
|
|
116
|
+
expect((stripped as { children?: unknown }).children).toBeUndefined();
|
|
117
|
+
// Original is untouched
|
|
118
|
+
expect((original as { children?: unknown }).children).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('jsonEqual', () => {
|
|
123
|
+
it('returns true for primitives that are ===', () => {
|
|
124
|
+
expect(jsonEqual(1, 1)).toBe(true);
|
|
125
|
+
expect(jsonEqual('a', 'a')).toBe(true);
|
|
126
|
+
expect(jsonEqual(null, null)).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns true for deeply equal objects regardless of key order', () => {
|
|
130
|
+
const a = { x: 1, y: 2 };
|
|
131
|
+
const b = { y: 2, x: 1 };
|
|
132
|
+
expect(jsonEqual(a, b)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns false for objects with different values', () => {
|
|
136
|
+
expect(jsonEqual({ x: 1 }, { x: 2 })).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns true for nested objects with same content', () => {
|
|
140
|
+
expect(jsonEqual({ a: { b: [1, 2] } }, { a: { b: [1, 2] } })).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// packages/pen-core/src/__tests__/node-diff.test.ts
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import type { PenDocument, PenNode } from '@zseven-w/pen-types';
|
|
4
|
+
import { diffDocuments } from '../merge/node-diff';
|
|
5
|
+
|
|
6
|
+
const rect = (id: string, props: Partial<PenNode> = {}): PenNode =>
|
|
7
|
+
({ id, type: 'rectangle', x: 0, y: 0, width: 10, height: 10, ...props }) as PenNode;
|
|
8
|
+
|
|
9
|
+
const frame = (id: string, children: PenNode[] = [], props: Partial<PenNode> = {}): PenNode =>
|
|
10
|
+
({
|
|
11
|
+
id,
|
|
12
|
+
type: 'frame',
|
|
13
|
+
x: 0,
|
|
14
|
+
y: 0,
|
|
15
|
+
width: 100,
|
|
16
|
+
height: 100,
|
|
17
|
+
children,
|
|
18
|
+
...props,
|
|
19
|
+
}) as PenNode;
|
|
20
|
+
|
|
21
|
+
const doc = (children: PenNode[]): PenDocument => ({
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
pages: [{ id: 'page-1', name: 'page-1', children }],
|
|
24
|
+
children: [],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('diffDocuments', () => {
|
|
28
|
+
it('returns empty for two empty documents', () => {
|
|
29
|
+
const empty = doc([]);
|
|
30
|
+
expect(diffDocuments(empty, empty)).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('emits one add for a new top-level node', () => {
|
|
34
|
+
const before = doc([]);
|
|
35
|
+
const after = doc([rect('r1')]);
|
|
36
|
+
const patches = diffDocuments(before, after);
|
|
37
|
+
expect(patches).toHaveLength(1);
|
|
38
|
+
expect(patches[0].op).toBe('add');
|
|
39
|
+
expect(patches[0].nodeId).toBe('r1');
|
|
40
|
+
expect(patches[0].pageId).toBe('page-1');
|
|
41
|
+
expect(patches[0].parentId).toBeNull();
|
|
42
|
+
expect(patches[0].index).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('emits one remove for a deleted top-level node', () => {
|
|
46
|
+
const before = doc([rect('r1')]);
|
|
47
|
+
const after = doc([]);
|
|
48
|
+
const patches = diffDocuments(before, after);
|
|
49
|
+
expect(patches).toHaveLength(1);
|
|
50
|
+
expect(patches[0].op).toBe('remove');
|
|
51
|
+
expect(patches[0].nodeId).toBe('r1');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns empty for an unchanged single node', () => {
|
|
55
|
+
const a = doc([rect('r1')]);
|
|
56
|
+
const b = doc([rect('r1')]);
|
|
57
|
+
expect(diffDocuments(a, b)).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('emits a modify with changed and before fields when an atomic field changes', () => {
|
|
61
|
+
const before = doc([rect('r1', { width: 10 })]);
|
|
62
|
+
const after = doc([rect('r1', { width: 20 })]);
|
|
63
|
+
const patches = diffDocuments(before, after);
|
|
64
|
+
expect(patches).toHaveLength(1);
|
|
65
|
+
expect(patches[0].op).toBe('modify');
|
|
66
|
+
expect(patches[0].nodeId).toBe('r1');
|
|
67
|
+
expect(patches[0].fields).toEqual({ width: 20 });
|
|
68
|
+
expect(patches[0].beforeFields).toEqual({ width: 10 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('emits one move when a node is reparented within the same page', () => {
|
|
72
|
+
const inner = rect('inner');
|
|
73
|
+
const f1 = frame('f1', [inner]);
|
|
74
|
+
const f2 = frame('f2', []);
|
|
75
|
+
const before = doc([f1, f2]);
|
|
76
|
+
// After: inner moved into f2
|
|
77
|
+
const after = doc([frame('f1', []), frame('f2', [rect('inner')])]);
|
|
78
|
+
const patches = diffDocuments(before, after);
|
|
79
|
+
// Expect at least one move op for inner (and no modify since fields unchanged)
|
|
80
|
+
const moves = patches.filter((p) => p.op === 'move');
|
|
81
|
+
expect(moves).toHaveLength(1);
|
|
82
|
+
expect(moves[0].nodeId).toBe('inner');
|
|
83
|
+
expect(moves[0].parentId).toBe('f2');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('emits move when a node moves to a different page', () => {
|
|
87
|
+
const inner = rect('inner');
|
|
88
|
+
const before: PenDocument = {
|
|
89
|
+
version: '1.0.0',
|
|
90
|
+
pages: [
|
|
91
|
+
{ id: 'p1', name: 'p1', children: [inner] },
|
|
92
|
+
{ id: 'p2', name: 'p2', children: [] },
|
|
93
|
+
],
|
|
94
|
+
children: [],
|
|
95
|
+
};
|
|
96
|
+
const after: PenDocument = {
|
|
97
|
+
version: '1.0.0',
|
|
98
|
+
pages: [
|
|
99
|
+
{ id: 'p1', name: 'p1', children: [] },
|
|
100
|
+
{ id: 'p2', name: 'p2', children: [rect('inner')] },
|
|
101
|
+
],
|
|
102
|
+
children: [],
|
|
103
|
+
};
|
|
104
|
+
const patches = diffDocuments(before, after);
|
|
105
|
+
const moves = patches.filter((p) => p.op === 'move');
|
|
106
|
+
expect(moves).toHaveLength(1);
|
|
107
|
+
expect(moves[0].nodeId).toBe('inner');
|
|
108
|
+
expect(moves[0].pageId).toBe('p2');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('emits add for a nested child added inside an existing frame', () => {
|
|
112
|
+
const before = doc([frame('f1', [])]);
|
|
113
|
+
const after = doc([frame('f1', [rect('r1')])]);
|
|
114
|
+
const patches = diffDocuments(before, after);
|
|
115
|
+
const adds = patches.filter((p) => p.op === 'add');
|
|
116
|
+
expect(adds).toHaveLength(1);
|
|
117
|
+
expect(adds[0].nodeId).toBe('r1');
|
|
118
|
+
expect(adds[0].parentId).toBe('f1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('emits a move when a node is reordered within the same parent', () => {
|
|
122
|
+
const before = doc([rect('a'), rect('b'), rect('c')]);
|
|
123
|
+
const after = doc([rect('b'), rect('a'), rect('c')]);
|
|
124
|
+
const patches = diffDocuments(before, after);
|
|
125
|
+
const moves = patches.filter((p) => p.op === 'move');
|
|
126
|
+
// a moves from index 0 to 1, b moves from index 1 to 0, c stays
|
|
127
|
+
expect(moves.map((m) => m.nodeId).sort()).toEqual(['a', 'b']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('produces add + modify + remove in a single mixed diff', () => {
|
|
131
|
+
const before = doc([rect('a', { width: 10 }), rect('b')]);
|
|
132
|
+
const after = doc([rect('a', { width: 20 }), rect('c')]);
|
|
133
|
+
const patches = diffDocuments(before, after);
|
|
134
|
+
expect(patches.find((p) => p.op === 'modify' && p.nodeId === 'a')).toBeDefined();
|
|
135
|
+
expect(patches.find((p) => p.op === 'remove' && p.nodeId === 'b')).toBeDefined();
|
|
136
|
+
expect(patches.find((p) => p.op === 'add' && p.nodeId === 'c')).toBeDefined();
|
|
137
|
+
expect(patches).toHaveLength(3);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import type { PenNode } from '@zseven-w/pen-types'
|
|
3
|
-
import { isBadgeOverlayNode } from '../node-helpers'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
|
+
import { isBadgeOverlayNode } from '../node-helpers';
|
|
4
4
|
|
|
5
5
|
describe('isBadgeOverlayNode', () => {
|
|
6
6
|
it('returns true for badge role', () => {
|
|
7
|
-
const node: PenNode = { id: '1', type: 'rectangle', role: 'badge' }
|
|
8
|
-
expect(isBadgeOverlayNode(node)).toBe(true)
|
|
9
|
-
})
|
|
7
|
+
const node: PenNode = { id: '1', type: 'rectangle', role: 'badge' };
|
|
8
|
+
expect(isBadgeOverlayNode(node)).toBe(true);
|
|
9
|
+
});
|
|
10
10
|
|
|
11
11
|
it('returns true for pill role', () => {
|
|
12
|
-
const node: PenNode = { id: '1', type: 'rectangle', role: 'pill' }
|
|
13
|
-
expect(isBadgeOverlayNode(node)).toBe(true)
|
|
14
|
-
})
|
|
12
|
+
const node: PenNode = { id: '1', type: 'rectangle', role: 'pill' };
|
|
13
|
+
expect(isBadgeOverlayNode(node)).toBe(true);
|
|
14
|
+
});
|
|
15
15
|
|
|
16
16
|
it('returns true for name containing "badge"', () => {
|
|
17
|
-
const node: PenNode = { id: '1', type: 'rectangle', name: 'Notification Badge' }
|
|
18
|
-
expect(isBadgeOverlayNode(node)).toBe(true)
|
|
19
|
-
})
|
|
17
|
+
const node: PenNode = { id: '1', type: 'rectangle', name: 'Notification Badge' };
|
|
18
|
+
expect(isBadgeOverlayNode(node)).toBe(true);
|
|
19
|
+
});
|
|
20
20
|
|
|
21
21
|
it('returns true for name containing "overlay"', () => {
|
|
22
|
-
const node: PenNode = { id: '1', type: 'rectangle', name: 'Image Overlay' }
|
|
23
|
-
expect(isBadgeOverlayNode(node)).toBe(true)
|
|
24
|
-
})
|
|
22
|
+
const node: PenNode = { id: '1', type: 'rectangle', name: 'Image Overlay' };
|
|
23
|
+
expect(isBadgeOverlayNode(node)).toBe(true);
|
|
24
|
+
});
|
|
25
25
|
|
|
26
26
|
it('returns false for regular nodes', () => {
|
|
27
|
-
const node: PenNode = { id: '1', type: 'rectangle', name: 'Button' }
|
|
28
|
-
expect(isBadgeOverlayNode(node)).toBe(false)
|
|
29
|
-
})
|
|
30
|
-
})
|
|
27
|
+
const node: PenNode = { id: '1', type: 'rectangle', name: 'Button' };
|
|
28
|
+
expect(isBadgeOverlayNode(node)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|