@zseven-w/pen-core 0.5.2 → 0.7.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 +18 -9
- package/package.json +5 -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 +173 -0
- 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 +158 -111
- 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 -230
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +133 -105
- 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 +79 -79
- 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 +107 -102
|
@@ -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
|
+
});
|