@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import type { PenNode, PenDocument } from '@zseven-w/pen-types'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode, PenDocument } from '@zseven-w/pen-types';
|
|
3
3
|
import {
|
|
4
4
|
createEmptyDocument,
|
|
5
5
|
findNodeInTree,
|
|
@@ -14,157 +14,188 @@ import {
|
|
|
14
14
|
migrateToPages,
|
|
15
15
|
DEFAULT_FRAME_ID,
|
|
16
16
|
DEFAULT_PAGE_ID,
|
|
17
|
-
} from '../tree-utils'
|
|
17
|
+
} from '../tree-utils';
|
|
18
18
|
|
|
19
19
|
const frame = (id: string, children: PenNode[] = []): PenNode => ({
|
|
20
|
-
id,
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
id,
|
|
21
|
+
type: 'frame',
|
|
22
|
+
name: id,
|
|
23
|
+
x: 0,
|
|
24
|
+
y: 0,
|
|
25
|
+
width: 100,
|
|
26
|
+
height: 100,
|
|
27
|
+
fill: [{ type: 'solid', color: '#fff' }],
|
|
28
|
+
children,
|
|
29
|
+
});
|
|
23
30
|
|
|
24
31
|
const rect = (id: string): PenNode => ({
|
|
25
|
-
id,
|
|
26
|
-
|
|
32
|
+
id,
|
|
33
|
+
type: 'rectangle',
|
|
34
|
+
x: 0,
|
|
35
|
+
y: 0,
|
|
36
|
+
width: 50,
|
|
37
|
+
height: 50,
|
|
38
|
+
});
|
|
27
39
|
|
|
28
40
|
describe('tree-utils', () => {
|
|
29
41
|
describe('createEmptyDocument', () => {
|
|
30
42
|
it('creates a document with a default page and root frame', () => {
|
|
31
|
-
const doc = createEmptyDocument()
|
|
32
|
-
expect(doc.version).toBe('1.0.0')
|
|
33
|
-
expect(doc.pages).toHaveLength(1)
|
|
34
|
-
expect(doc.pages![0].id).toBe(DEFAULT_PAGE_ID)
|
|
35
|
-
expect(doc.pages![0].children).toHaveLength(1)
|
|
36
|
-
expect(doc.pages![0].children[0].id).toBe(DEFAULT_FRAME_ID)
|
|
37
|
-
})
|
|
38
|
-
})
|
|
43
|
+
const doc = createEmptyDocument();
|
|
44
|
+
expect(doc.version).toBe('1.0.0');
|
|
45
|
+
expect(doc.pages).toHaveLength(1);
|
|
46
|
+
expect(doc.pages![0].id).toBe(DEFAULT_PAGE_ID);
|
|
47
|
+
expect(doc.pages![0].children).toHaveLength(1);
|
|
48
|
+
expect(doc.pages![0].children[0].id).toBe(DEFAULT_FRAME_ID);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
39
51
|
|
|
40
52
|
describe('findNodeInTree', () => {
|
|
41
53
|
it('finds a node by id at root level', () => {
|
|
42
|
-
const nodes = [rect('a'), rect('b')]
|
|
43
|
-
expect(findNodeInTree(nodes, 'b')?.id).toBe('b')
|
|
44
|
-
})
|
|
54
|
+
const nodes = [rect('a'), rect('b')];
|
|
55
|
+
expect(findNodeInTree(nodes, 'b')?.id).toBe('b');
|
|
56
|
+
});
|
|
45
57
|
|
|
46
58
|
it('finds a nested node', () => {
|
|
47
|
-
const nodes = [frame('parent', [rect('child')])]
|
|
48
|
-
expect(findNodeInTree(nodes, 'child')?.id).toBe('child')
|
|
49
|
-
})
|
|
59
|
+
const nodes = [frame('parent', [rect('child')])];
|
|
60
|
+
expect(findNodeInTree(nodes, 'child')?.id).toBe('child');
|
|
61
|
+
});
|
|
50
62
|
|
|
51
63
|
it('returns undefined for missing node', () => {
|
|
52
|
-
expect(findNodeInTree([rect('a')], 'missing')).toBeUndefined()
|
|
53
|
-
})
|
|
54
|
-
})
|
|
64
|
+
expect(findNodeInTree([rect('a')], 'missing')).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
55
67
|
|
|
56
68
|
describe('findParentInTree', () => {
|
|
57
69
|
it('finds the parent of a child node', () => {
|
|
58
|
-
const nodes = [frame('parent', [rect('child')])]
|
|
59
|
-
expect(findParentInTree(nodes, 'child')?.id).toBe('parent')
|
|
60
|
-
})
|
|
70
|
+
const nodes = [frame('parent', [rect('child')])];
|
|
71
|
+
expect(findParentInTree(nodes, 'child')?.id).toBe('parent');
|
|
72
|
+
});
|
|
61
73
|
|
|
62
74
|
it('returns undefined for root nodes', () => {
|
|
63
|
-
expect(findParentInTree([rect('root')], 'root')).toBeUndefined()
|
|
64
|
-
})
|
|
65
|
-
})
|
|
75
|
+
expect(findParentInTree([rect('root')], 'root')).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
66
78
|
|
|
67
79
|
describe('removeNodeFromTree', () => {
|
|
68
80
|
it('removes a root node', () => {
|
|
69
|
-
const result = removeNodeFromTree([rect('a'), rect('b')], 'a')
|
|
70
|
-
expect(result).toHaveLength(1)
|
|
71
|
-
expect(result[0].id).toBe('b')
|
|
72
|
-
})
|
|
81
|
+
const result = removeNodeFromTree([rect('a'), rect('b')], 'a');
|
|
82
|
+
expect(result).toHaveLength(1);
|
|
83
|
+
expect(result[0].id).toBe('b');
|
|
84
|
+
});
|
|
73
85
|
|
|
74
86
|
it('removes a nested node', () => {
|
|
75
|
-
const nodes = [frame('parent', [rect('child1'), rect('child2')])]
|
|
76
|
-
const result = removeNodeFromTree(nodes, 'child1')
|
|
77
|
-
const parent = result[0] as PenNode & { children: PenNode[] }
|
|
78
|
-
expect(parent.children).toHaveLength(1)
|
|
79
|
-
expect(parent.children[0].id).toBe('child2')
|
|
80
|
-
})
|
|
81
|
-
})
|
|
87
|
+
const nodes = [frame('parent', [rect('child1'), rect('child2')])];
|
|
88
|
+
const result = removeNodeFromTree(nodes, 'child1');
|
|
89
|
+
const parent = result[0] as PenNode & { children: PenNode[] };
|
|
90
|
+
expect(parent.children).toHaveLength(1);
|
|
91
|
+
expect(parent.children[0].id).toBe('child2');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
82
94
|
|
|
83
95
|
describe('updateNodeInTree', () => {
|
|
84
96
|
it('updates a node by id', () => {
|
|
85
|
-
const nodes = [rect('a')]
|
|
86
|
-
const result = updateNodeInTree(nodes, 'a', { name: 'updated' })
|
|
87
|
-
expect(result[0].name).toBe('updated')
|
|
88
|
-
})
|
|
97
|
+
const nodes = [rect('a')];
|
|
98
|
+
const result = updateNodeInTree(nodes, 'a', { name: 'updated' });
|
|
99
|
+
expect(result[0].name).toBe('updated');
|
|
100
|
+
});
|
|
89
101
|
|
|
90
102
|
it('updates a nested node', () => {
|
|
91
|
-
const nodes = [frame('parent', [rect('child')])]
|
|
92
|
-
const result = updateNodeInTree(nodes, 'child', { name: 'updated' })
|
|
93
|
-
const parent = result[0] as PenNode & { children: PenNode[] }
|
|
94
|
-
expect(parent.children[0].name).toBe('updated')
|
|
95
|
-
})
|
|
96
|
-
|
|
103
|
+
const nodes = [frame('parent', [rect('child')])];
|
|
104
|
+
const result = updateNodeInTree(nodes, 'child', { name: 'updated' });
|
|
105
|
+
const parent = result[0] as PenNode & { children: PenNode[] };
|
|
106
|
+
expect(parent.children[0].name).toBe('updated');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('reuses untouched branches when updating a nested node', () => {
|
|
110
|
+
const sibling = frame('sibling', [rect('sibling-child')]);
|
|
111
|
+
const parent = frame('parent', [rect('child')]);
|
|
112
|
+
const nodes = [parent, sibling];
|
|
113
|
+
|
|
114
|
+
const result = updateNodeInTree(nodes, 'child', { name: 'updated' });
|
|
115
|
+
|
|
116
|
+
expect(result).not.toBe(nodes);
|
|
117
|
+
expect(result[0]).not.toBe(parent);
|
|
118
|
+
expect((result[0] as PenNode & { children: PenNode[] }).children[0].name).toBe('updated');
|
|
119
|
+
expect(result[1]).toBe(sibling);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns the original tree for no-op updates', () => {
|
|
123
|
+
const nodes = [rect('a')];
|
|
124
|
+
const result = updateNodeInTree(nodes, 'a', { x: 0 });
|
|
125
|
+
expect(result).toBe(nodes);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
97
128
|
|
|
98
129
|
describe('flattenNodes', () => {
|
|
99
130
|
it('flattens a nested tree', () => {
|
|
100
|
-
const nodes = [frame('a', [rect('b'), frame('c', [rect('d')])])]
|
|
101
|
-
const flat = flattenNodes(nodes)
|
|
102
|
-
expect(flat.map(n => n.id)).toEqual(['a', 'b', 'c', 'd'])
|
|
103
|
-
})
|
|
104
|
-
})
|
|
131
|
+
const nodes = [frame('a', [rect('b'), frame('c', [rect('d')])])];
|
|
132
|
+
const flat = flattenNodes(nodes);
|
|
133
|
+
expect(flat.map((n) => n.id)).toEqual(['a', 'b', 'c', 'd']);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
105
136
|
|
|
106
137
|
describe('insertNodeInTree', () => {
|
|
107
138
|
it('inserts at root level', () => {
|
|
108
|
-
const result = insertNodeInTree([rect('a')], null, rect('b'))
|
|
109
|
-
expect(result).toHaveLength(2)
|
|
110
|
-
expect(result[1].id).toBe('b')
|
|
111
|
-
})
|
|
139
|
+
const result = insertNodeInTree([rect('a')], null, rect('b'));
|
|
140
|
+
expect(result).toHaveLength(2);
|
|
141
|
+
expect(result[1].id).toBe('b');
|
|
142
|
+
});
|
|
112
143
|
|
|
113
144
|
it('inserts into a parent', () => {
|
|
114
|
-
const nodes = [frame('parent', [rect('existing')])]
|
|
115
|
-
const result = insertNodeInTree(nodes, 'parent', rect('new'))
|
|
116
|
-
const parent = result[0] as PenNode & { children: PenNode[] }
|
|
117
|
-
expect(parent.children).toHaveLength(2)
|
|
118
|
-
expect(parent.children[1].id).toBe('new')
|
|
119
|
-
})
|
|
145
|
+
const nodes = [frame('parent', [rect('existing')])];
|
|
146
|
+
const result = insertNodeInTree(nodes, 'parent', rect('new'));
|
|
147
|
+
const parent = result[0] as PenNode & { children: PenNode[] };
|
|
148
|
+
expect(parent.children).toHaveLength(2);
|
|
149
|
+
expect(parent.children[1].id).toBe('new');
|
|
150
|
+
});
|
|
120
151
|
|
|
121
152
|
it('inserts at a specific index', () => {
|
|
122
|
-
const nodes = [frame('parent', [rect('a'), rect('c')])]
|
|
123
|
-
const result = insertNodeInTree(nodes, 'parent', rect('b'), 1)
|
|
124
|
-
const parent = result[0] as PenNode & { children: PenNode[] }
|
|
125
|
-
expect(parent.children.map(n => n.id)).toEqual(['a', 'b', 'c'])
|
|
126
|
-
})
|
|
127
|
-
})
|
|
153
|
+
const nodes = [frame('parent', [rect('a'), rect('c')])];
|
|
154
|
+
const result = insertNodeInTree(nodes, 'parent', rect('b'), 1);
|
|
155
|
+
const parent = result[0] as PenNode & { children: PenNode[] };
|
|
156
|
+
expect(parent.children.map((n) => n.id)).toEqual(['a', 'b', 'c']);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
128
159
|
|
|
129
160
|
describe('isDescendantOf', () => {
|
|
130
161
|
it('returns true for a descendant', () => {
|
|
131
|
-
const nodes = [frame('a', [frame('b', [rect('c')])])]
|
|
132
|
-
expect(isDescendantOf(nodes, 'c', 'a')).toBe(true)
|
|
133
|
-
})
|
|
162
|
+
const nodes = [frame('a', [frame('b', [rect('c')])])];
|
|
163
|
+
expect(isDescendantOf(nodes, 'c', 'a')).toBe(true);
|
|
164
|
+
});
|
|
134
165
|
|
|
135
166
|
it('returns false for non-descendant', () => {
|
|
136
|
-
const nodes = [frame('a', [rect('b')]), rect('c')]
|
|
137
|
-
expect(isDescendantOf(nodes, 'c', 'a')).toBe(false)
|
|
138
|
-
})
|
|
139
|
-
})
|
|
167
|
+
const nodes = [frame('a', [rect('b')]), rect('c')];
|
|
168
|
+
expect(isDescendantOf(nodes, 'c', 'a')).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
140
171
|
|
|
141
172
|
describe('page helpers', () => {
|
|
142
173
|
it('getActivePageChildren returns page children', () => {
|
|
143
|
-
const doc = createEmptyDocument()
|
|
144
|
-
const children = getActivePageChildren(doc, DEFAULT_PAGE_ID)
|
|
145
|
-
expect(children).toHaveLength(1)
|
|
146
|
-
expect(children[0].id).toBe(DEFAULT_FRAME_ID)
|
|
147
|
-
})
|
|
174
|
+
const doc = createEmptyDocument();
|
|
175
|
+
const children = getActivePageChildren(doc, DEFAULT_PAGE_ID);
|
|
176
|
+
expect(children).toHaveLength(1);
|
|
177
|
+
expect(children[0].id).toBe(DEFAULT_FRAME_ID);
|
|
178
|
+
});
|
|
148
179
|
|
|
149
180
|
it('setActivePageChildren replaces page children', () => {
|
|
150
|
-
const doc = createEmptyDocument()
|
|
151
|
-
const newChildren = [rect('new')]
|
|
152
|
-
const updated = setActivePageChildren(doc, DEFAULT_PAGE_ID, newChildren)
|
|
153
|
-
expect(updated.pages![0].children).toHaveLength(1)
|
|
154
|
-
expect(updated.pages![0].children[0].id).toBe('new')
|
|
155
|
-
})
|
|
181
|
+
const doc = createEmptyDocument();
|
|
182
|
+
const newChildren = [rect('new')];
|
|
183
|
+
const updated = setActivePageChildren(doc, DEFAULT_PAGE_ID, newChildren);
|
|
184
|
+
expect(updated.pages![0].children).toHaveLength(1);
|
|
185
|
+
expect(updated.pages![0].children[0].id).toBe('new');
|
|
186
|
+
});
|
|
156
187
|
|
|
157
188
|
it('migrateToPages wraps legacy doc', () => {
|
|
158
|
-
const legacy: PenDocument = { version: '1.0.0', children: [rect('a')] }
|
|
159
|
-
const migrated = migrateToPages(legacy)
|
|
160
|
-
expect(migrated.pages).toHaveLength(1)
|
|
161
|
-
expect(migrated.pages![0].children[0].id).toBe('a')
|
|
162
|
-
expect(migrated.children).toEqual([])
|
|
163
|
-
})
|
|
189
|
+
const legacy: PenDocument = { version: '1.0.0', children: [rect('a')] };
|
|
190
|
+
const migrated = migrateToPages(legacy);
|
|
191
|
+
expect(migrated.pages).toHaveLength(1);
|
|
192
|
+
expect(migrated.pages![0].children[0].id).toBe('a');
|
|
193
|
+
expect(migrated.children).toEqual([]);
|
|
194
|
+
});
|
|
164
195
|
|
|
165
196
|
it('migrateToPages preserves existing pages', () => {
|
|
166
|
-
const doc = createEmptyDocument()
|
|
167
|
-
expect(migrateToPages(doc)).toBe(doc)
|
|
168
|
-
})
|
|
169
|
-
})
|
|
170
|
-
})
|
|
197
|
+
const doc = createEmptyDocument();
|
|
198
|
+
expect(migrateToPages(doc)).toBe(doc);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
|
+
import { unwrapFakePhoneMockups } from '../layout/unwrap-fake-phone-mockup';
|
|
4
|
+
|
|
5
|
+
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
|
|
6
|
+
({
|
|
7
|
+
id: 'f1',
|
|
8
|
+
type: 'frame',
|
|
9
|
+
...props,
|
|
10
|
+
}) as PenNode;
|
|
11
|
+
|
|
12
|
+
const rect = (id: string, props: Partial<PenNode> = {}): PenNode =>
|
|
13
|
+
({
|
|
14
|
+
id,
|
|
15
|
+
type: 'rectangle',
|
|
16
|
+
width: 50,
|
|
17
|
+
height: 30,
|
|
18
|
+
...props,
|
|
19
|
+
}) as PenNode;
|
|
20
|
+
|
|
21
|
+
describe('unwrapFakePhoneMockups', () => {
|
|
22
|
+
it('leaves a real phone mockup with 0 children alone', () => {
|
|
23
|
+
const phone = frame({
|
|
24
|
+
id: 'phone',
|
|
25
|
+
name: 'Phone Mockup',
|
|
26
|
+
cornerRadius: 32,
|
|
27
|
+
width: 280,
|
|
28
|
+
height: 580,
|
|
29
|
+
children: [],
|
|
30
|
+
});
|
|
31
|
+
const root = frame({ id: 'root', children: [phone] });
|
|
32
|
+
unwrapFakePhoneMockups(root);
|
|
33
|
+
const kids = (root as PenNode & { children: PenNode[] }).children;
|
|
34
|
+
expect(kids).toHaveLength(1);
|
|
35
|
+
expect(kids[0].id).toBe('phone');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('leaves a real phone mockup with a single placeholder child alone', () => {
|
|
39
|
+
const placeholder: PenNode = {
|
|
40
|
+
id: 'placeholder',
|
|
41
|
+
type: 'text',
|
|
42
|
+
content: 'App Preview',
|
|
43
|
+
} as PenNode;
|
|
44
|
+
const phone = frame({
|
|
45
|
+
id: 'phone',
|
|
46
|
+
name: 'Phone Mockup',
|
|
47
|
+
cornerRadius: 32,
|
|
48
|
+
width: 280,
|
|
49
|
+
children: [placeholder],
|
|
50
|
+
});
|
|
51
|
+
const root = frame({ id: 'root', children: [phone] });
|
|
52
|
+
unwrapFakePhoneMockups(root);
|
|
53
|
+
const kids = (root as PenNode & { children: PenNode[] }).children;
|
|
54
|
+
expect(kids).toHaveLength(1);
|
|
55
|
+
expect(kids[0].id).toBe('phone');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('unwraps a fake "Phone Mockup" frame with multiple children', () => {
|
|
59
|
+
const fakePhone = frame({
|
|
60
|
+
id: 'fake-phone',
|
|
61
|
+
name: 'Phone Mockup',
|
|
62
|
+
cornerRadius: 32,
|
|
63
|
+
width: 260,
|
|
64
|
+
height: 456,
|
|
65
|
+
layout: 'horizontal',
|
|
66
|
+
children: [rect('section1'), rect('section2'), rect('section3')],
|
|
67
|
+
});
|
|
68
|
+
const root = frame({
|
|
69
|
+
id: 'root',
|
|
70
|
+
children: [rect('header'), fakePhone],
|
|
71
|
+
});
|
|
72
|
+
unwrapFakePhoneMockups(root);
|
|
73
|
+
const kids = (root as PenNode & { children: PenNode[] }).children;
|
|
74
|
+
expect(kids).toHaveLength(4);
|
|
75
|
+
expect(kids[0].id).toBe('header');
|
|
76
|
+
expect(kids[1].id).toBe('section1');
|
|
77
|
+
expect(kids[2].id).toBe('section2');
|
|
78
|
+
expect(kids[3].id).toBe('section3');
|
|
79
|
+
expect(kids.find((c) => c.id === 'fake-phone')).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('detects fake mockup by visual signature even when name is generic', () => {
|
|
83
|
+
const fakePhone = frame({
|
|
84
|
+
id: 'wrapper',
|
|
85
|
+
name: 'Container',
|
|
86
|
+
cornerRadius: 32,
|
|
87
|
+
width: 280,
|
|
88
|
+
layout: 'vertical',
|
|
89
|
+
children: [rect('a'), rect('b'), rect('c'), rect('d')],
|
|
90
|
+
});
|
|
91
|
+
const root = frame({ id: 'root', children: [fakePhone] });
|
|
92
|
+
unwrapFakePhoneMockups(root);
|
|
93
|
+
const kids = (root as PenNode & { children: PenNode[] }).children;
|
|
94
|
+
expect(kids).toHaveLength(4);
|
|
95
|
+
expect(kids.map((c) => c.id)).toEqual(['a', 'b', 'c', 'd']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('does not unwrap a card-like frame with cornerRadius < phone bezel range', () => {
|
|
99
|
+
// Cards typically use cornerRadius 12-20, not 28-40.
|
|
100
|
+
const card = frame({
|
|
101
|
+
id: 'card',
|
|
102
|
+
name: 'Stat Card',
|
|
103
|
+
cornerRadius: 16,
|
|
104
|
+
width: 200,
|
|
105
|
+
layout: 'vertical',
|
|
106
|
+
children: [rect('icon'), rect('label'), rect('value')],
|
|
107
|
+
});
|
|
108
|
+
const root = frame({ id: 'root', children: [card] });
|
|
109
|
+
unwrapFakePhoneMockups(root);
|
|
110
|
+
const kids = (root as PenNode & { children: PenNode[] }).children;
|
|
111
|
+
expect(kids).toHaveLength(1);
|
|
112
|
+
expect(kids[0].id).toBe('card');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not unwrap a wide frame even with cornerRadius 32', () => {
|
|
116
|
+
// A 600px wide hero card with cornerRadius 32 is not a phone mockup.
|
|
117
|
+
const heroCard = frame({
|
|
118
|
+
id: 'hero',
|
|
119
|
+
name: 'Hero Card',
|
|
120
|
+
cornerRadius: 32,
|
|
121
|
+
width: 600,
|
|
122
|
+
layout: 'vertical',
|
|
123
|
+
children: [rect('a'), rect('b'), rect('c')],
|
|
124
|
+
});
|
|
125
|
+
const root = frame({ id: 'root', children: [heroCard] });
|
|
126
|
+
unwrapFakePhoneMockups(root);
|
|
127
|
+
const kids = (root as PenNode & { children: PenNode[] }).children;
|
|
128
|
+
expect(kids).toHaveLength(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('recurses into nested frames', () => {
|
|
132
|
+
const fakePhone = frame({
|
|
133
|
+
id: 'fake',
|
|
134
|
+
name: 'Phone Mockup',
|
|
135
|
+
cornerRadius: 32,
|
|
136
|
+
width: 260,
|
|
137
|
+
layout: 'horizontal',
|
|
138
|
+
children: [rect('a'), rect('b')],
|
|
139
|
+
});
|
|
140
|
+
const inner = frame({ id: 'inner', children: [fakePhone] });
|
|
141
|
+
const outer = frame({ id: 'outer', children: [inner] });
|
|
142
|
+
unwrapFakePhoneMockups(outer);
|
|
143
|
+
const innerKids = (inner as PenNode & { children: PenNode[] }).children;
|
|
144
|
+
expect(innerKids).toHaveLength(2);
|
|
145
|
+
expect(innerKids[0].id).toBe('a');
|
|
146
|
+
expect(innerKids[1].id).toBe('b');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('does nothing for non-frame leaf nodes', () => {
|
|
150
|
+
const text: PenNode = { id: 't', type: 'text', content: 'hi' } as PenNode;
|
|
151
|
+
expect(() => unwrapFakePhoneMockups(text)).not.toThrow();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns false when no fake phone mockup is found anywhere', () => {
|
|
155
|
+
const root = frame({
|
|
156
|
+
id: 'root',
|
|
157
|
+
layout: 'vertical',
|
|
158
|
+
children: [rect('a'), rect('b'), rect('c')],
|
|
159
|
+
});
|
|
160
|
+
const changed = unwrapFakePhoneMockups(root);
|
|
161
|
+
expect(changed).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns true when a child fake phone mockup is unwrapped', () => {
|
|
165
|
+
const fakePhone = frame({
|
|
166
|
+
id: 'fake',
|
|
167
|
+
name: 'Phone Mockup',
|
|
168
|
+
cornerRadius: 32,
|
|
169
|
+
width: 260,
|
|
170
|
+
layout: 'horizontal',
|
|
171
|
+
children: [rect('a'), rect('b')],
|
|
172
|
+
});
|
|
173
|
+
const root = frame({ id: 'root', children: [fakePhone] });
|
|
174
|
+
const changed = unwrapFakePhoneMockups(root);
|
|
175
|
+
expect(changed).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('returns true when the root itself is sanitized', () => {
|
|
179
|
+
const fakePhoneRoot = frame({
|
|
180
|
+
id: 'phone',
|
|
181
|
+
name: 'Phone Mockup',
|
|
182
|
+
cornerRadius: 32,
|
|
183
|
+
width: 260,
|
|
184
|
+
layout: 'horizontal',
|
|
185
|
+
children: [rect('a'), rect('b')],
|
|
186
|
+
});
|
|
187
|
+
const changed = unwrapFakePhoneMockups(fakePhoneRoot);
|
|
188
|
+
expect(changed).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('returns true when a deeply nested fake phone mockup is unwrapped', () => {
|
|
192
|
+
const fake = frame({
|
|
193
|
+
id: 'fake',
|
|
194
|
+
name: 'Phone Mockup',
|
|
195
|
+
cornerRadius: 32,
|
|
196
|
+
width: 260,
|
|
197
|
+
layout: 'horizontal',
|
|
198
|
+
children: [rect('a')],
|
|
199
|
+
});
|
|
200
|
+
const inner = frame({ id: 'inner', children: [fake] });
|
|
201
|
+
const outer = frame({ id: 'outer', children: [inner] });
|
|
202
|
+
const changed = unwrapFakePhoneMockups(outer);
|
|
203
|
+
expect(changed).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('returns false for a real phone mockup with at most one child', () => {
|
|
207
|
+
const realPhone = frame({
|
|
208
|
+
id: 'phone',
|
|
209
|
+
name: 'Phone Mockup',
|
|
210
|
+
cornerRadius: 32,
|
|
211
|
+
width: 280,
|
|
212
|
+
height: 580,
|
|
213
|
+
children: [],
|
|
214
|
+
});
|
|
215
|
+
const root = frame({ id: 'root', children: [realPhone] });
|
|
216
|
+
const changed = unwrapFakePhoneMockups(root);
|
|
217
|
+
expect(changed).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('sanitizes the root node when the root itself is a fake phone mockup', () => {
|
|
221
|
+
// The actual sub-agent path: bottomNav-root IS the fake wrapper. The
|
|
222
|
+
// function is called on the wrapper itself, with no parent in scope.
|
|
223
|
+
// We can't drop the root, so we sanitize: strip phone bezel visuals,
|
|
224
|
+
// restore vertical layout + fill_container width, drop the misleading
|
|
225
|
+
// name. The children stay (might be duplicates, but at least they're
|
|
226
|
+
// not crushed into a 260px column).
|
|
227
|
+
const fakePhoneRoot = frame({
|
|
228
|
+
id: 'bottomNav-root',
|
|
229
|
+
name: 'Phone Mockup',
|
|
230
|
+
width: 260,
|
|
231
|
+
height: 456,
|
|
232
|
+
cornerRadius: 32,
|
|
233
|
+
fill: [{ type: 'solid', color: '#0A0A0A' }],
|
|
234
|
+
layout: 'horizontal',
|
|
235
|
+
children: [rect('child1'), rect('child2'), rect('child3')],
|
|
236
|
+
});
|
|
237
|
+
unwrapFakePhoneMockups(fakePhoneRoot);
|
|
238
|
+
const sanitized = fakePhoneRoot as PenNode & {
|
|
239
|
+
width?: unknown;
|
|
240
|
+
cornerRadius?: unknown;
|
|
241
|
+
layout?: unknown;
|
|
242
|
+
name?: unknown;
|
|
243
|
+
};
|
|
244
|
+
// Phone mockup visual signature must be gone
|
|
245
|
+
expect(sanitized.cornerRadius).toBeUndefined();
|
|
246
|
+
expect(sanitized.width).toBe('fill_container');
|
|
247
|
+
expect(sanitized.layout).toBe('vertical');
|
|
248
|
+
// Misleading name is cleared
|
|
249
|
+
expect(sanitized.name).not.toBe('Phone Mockup');
|
|
250
|
+
// Children survive — at least they will render in a normal vertical stack
|
|
251
|
+
const kids = (fakePhoneRoot as PenNode & { children: PenNode[] }).children;
|
|
252
|
+
expect(kids).toHaveLength(3);
|
|
253
|
+
expect(kids[0].id).toBe('child1');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('does not sanitize a real phone mockup root with 0 children', () => {
|
|
257
|
+
// Important: when called on a legitimate phone mockup root (no children),
|
|
258
|
+
// it must be left alone — the sanitize branch should only fire when the
|
|
259
|
+
// mockup is structurally invalid (children > 1 OR layout horizontal/vertical).
|
|
260
|
+
const realPhoneRoot = frame({
|
|
261
|
+
id: 'phone',
|
|
262
|
+
name: 'Phone Mockup',
|
|
263
|
+
cornerRadius: 32,
|
|
264
|
+
width: 280,
|
|
265
|
+
height: 580,
|
|
266
|
+
children: [],
|
|
267
|
+
});
|
|
268
|
+
unwrapFakePhoneMockups(realPhoneRoot);
|
|
269
|
+
const node = realPhoneRoot as PenNode & {
|
|
270
|
+
cornerRadius?: unknown;
|
|
271
|
+
width?: unknown;
|
|
272
|
+
name?: unknown;
|
|
273
|
+
};
|
|
274
|
+
expect(node.cornerRadius).toBe(32);
|
|
275
|
+
expect(node.width).toBe(280);
|
|
276
|
+
expect(node.name).toBe('Phone Mockup');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('reproduces the M2.7 health-tracker bottomNav case', () => {
|
|
280
|
+
// Direct repro of the actual failing case: bottomNav-root frame named
|
|
281
|
+
// "Phone Mockup", w=260, h=456, cornerRadius=32, layout=horizontal,
|
|
282
|
+
// wrapping a complete copy of the entire mobile app (6 sections).
|
|
283
|
+
const fakeBottomNav = frame({
|
|
284
|
+
id: 'bottomNav-root',
|
|
285
|
+
name: 'Phone Mockup',
|
|
286
|
+
width: 260,
|
|
287
|
+
height: 456,
|
|
288
|
+
cornerRadius: 32,
|
|
289
|
+
fill: [{ type: 'solid', color: '#0A0A0A' }],
|
|
290
|
+
layout: 'horizontal',
|
|
291
|
+
children: [
|
|
292
|
+
frame({ id: 'bottomNav-header', name: 'Header' }),
|
|
293
|
+
frame({ id: 'bottomNav-activity-section', name: 'Activity Section' }),
|
|
294
|
+
frame({ id: 'bottomNav-heart-card', name: 'Heart Rate Card' }),
|
|
295
|
+
frame({ id: 'bottomNav-weekly-section', name: 'Weekly Summary' }),
|
|
296
|
+
frame({ id: 'bottomNav-upcoming-section', name: 'Upcoming Section' }),
|
|
297
|
+
frame({ id: 'bottomNav-tab-bar', name: 'Tab Bar' }),
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
const rootFrame = frame({
|
|
301
|
+
id: 'root-frame',
|
|
302
|
+
name: 'Health Tracker Home',
|
|
303
|
+
layout: 'vertical',
|
|
304
|
+
children: [
|
|
305
|
+
frame({ id: 'header-root', name: 'Header Section' }),
|
|
306
|
+
frame({ id: 'activityRings-root', name: 'Activity Rings Section' }),
|
|
307
|
+
frame({ id: 'heartRate-root', name: 'HeartRateCard' }),
|
|
308
|
+
frame({ id: 'weeklyWorkout-root', name: 'Weekly Workout Summary' }),
|
|
309
|
+
frame({ id: 'upcomingWorkouts-root', name: 'Upcoming Workouts Section' }),
|
|
310
|
+
fakeBottomNav,
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
unwrapFakePhoneMockups(rootFrame);
|
|
314
|
+
const kids = (rootFrame as PenNode & { children: PenNode[] }).children;
|
|
315
|
+
// Original 5 real sections + 6 unwrapped grandchildren = 11
|
|
316
|
+
expect(kids).toHaveLength(11);
|
|
317
|
+
// Fake wrapper is gone
|
|
318
|
+
expect(kids.find((c) => c.id === 'bottomNav-root')).toBeUndefined();
|
|
319
|
+
// Real tab-bar is now a direct child of root-frame
|
|
320
|
+
expect(kids.find((c) => c.id === 'bottomNav-tab-bar')).toBeDefined();
|
|
321
|
+
});
|
|
322
|
+
});
|