@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,173 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
|
+
import { resolveNodeForCanvas } from '../variables/resolve';
|
|
4
|
+
|
|
5
|
+
describe('resolveNodeForCanvas — recursive', () => {
|
|
6
|
+
const variables = {
|
|
7
|
+
spacing: { name: 'spacing', type: 'number' as const, value: 16 },
|
|
8
|
+
'bg-color': { name: 'bg-color', type: 'color' as const, value: '#ff0000' },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
it('resolves $variable gap on nested child frame', () => {
|
|
12
|
+
const doc: PenNode = {
|
|
13
|
+
id: 'root',
|
|
14
|
+
type: 'frame',
|
|
15
|
+
x: 0,
|
|
16
|
+
y: 0,
|
|
17
|
+
width: 400,
|
|
18
|
+
height: 600,
|
|
19
|
+
layout: 'vertical',
|
|
20
|
+
children: [
|
|
21
|
+
{
|
|
22
|
+
id: 'child',
|
|
23
|
+
type: 'frame',
|
|
24
|
+
x: 0,
|
|
25
|
+
y: 0,
|
|
26
|
+
width: 300,
|
|
27
|
+
height: 200,
|
|
28
|
+
layout: 'vertical',
|
|
29
|
+
gap: '$spacing' as unknown as number,
|
|
30
|
+
children: [
|
|
31
|
+
{ id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' },
|
|
32
|
+
{ id: 'text2', type: 'text', x: 0, y: 0, content: 'World' },
|
|
33
|
+
],
|
|
34
|
+
} as PenNode,
|
|
35
|
+
],
|
|
36
|
+
} as PenNode;
|
|
37
|
+
|
|
38
|
+
const result = resolveNodeForCanvas(doc, variables);
|
|
39
|
+
const childFrame = (result as any).children[0];
|
|
40
|
+
expect(childFrame.gap).toBe(16);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('resolves $variable fill on deeply nested text node', () => {
|
|
44
|
+
const doc: PenNode = {
|
|
45
|
+
id: 'root',
|
|
46
|
+
type: 'frame',
|
|
47
|
+
x: 0,
|
|
48
|
+
y: 0,
|
|
49
|
+
width: 400,
|
|
50
|
+
height: 600,
|
|
51
|
+
children: [
|
|
52
|
+
{
|
|
53
|
+
id: 'child',
|
|
54
|
+
type: 'frame',
|
|
55
|
+
x: 0,
|
|
56
|
+
y: 0,
|
|
57
|
+
width: 300,
|
|
58
|
+
height: 200,
|
|
59
|
+
children: [
|
|
60
|
+
{
|
|
61
|
+
id: 'text1',
|
|
62
|
+
type: 'text',
|
|
63
|
+
x: 0,
|
|
64
|
+
y: 0,
|
|
65
|
+
content: 'Hello',
|
|
66
|
+
fill: [{ type: 'solid', color: '$bg-color' }],
|
|
67
|
+
} as PenNode,
|
|
68
|
+
],
|
|
69
|
+
} as PenNode,
|
|
70
|
+
],
|
|
71
|
+
} as PenNode;
|
|
72
|
+
|
|
73
|
+
const result = resolveNodeForCanvas(doc, variables);
|
|
74
|
+
const textNode = (result as any).children[0].children[0];
|
|
75
|
+
expect(textNode.fill[0].color).toBe('#ff0000');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('resolves $variable padding on nested frame', () => {
|
|
79
|
+
const doc: PenNode = {
|
|
80
|
+
id: 'root',
|
|
81
|
+
type: 'frame',
|
|
82
|
+
x: 0,
|
|
83
|
+
y: 0,
|
|
84
|
+
width: 400,
|
|
85
|
+
height: 600,
|
|
86
|
+
children: [
|
|
87
|
+
{
|
|
88
|
+
id: 'child',
|
|
89
|
+
type: 'frame',
|
|
90
|
+
x: 0,
|
|
91
|
+
y: 0,
|
|
92
|
+
width: 300,
|
|
93
|
+
height: 200,
|
|
94
|
+
padding: '$spacing' as unknown as number,
|
|
95
|
+
children: [],
|
|
96
|
+
} as PenNode,
|
|
97
|
+
],
|
|
98
|
+
} as PenNode;
|
|
99
|
+
|
|
100
|
+
const result = resolveNodeForCanvas(doc, variables);
|
|
101
|
+
const childFrame = (result as any).children[0];
|
|
102
|
+
expect(childFrame.padding).toBe(16);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns same reference when no variables exist', () => {
|
|
106
|
+
const doc: PenNode = {
|
|
107
|
+
id: 'root',
|
|
108
|
+
type: 'frame',
|
|
109
|
+
x: 0,
|
|
110
|
+
y: 0,
|
|
111
|
+
width: 400,
|
|
112
|
+
height: 600,
|
|
113
|
+
children: [{ id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' }],
|
|
114
|
+
} as PenNode;
|
|
115
|
+
|
|
116
|
+
const result = resolveNodeForCanvas(doc, {});
|
|
117
|
+
expect(result).toBe(doc);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('resolves $variable opacity on nested text node', () => {
|
|
121
|
+
const variables3 = {
|
|
122
|
+
'text-opacity': { name: 'text-opacity', type: 'number' as const, value: 0.5 },
|
|
123
|
+
};
|
|
124
|
+
const doc: PenNode = {
|
|
125
|
+
id: 'root',
|
|
126
|
+
type: 'frame',
|
|
127
|
+
x: 0,
|
|
128
|
+
y: 0,
|
|
129
|
+
width: 400,
|
|
130
|
+
height: 600,
|
|
131
|
+
children: [
|
|
132
|
+
{
|
|
133
|
+
id: 'child',
|
|
134
|
+
type: 'frame',
|
|
135
|
+
x: 0,
|
|
136
|
+
y: 0,
|
|
137
|
+
width: 300,
|
|
138
|
+
height: 200,
|
|
139
|
+
children: [
|
|
140
|
+
{
|
|
141
|
+
id: 'text1',
|
|
142
|
+
type: 'text',
|
|
143
|
+
x: 0,
|
|
144
|
+
y: 0,
|
|
145
|
+
content: 'Hello',
|
|
146
|
+
opacity: '$text-opacity' as unknown as number,
|
|
147
|
+
} as PenNode,
|
|
148
|
+
],
|
|
149
|
+
} as PenNode,
|
|
150
|
+
],
|
|
151
|
+
} as PenNode;
|
|
152
|
+
|
|
153
|
+
const result = resolveNodeForCanvas(doc, variables3);
|
|
154
|
+
const textNode = (result as any).children[0].children[0];
|
|
155
|
+
expect(textNode.opacity).toBe(0.5);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('preserves reference when children have no variables', () => {
|
|
159
|
+
const variables2 = { color1: { name: 'color1', type: 'color' as const, value: '#000' } };
|
|
160
|
+
const doc: PenNode = {
|
|
161
|
+
id: 'root',
|
|
162
|
+
type: 'frame',
|
|
163
|
+
x: 0,
|
|
164
|
+
y: 0,
|
|
165
|
+
width: 400,
|
|
166
|
+
height: 600,
|
|
167
|
+
children: [{ id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' }],
|
|
168
|
+
} as PenNode;
|
|
169
|
+
|
|
170
|
+
const result = resolveNodeForCanvas(doc, variables2);
|
|
171
|
+
expect(result).toBe(doc);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
|
+
import { stripRedundantSectionFills } from '../layout/strip-redundant-section-fills';
|
|
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 solidFill = (color: string) => [{ type: 'solid' as const, color }];
|
|
13
|
+
|
|
14
|
+
describe('stripRedundantSectionFills', () => {
|
|
15
|
+
it('strips a section fill that exactly matches the root fill', () => {
|
|
16
|
+
const section = frame({
|
|
17
|
+
id: 'sec1',
|
|
18
|
+
name: 'Section',
|
|
19
|
+
fill: solidFill('#1a1a2e'),
|
|
20
|
+
children: [frame({ id: 'child' })],
|
|
21
|
+
});
|
|
22
|
+
const root = frame({
|
|
23
|
+
id: 'root',
|
|
24
|
+
fill: solidFill('#1a1a2e'),
|
|
25
|
+
children: [section],
|
|
26
|
+
});
|
|
27
|
+
const changed = stripRedundantSectionFills(root);
|
|
28
|
+
expect(changed).toBe(true);
|
|
29
|
+
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('strips a section fill that matches a common safe-dark tint', () => {
|
|
33
|
+
// Root has #1a1a2e (deep navy), section has #0A0A0A (near-black safe
|
|
34
|
+
// dark) — the classic M2.7 failure where the model picks a "safe"
|
|
35
|
+
// dark for every section root, hiding the intended root background.
|
|
36
|
+
const section = frame({
|
|
37
|
+
id: 'sec1',
|
|
38
|
+
name: 'Activity Rings Section',
|
|
39
|
+
fill: solidFill('#0A0A0A'),
|
|
40
|
+
children: [frame({ id: 'child' })],
|
|
41
|
+
});
|
|
42
|
+
const root = frame({
|
|
43
|
+
id: 'root',
|
|
44
|
+
fill: solidFill('#1a1a2e'),
|
|
45
|
+
children: [section],
|
|
46
|
+
});
|
|
47
|
+
stripRedundantSectionFills(root);
|
|
48
|
+
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does not strip fill from a card (cards own their visual fill)', () => {
|
|
52
|
+
const card = frame({
|
|
53
|
+
id: 'card1',
|
|
54
|
+
name: 'Stat Card',
|
|
55
|
+
role: 'card',
|
|
56
|
+
fill: solidFill('#0A0A0A'),
|
|
57
|
+
cornerRadius: 12,
|
|
58
|
+
children: [frame({ id: 'child' })],
|
|
59
|
+
});
|
|
60
|
+
const root = frame({
|
|
61
|
+
id: 'root',
|
|
62
|
+
fill: solidFill('#1a1a2e'),
|
|
63
|
+
children: [card],
|
|
64
|
+
});
|
|
65
|
+
const changed = stripRedundantSectionFills(root);
|
|
66
|
+
expect(changed).toBe(false);
|
|
67
|
+
expect((card as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not strip fill from a button', () => {
|
|
71
|
+
const button = frame({
|
|
72
|
+
id: 'btn',
|
|
73
|
+
name: 'CTA Button',
|
|
74
|
+
role: 'button',
|
|
75
|
+
fill: solidFill('#0A0A0A'),
|
|
76
|
+
children: [frame({ id: 'label' })],
|
|
77
|
+
});
|
|
78
|
+
const root = frame({
|
|
79
|
+
id: 'root',
|
|
80
|
+
fill: solidFill('#1a1a2e'),
|
|
81
|
+
children: [button],
|
|
82
|
+
});
|
|
83
|
+
stripRedundantSectionFills(root);
|
|
84
|
+
expect((button as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('does not strip fill from a badge or chip', () => {
|
|
88
|
+
const badge = frame({
|
|
89
|
+
id: 'bd',
|
|
90
|
+
name: 'Badge',
|
|
91
|
+
role: 'badge',
|
|
92
|
+
fill: solidFill('#0A0A0A'),
|
|
93
|
+
});
|
|
94
|
+
const chip = frame({
|
|
95
|
+
id: 'ch',
|
|
96
|
+
name: 'Chip',
|
|
97
|
+
role: 'chip',
|
|
98
|
+
fill: solidFill('#0A0A0A'),
|
|
99
|
+
});
|
|
100
|
+
const root = frame({
|
|
101
|
+
id: 'root',
|
|
102
|
+
fill: solidFill('#1a1a2e'),
|
|
103
|
+
children: [badge, chip],
|
|
104
|
+
});
|
|
105
|
+
stripRedundantSectionFills(root);
|
|
106
|
+
expect((badge as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
|
|
107
|
+
expect((chip as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does not strip a fill that is clearly distinct from root (intentional)', () => {
|
|
111
|
+
// #FF5733 is nothing like root's #1a1a2e and is not a safe-dark — it
|
|
112
|
+
// is probably a deliberate accent / hero section. Leave it.
|
|
113
|
+
const hero = frame({
|
|
114
|
+
id: 'hero',
|
|
115
|
+
name: 'Hero Section',
|
|
116
|
+
fill: solidFill('#FF5733'),
|
|
117
|
+
children: [frame({ id: 'headline' })],
|
|
118
|
+
});
|
|
119
|
+
const root = frame({
|
|
120
|
+
id: 'root',
|
|
121
|
+
fill: solidFill('#1a1a2e'),
|
|
122
|
+
children: [hero],
|
|
123
|
+
});
|
|
124
|
+
const changed = stripRedundantSectionFills(root);
|
|
125
|
+
expect(changed).toBe(false);
|
|
126
|
+
expect((hero as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#FF5733'));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('strips fills from multiple sections in one pass', () => {
|
|
130
|
+
const section1 = frame({ id: 's1', fill: solidFill('#0A0A0A') });
|
|
131
|
+
const section2 = frame({ id: 's2', fill: solidFill('#0A0A0A') });
|
|
132
|
+
const section3 = frame({ id: 's3', fill: solidFill('#0A0A0A') });
|
|
133
|
+
const root = frame({
|
|
134
|
+
id: 'root',
|
|
135
|
+
fill: solidFill('#1a1a2e'),
|
|
136
|
+
children: [section1, section2, section3],
|
|
137
|
+
});
|
|
138
|
+
stripRedundantSectionFills(root);
|
|
139
|
+
expect((section1 as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
140
|
+
expect((section2 as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
141
|
+
expect((section3 as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not touch deeply nested frames inside a section', () => {
|
|
145
|
+
// Only direct children of the root are considered "section level". A
|
|
146
|
+
// card nested three levels deep with the same color should be left
|
|
147
|
+
// alone — it is not a top-level section.
|
|
148
|
+
const deepCard = frame({
|
|
149
|
+
id: 'deep-card',
|
|
150
|
+
role: 'card',
|
|
151
|
+
fill: solidFill('#0A0A0A'),
|
|
152
|
+
});
|
|
153
|
+
const middle = frame({ id: 'middle', children: [deepCard] });
|
|
154
|
+
const section = frame({
|
|
155
|
+
id: 'section',
|
|
156
|
+
fill: solidFill('#0A0A0A'),
|
|
157
|
+
children: [middle],
|
|
158
|
+
});
|
|
159
|
+
const root = frame({
|
|
160
|
+
id: 'root',
|
|
161
|
+
fill: solidFill('#1a1a2e'),
|
|
162
|
+
children: [section],
|
|
163
|
+
});
|
|
164
|
+
stripRedundantSectionFills(root);
|
|
165
|
+
// Section (direct child) is stripped
|
|
166
|
+
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
167
|
+
// Deep card is left alone
|
|
168
|
+
expect((deepCard as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('returns false when there is nothing to strip', () => {
|
|
172
|
+
const root = frame({
|
|
173
|
+
id: 'root',
|
|
174
|
+
fill: solidFill('#1a1a2e'),
|
|
175
|
+
children: [
|
|
176
|
+
frame({ id: 's1' }), // no fill
|
|
177
|
+
frame({
|
|
178
|
+
id: 'card1',
|
|
179
|
+
role: 'card',
|
|
180
|
+
fill: solidFill('#0A0A0A'), // card protected
|
|
181
|
+
}),
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
const changed = stripRedundantSectionFills(root);
|
|
185
|
+
expect(changed).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('handles a root frame without a fill (treats only safe-dark sections)', () => {
|
|
189
|
+
// Root has no fill; we still strip sections that carry a safe-dark
|
|
190
|
+
// "default" fill, because those are almost certainly the sub-agent
|
|
191
|
+
// hedging against a missing background spec.
|
|
192
|
+
const section = frame({
|
|
193
|
+
id: 'sec',
|
|
194
|
+
fill: solidFill('#0A0A0A'),
|
|
195
|
+
});
|
|
196
|
+
const root = frame({
|
|
197
|
+
id: 'root',
|
|
198
|
+
children: [section],
|
|
199
|
+
});
|
|
200
|
+
stripRedundantSectionFills(root);
|
|
201
|
+
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('is strictly non-recursive: never touches grandchildren even when caller mis-targets a card', () => {
|
|
205
|
+
// Defensive: if a caller accidentally hands us a card frame instead of
|
|
206
|
+
// the page root, we must NOT recurse into it. Only the direct children
|
|
207
|
+
// of the passed node are ever considered — and a card header (no role,
|
|
208
|
+
// safe-dark fill) that is a DIRECT child of a card is still fair game,
|
|
209
|
+
// but anything deeper is untouched.
|
|
210
|
+
const deepInner = frame({
|
|
211
|
+
id: 'deep',
|
|
212
|
+
// no role, safe-dark — would normally be stripped, but is two levels
|
|
213
|
+
// down so must survive
|
|
214
|
+
fill: solidFill('#0A0A0A'),
|
|
215
|
+
});
|
|
216
|
+
const cardBody = frame({ id: 'body', children: [deepInner] });
|
|
217
|
+
const cardHeader = frame({
|
|
218
|
+
id: 'header',
|
|
219
|
+
// no role, safe-dark — direct child of the mis-targeted parent, so
|
|
220
|
+
// will still be stripped (the caller is at fault)
|
|
221
|
+
fill: solidFill('#0A0A0A'),
|
|
222
|
+
});
|
|
223
|
+
const card = frame({
|
|
224
|
+
id: 'card',
|
|
225
|
+
role: 'card',
|
|
226
|
+
fill: solidFill('#141414'),
|
|
227
|
+
children: [cardHeader, cardBody],
|
|
228
|
+
});
|
|
229
|
+
// Deliberately mis-target the card (not the page root). This must NOT
|
|
230
|
+
// crash and must NOT recurse into cardBody's grandchildren.
|
|
231
|
+
stripRedundantSectionFills(card);
|
|
232
|
+
// Card itself is untouched (we never touch the passed node)
|
|
233
|
+
expect((card as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#141414'));
|
|
234
|
+
// deepInner survives because strip is strictly non-recursive
|
|
235
|
+
expect((deepInner as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('reproduces the M2.7 health-tracker case', () => {
|
|
239
|
+
// Direct repro of the actual failure: root #1a1a2e, six section roots
|
|
240
|
+
// all hardcoded #0A0A0A, including one real card. The six section
|
|
241
|
+
// fills get stripped, the card keeps its fill.
|
|
242
|
+
const root = frame({
|
|
243
|
+
id: 'root-frame',
|
|
244
|
+
name: 'Health Dashboard',
|
|
245
|
+
fill: solidFill('#1a1a2e'),
|
|
246
|
+
children: [
|
|
247
|
+
frame({ id: 'header-root', name: 'Greeting Header', fill: solidFill('#0A0A0A') }),
|
|
248
|
+
frame({
|
|
249
|
+
id: 'activityRings-root',
|
|
250
|
+
name: 'Activity Rings Section',
|
|
251
|
+
fill: solidFill('#0A0A0A'),
|
|
252
|
+
}),
|
|
253
|
+
frame({
|
|
254
|
+
id: 'heartRate-root',
|
|
255
|
+
name: 'Heart Rate Card Section',
|
|
256
|
+
fill: solidFill('#0A0A0A'),
|
|
257
|
+
}),
|
|
258
|
+
frame({
|
|
259
|
+
id: 'workoutChart-root',
|
|
260
|
+
name: 'Weekly Workout Chart',
|
|
261
|
+
fill: solidFill('#0A0A0A'),
|
|
262
|
+
}),
|
|
263
|
+
frame({
|
|
264
|
+
id: 'upcomingWorkouts-root',
|
|
265
|
+
name: 'Upcoming Workouts',
|
|
266
|
+
fill: solidFill('#0A0A0A'),
|
|
267
|
+
}),
|
|
268
|
+
frame({ id: 'bottomNav-root', name: 'Bottom Tab Bar', fill: solidFill('#0A0A0A') }),
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
const changed = stripRedundantSectionFills(root);
|
|
272
|
+
expect(changed).toBe(true);
|
|
273
|
+
const kids = (root as PenNode & { children: PenNode[] }).children;
|
|
274
|
+
for (const section of kids) {
|
|
275
|
+
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -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
|
parseSizing,
|
|
5
5
|
defaultLineHeight,
|
|
@@ -11,137 +11,142 @@ import {
|
|
|
11
11
|
resolveTextContent,
|
|
12
12
|
countExplicitTextLines,
|
|
13
13
|
estimateTextHeight,
|
|
14
|
-
} from '../layout/text-measure'
|
|
14
|
+
} from '../layout/text-measure';
|
|
15
15
|
|
|
16
16
|
describe('text-measure', () => {
|
|
17
17
|
describe('parseSizing', () => {
|
|
18
18
|
it('returns number for number input', () => {
|
|
19
|
-
expect(parseSizing(100)).toBe(100)
|
|
20
|
-
})
|
|
19
|
+
expect(parseSizing(100)).toBe(100);
|
|
20
|
+
});
|
|
21
21
|
|
|
22
22
|
it('returns "fill" for fill_container', () => {
|
|
23
|
-
expect(parseSizing('fill_container')).toBe('fill')
|
|
24
|
-
})
|
|
23
|
+
expect(parseSizing('fill_container')).toBe('fill');
|
|
24
|
+
});
|
|
25
25
|
|
|
26
26
|
it('returns "fit" for fit_content', () => {
|
|
27
|
-
expect(parseSizing('fit_content')).toBe('fit')
|
|
28
|
-
})
|
|
27
|
+
expect(parseSizing('fit_content')).toBe('fit');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('uses numeric hints from fit_content(N)', () => {
|
|
31
|
+
expect(parseSizing('fit_content(600)')).toBe(600);
|
|
32
|
+
});
|
|
29
33
|
|
|
30
34
|
it('parses numeric strings', () => {
|
|
31
|
-
expect(parseSizing('200')).toBe(200)
|
|
32
|
-
})
|
|
35
|
+
expect(parseSizing('200')).toBe(200);
|
|
36
|
+
});
|
|
33
37
|
|
|
34
38
|
it('returns 0 for non-parseable', () => {
|
|
35
|
-
expect(parseSizing(undefined)).toBe(0)
|
|
36
|
-
expect(parseSizing('abc')).toBe(0)
|
|
37
|
-
})
|
|
38
|
-
})
|
|
39
|
+
expect(parseSizing(undefined)).toBe(0);
|
|
40
|
+
expect(parseSizing('abc')).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
39
43
|
|
|
40
44
|
describe('defaultLineHeight', () => {
|
|
41
45
|
it('returns tight leading for display text', () => {
|
|
42
|
-
expect(defaultLineHeight(48)).toBe(1.0)
|
|
43
|
-
})
|
|
46
|
+
expect(defaultLineHeight(48)).toBe(1.0);
|
|
47
|
+
});
|
|
44
48
|
|
|
45
49
|
it('returns comfortable leading for body text', () => {
|
|
46
|
-
expect(defaultLineHeight(14)).toBe(1.5)
|
|
47
|
-
})
|
|
48
|
-
})
|
|
50
|
+
expect(defaultLineHeight(14)).toBe(1.5);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
49
53
|
|
|
50
54
|
describe('CJK detection', () => {
|
|
51
55
|
it('detects CJK code points', () => {
|
|
52
|
-
expect(isCjkCodePoint('中'.codePointAt(0)!)).toBe(true)
|
|
53
|
-
expect(isCjkCodePoint('あ'.codePointAt(0)!)).toBe(true)
|
|
54
|
-
expect(isCjkCodePoint('A'.codePointAt(0)!)).toBe(false)
|
|
55
|
-
})
|
|
56
|
+
expect(isCjkCodePoint('中'.codePointAt(0)!)).toBe(true);
|
|
57
|
+
expect(isCjkCodePoint('あ'.codePointAt(0)!)).toBe(true);
|
|
58
|
+
expect(isCjkCodePoint('A'.codePointAt(0)!)).toBe(false);
|
|
59
|
+
});
|
|
56
60
|
|
|
57
61
|
it('hasCjkText detects CJK in strings', () => {
|
|
58
|
-
expect(hasCjkText('Hello 世界')).toBe(true)
|
|
59
|
-
expect(hasCjkText('Hello World')).toBe(false)
|
|
60
|
-
})
|
|
61
|
-
})
|
|
62
|
+
expect(hasCjkText('Hello 世界')).toBe(true);
|
|
63
|
+
expect(hasCjkText('Hello World')).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
62
66
|
|
|
63
67
|
describe('estimateGlyphWidth', () => {
|
|
64
68
|
it('returns 0 for newline', () => {
|
|
65
|
-
expect(estimateGlyphWidth('\n', 16)).toBe(0)
|
|
66
|
-
})
|
|
69
|
+
expect(estimateGlyphWidth('\n', 16)).toBe(0);
|
|
70
|
+
});
|
|
67
71
|
|
|
68
72
|
it('estimates CJK wider than Latin', () => {
|
|
69
|
-
const cjk = estimateGlyphWidth('中', 16)
|
|
70
|
-
const latin = estimateGlyphWidth('a', 16)
|
|
71
|
-
expect(cjk).toBeGreaterThan(latin)
|
|
72
|
-
})
|
|
73
|
+
const cjk = estimateGlyphWidth('中', 16);
|
|
74
|
+
const latin = estimateGlyphWidth('a', 16);
|
|
75
|
+
expect(cjk).toBeGreaterThan(latin);
|
|
76
|
+
});
|
|
73
77
|
|
|
74
78
|
it('estimates uppercase wider than lowercase', () => {
|
|
75
|
-
const upper = estimateGlyphWidth('A', 16)
|
|
76
|
-
const lower = estimateGlyphWidth('a', 16)
|
|
77
|
-
expect(upper).toBeGreaterThan(lower)
|
|
78
|
-
})
|
|
79
|
-
})
|
|
79
|
+
const upper = estimateGlyphWidth('A', 16);
|
|
80
|
+
const lower = estimateGlyphWidth('a', 16);
|
|
81
|
+
expect(upper).toBeGreaterThan(lower);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
80
84
|
|
|
81
85
|
describe('estimateLineWidth', () => {
|
|
82
86
|
it('estimates width of a line', () => {
|
|
83
|
-
const width = estimateLineWidth('Hello', 16)
|
|
84
|
-
expect(width).toBeGreaterThan(0)
|
|
85
|
-
})
|
|
87
|
+
const width = estimateLineWidth('Hello', 16);
|
|
88
|
+
expect(width).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
86
90
|
|
|
87
91
|
it('adds letter spacing', () => {
|
|
88
|
-
const base = estimateLineWidth('AB', 16, 0)
|
|
89
|
-
const spaced = estimateLineWidth('AB', 16, 5)
|
|
90
|
-
expect(spaced).toBeGreaterThan(base)
|
|
91
|
-
})
|
|
92
|
-
})
|
|
92
|
+
const base = estimateLineWidth('AB', 16, 0);
|
|
93
|
+
const spaced = estimateLineWidth('AB', 16, 5);
|
|
94
|
+
expect(spaced).toBeGreaterThan(base);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
93
97
|
|
|
94
98
|
describe('estimateTextWidth', () => {
|
|
95
99
|
it('returns the widest line', () => {
|
|
96
|
-
const width = estimateTextWidth('short\nmuch longer line', 16)
|
|
97
|
-
const singleWidth = estimateTextWidth('much longer line', 16)
|
|
100
|
+
const width = estimateTextWidth('short\nmuch longer line', 16);
|
|
101
|
+
const singleWidth = estimateTextWidth('much longer line', 16);
|
|
98
102
|
// Multi-line should return width of longest line
|
|
99
|
-
expect(width).toBeCloseTo(singleWidth, 0)
|
|
100
|
-
})
|
|
101
|
-
})
|
|
103
|
+
expect(width).toBeCloseTo(singleWidth, 0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
102
106
|
|
|
103
107
|
describe('resolveTextContent', () => {
|
|
104
108
|
it('resolves string content', () => {
|
|
105
|
-
const node: PenNode = { id: '1', type: 'text', content: 'Hello' }
|
|
106
|
-
expect(resolveTextContent(node)).toBe('Hello')
|
|
107
|
-
})
|
|
109
|
+
const node: PenNode = { id: '1', type: 'text', content: 'Hello' };
|
|
110
|
+
expect(resolveTextContent(node)).toBe('Hello');
|
|
111
|
+
});
|
|
108
112
|
|
|
109
113
|
it('resolves styled segment content', () => {
|
|
110
114
|
const node: PenNode = {
|
|
111
|
-
id: '1',
|
|
115
|
+
id: '1',
|
|
116
|
+
type: 'text',
|
|
112
117
|
content: [{ text: 'Hello ' }, { text: 'World' }],
|
|
113
|
-
}
|
|
114
|
-
expect(resolveTextContent(node)).toBe('Hello World')
|
|
115
|
-
})
|
|
118
|
+
};
|
|
119
|
+
expect(resolveTextContent(node)).toBe('Hello World');
|
|
120
|
+
});
|
|
116
121
|
|
|
117
122
|
it('returns empty for non-text nodes', () => {
|
|
118
|
-
const node: PenNode = { id: '1', type: 'rectangle' }
|
|
119
|
-
expect(resolveTextContent(node)).toBe('')
|
|
120
|
-
})
|
|
121
|
-
})
|
|
123
|
+
const node: PenNode = { id: '1', type: 'rectangle' };
|
|
124
|
+
expect(resolveTextContent(node)).toBe('');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
122
127
|
|
|
123
128
|
describe('countExplicitTextLines', () => {
|
|
124
129
|
it('counts newlines', () => {
|
|
125
|
-
expect(countExplicitTextLines('a\nb\nc')).toBe(3)
|
|
126
|
-
})
|
|
130
|
+
expect(countExplicitTextLines('a\nb\nc')).toBe(3);
|
|
131
|
+
});
|
|
127
132
|
|
|
128
133
|
it('returns 1 for empty string', () => {
|
|
129
|
-
expect(countExplicitTextLines('')).toBe(1)
|
|
130
|
-
})
|
|
131
|
-
})
|
|
134
|
+
expect(countExplicitTextLines('')).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
132
137
|
|
|
133
138
|
describe('estimateTextHeight', () => {
|
|
134
139
|
it('estimates height for single-line text', () => {
|
|
135
|
-
const node: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 }
|
|
136
|
-
const height = estimateTextHeight(node)
|
|
137
|
-
expect(height).toBeGreaterThan(0)
|
|
138
|
-
expect(height).toBeLessThan(50)
|
|
139
|
-
})
|
|
140
|
+
const node: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 };
|
|
141
|
+
const height = estimateTextHeight(node);
|
|
142
|
+
expect(height).toBeGreaterThan(0);
|
|
143
|
+
expect(height).toBeLessThan(50);
|
|
144
|
+
});
|
|
140
145
|
|
|
141
146
|
it('estimates taller for multi-line text', () => {
|
|
142
|
-
const single: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 }
|
|
143
|
-
const multi: PenNode = { id: '2', type: 'text', content: 'Hello\nWorld', fontSize: 16 }
|
|
144
|
-
expect(estimateTextHeight(multi)).toBeGreaterThan(estimateTextHeight(single))
|
|
145
|
-
})
|
|
146
|
-
})
|
|
147
|
-
})
|
|
147
|
+
const single: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 };
|
|
148
|
+
const multi: PenNode = { id: '2', type: 'text', content: 'Hello\nWorld', fontSize: 16 };
|
|
149
|
+
expect(estimateTextHeight(multi)).toBeGreaterThan(estimateTextHeight(single));
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|