@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.
Files changed (45) hide show
  1. package/README.md +18 -9
  2. package/package.json +5 -5
  3. package/src/__tests__/arc-path.test.ts +23 -23
  4. package/src/__tests__/codegen-utils.test.ts +50 -0
  5. package/src/__tests__/design-md-parser.test.ts +49 -0
  6. package/src/__tests__/font-utils.test.ts +15 -15
  7. package/src/__tests__/layout-engine.test.ts +169 -83
  8. package/src/__tests__/merge-helpers.test.ts +143 -0
  9. package/src/__tests__/node-diff.test.ts +139 -0
  10. package/src/__tests__/node-helpers.test.ts +19 -19
  11. package/src/__tests__/node-merge.test.ts +425 -0
  12. package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
  13. package/src/__tests__/normalize-tree-layout.test.ts +294 -0
  14. package/src/__tests__/normalize.test.ts +119 -80
  15. package/src/__tests__/path-anchors.test.ts +98 -0
  16. package/src/__tests__/resolve-variables-recursive.test.ts +173 -0
  17. package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
  18. package/src/__tests__/text-measure.test.ts +84 -79
  19. package/src/__tests__/tree-utils.test.ts +133 -102
  20. package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
  21. package/src/__tests__/variables.test.ts +68 -65
  22. package/src/arc-path.ts +35 -35
  23. package/src/boolean-ops.ts +158 -111
  24. package/src/constants.ts +36 -36
  25. package/src/design-md-parser.ts +363 -0
  26. package/src/font-utils.ts +30 -15
  27. package/src/id.ts +1 -1
  28. package/src/index.ts +47 -13
  29. package/src/layout/engine.ts +255 -230
  30. package/src/layout/normalize-tree.ts +140 -0
  31. package/src/layout/strip-redundant-section-fills.ts +155 -0
  32. package/src/layout/text-measure.ts +133 -105
  33. package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
  34. package/src/merge/index.ts +16 -0
  35. package/src/merge/merge-helpers.ts +113 -0
  36. package/src/merge/node-diff.ts +123 -0
  37. package/src/merge/node-merge.ts +651 -0
  38. package/src/node-helpers.ts +18 -5
  39. package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
  40. package/src/normalize.ts +79 -79
  41. package/src/path-anchors.ts +331 -0
  42. package/src/sync-lock.ts +3 -3
  43. package/src/tree-utils.ts +180 -158
  44. package/src/variables/replace-refs.ts +63 -60
  45. package/src/variables/resolve.ts +107 -102
@@ -0,0 +1,425 @@
1
+ // packages/pen-core/src/__tests__/node-merge.test.ts
2
+ import { describe, it, expect } from 'vitest';
3
+ import type { PenDocument, PenNode, VariableDefinition } from '@zseven-w/pen-types';
4
+ import { mergeDocuments } from '../merge/node-merge';
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 group = (id: string, children: PenNode[] = []): PenNode =>
22
+ ({ id, type: 'group', x: 0, y: 0, width: 10, height: 10, children }) as PenNode;
23
+
24
+ const doc = (children: PenNode[]): PenDocument => ({
25
+ version: '1.0.0',
26
+ pages: [{ id: 'page-1', name: 'page-1', children }],
27
+ children: [],
28
+ });
29
+
30
+ const docMulti = (pages: Array<{ id: string; children: PenNode[] }>): PenDocument => ({
31
+ version: '1.0.0',
32
+ pages: pages.map((p) => ({ id: p.id, name: p.id, children: p.children })),
33
+ children: [],
34
+ });
35
+
36
+ const docLegacy = (children: PenNode[]): PenDocument => ({
37
+ version: '1.0.0',
38
+ children,
39
+ });
40
+
41
+ const findNode = (d: PenDocument, id: string): PenNode | undefined => {
42
+ function walk(nodes: PenNode[]): PenNode | undefined {
43
+ for (const n of nodes) {
44
+ if (n.id === id) return n;
45
+ const c = (n as { children?: PenNode[] }).children;
46
+ if (c) {
47
+ const found = walk(c);
48
+ if (found) return found;
49
+ }
50
+ }
51
+ return undefined;
52
+ }
53
+ for (const page of d.pages ?? [{ id: null, name: null, children: d.children ?? [] } as never]) {
54
+ const found = walk((page as { children: PenNode[] }).children);
55
+ if (found) return found;
56
+ }
57
+ return undefined;
58
+ };
59
+
60
+ describe('mergeDocuments', () => {
61
+ // 1
62
+ it('merges two empty documents to an empty document with no conflicts', () => {
63
+ const empty = doc([]);
64
+ const result = mergeDocuments({ base: empty, ours: empty, theirs: empty });
65
+ expect(result.nodeConflicts).toEqual([]);
66
+ expect(result.docFieldConflicts).toEqual([]);
67
+ expect(result.merged.pages![0].children).toEqual([]);
68
+ });
69
+
70
+ // 2
71
+ it('produces no conflicts when all three sides are identical', () => {
72
+ const d = doc([rect('r1'), rect('r2')]);
73
+ const result = mergeDocuments({ base: d, ours: d, theirs: d });
74
+ expect(result.nodeConflicts).toEqual([]);
75
+ expect(result.docFieldConflicts).toEqual([]);
76
+ expect(result.merged.pages![0].children).toHaveLength(2);
77
+ });
78
+
79
+ // 3
80
+ it('takes ours when only ours changed a field', () => {
81
+ const base = doc([rect('r1', { width: 10 })]);
82
+ const ours = doc([rect('r1', { width: 20 })]);
83
+ const theirs = doc([rect('r1', { width: 10 })]);
84
+ const result = mergeDocuments({ base, ours, theirs });
85
+ expect(result.nodeConflicts).toEqual([]);
86
+ expect((findNode(result.merged, 'r1') as { width: number }).width).toBe(20);
87
+ });
88
+
89
+ // 4
90
+ it('takes theirs when only theirs changed a field', () => {
91
+ const base = doc([rect('r1', { width: 10 })]);
92
+ const ours = doc([rect('r1', { width: 10 })]);
93
+ const theirs = doc([rect('r1', { width: 30 })]);
94
+ const result = mergeDocuments({ base, ours, theirs });
95
+ expect(result.nodeConflicts).toEqual([]);
96
+ expect((findNode(result.merged, 'r1') as { width: number }).width).toBe(30);
97
+ });
98
+
99
+ // 5
100
+ it('produces no conflict when both sides change a field to the same value', () => {
101
+ const base = doc([rect('r1', { width: 10 })]);
102
+ const ours = doc([rect('r1', { width: 99 })]);
103
+ const theirs = doc([rect('r1', { width: 99 })]);
104
+ const result = mergeDocuments({ base, ours, theirs });
105
+ expect(result.nodeConflicts).toEqual([]);
106
+ expect((findNode(result.merged, 'r1') as { width: number }).width).toBe(99);
107
+ });
108
+
109
+ // 6
110
+ it('emits both-modified-same-field when sides disagree on a field value', () => {
111
+ const base = doc([rect('r1', { width: 10 })]);
112
+ const ours = doc([rect('r1', { width: 20 })]);
113
+ const theirs = doc([rect('r1', { width: 30 })]);
114
+ const result = mergeDocuments({ base, ours, theirs });
115
+ expect(result.nodeConflicts).toHaveLength(1);
116
+ expect(result.nodeConflicts[0].reason).toBe('both-modified-same-field');
117
+ expect(result.nodeConflicts[0].nodeId).toBe('r1');
118
+ // Merged document uses ours as placeholder
119
+ expect((findNode(result.merged, 'r1') as { width: number }).width).toBe(20);
120
+ });
121
+
122
+ // 7
123
+ it('takes ours when ours adds a node not in base or theirs', () => {
124
+ const base = doc([]);
125
+ const ours = doc([rect('r1')]);
126
+ const theirs = doc([]);
127
+ const result = mergeDocuments({ base, ours, theirs });
128
+ expect(result.nodeConflicts).toEqual([]);
129
+ expect(findNode(result.merged, 'r1')).toBeDefined();
130
+ });
131
+
132
+ // 8
133
+ it('takes theirs when theirs adds a node not in base or ours', () => {
134
+ const base = doc([]);
135
+ const ours = doc([]);
136
+ const theirs = doc([rect('r1')]);
137
+ const result = mergeDocuments({ base, ours, theirs });
138
+ expect(result.nodeConflicts).toEqual([]);
139
+ expect(findNode(result.merged, 'r1')).toBeDefined();
140
+ });
141
+
142
+ // 9
143
+ it('takes ours without conflict when both sides add the same id with same content', () => {
144
+ const base = doc([]);
145
+ const ours = doc([rect('r1', { width: 50 })]);
146
+ const theirs = doc([rect('r1', { width: 50 })]);
147
+ const result = mergeDocuments({ base, ours, theirs });
148
+ expect(result.nodeConflicts).toEqual([]);
149
+ expect((findNode(result.merged, 'r1') as { width: number }).width).toBe(50);
150
+ });
151
+
152
+ // 10
153
+ it('emits add-vs-add-different when both sides add the same id with different content', () => {
154
+ const base = doc([]);
155
+ const ours = doc([rect('r1', { width: 50 })]);
156
+ const theirs = doc([rect('r1', { width: 75 })]);
157
+ const result = mergeDocuments({ base, ours, theirs });
158
+ expect(result.nodeConflicts).toHaveLength(1);
159
+ expect(result.nodeConflicts[0].reason).toBe('add-vs-add-different');
160
+ expect(result.nodeConflicts[0].base).toBeNull();
161
+ });
162
+
163
+ // 11
164
+ it('cleanly deletes when ours deletes and theirs is unchanged', () => {
165
+ const base = doc([rect('r1')]);
166
+ const ours = doc([]);
167
+ const theirs = doc([rect('r1')]);
168
+ const result = mergeDocuments({ base, ours, theirs });
169
+ expect(result.nodeConflicts).toEqual([]);
170
+ expect(findNode(result.merged, 'r1')).toBeUndefined();
171
+ });
172
+
173
+ // 12
174
+ it('cleanly deletes when theirs deletes and ours is unchanged', () => {
175
+ const base = doc([rect('r1')]);
176
+ const ours = doc([rect('r1')]);
177
+ const theirs = doc([]);
178
+ const result = mergeDocuments({ base, ours, theirs });
179
+ expect(result.nodeConflicts).toEqual([]);
180
+ expect(findNode(result.merged, 'r1')).toBeUndefined();
181
+ });
182
+
183
+ // 13
184
+ it('cleanly deletes when both sides delete the same node', () => {
185
+ const base = doc([rect('r1')]);
186
+ const ours = doc([]);
187
+ const theirs = doc([]);
188
+ const result = mergeDocuments({ base, ours, theirs });
189
+ expect(result.nodeConflicts).toEqual([]);
190
+ expect(findNode(result.merged, 'r1')).toBeUndefined();
191
+ });
192
+
193
+ // 14
194
+ it('emits modify-vs-delete when ours modifies and theirs deletes', () => {
195
+ const base = doc([rect('r1', { width: 10 })]);
196
+ const ours = doc([rect('r1', { width: 20 })]);
197
+ const theirs = doc([]);
198
+ const result = mergeDocuments({ base, ours, theirs });
199
+ expect(result.nodeConflicts).toHaveLength(1);
200
+ expect(result.nodeConflicts[0].reason).toBe('modify-vs-delete');
201
+ expect(result.nodeConflicts[0].theirs).toBeNull();
202
+ });
203
+
204
+ // 15
205
+ it('emits modify-vs-delete when ours deletes and theirs modifies', () => {
206
+ const base = doc([rect('r1', { width: 10 })]);
207
+ const ours = doc([]);
208
+ const theirs = doc([rect('r1', { width: 30 })]);
209
+ const result = mergeDocuments({ base, ours, theirs });
210
+ expect(result.nodeConflicts).toHaveLength(1);
211
+ expect(result.nodeConflicts[0].reason).toBe('modify-vs-delete');
212
+ expect(result.nodeConflicts[0].ours).toBeNull();
213
+ });
214
+
215
+ // 16
216
+ it('handles nested groups: deep modify keeps parents intact', () => {
217
+ const base = doc([frame('f1', [group('g1', [rect('r1', { width: 10 })])])]);
218
+ const ours = doc([frame('f1', [group('g1', [rect('r1', { width: 20 })])])]);
219
+ const theirs = doc([frame('f1', [group('g1', [rect('r1', { width: 10 })])])]);
220
+ const result = mergeDocuments({ base, ours, theirs });
221
+ expect(result.nodeConflicts).toEqual([]);
222
+ expect((findNode(result.merged, 'r1') as { width: number }).width).toBe(20);
223
+ expect(findNode(result.merged, 'g1')).toBeDefined();
224
+ expect(findNode(result.merged, 'f1')).toBeDefined();
225
+ });
226
+
227
+ // 17
228
+ it('auto-merges reparent when both sides move to the same new parent', () => {
229
+ const base = doc([frame('f1', [rect('r1')]), frame('f2', [])]);
230
+ const ours = doc([frame('f1', []), frame('f2', [rect('r1')])]);
231
+ const theirs = doc([frame('f1', []), frame('f2', [rect('r1')])]);
232
+ const result = mergeDocuments({ base, ours, theirs });
233
+ const reparentConflicts = result.nodeConflicts.filter((c) => c.reason === 'reparent-conflict');
234
+ expect(reparentConflicts).toHaveLength(0);
235
+ // r1 ends up under f2
236
+ const f2 = findNode(result.merged, 'f2') as { children?: PenNode[] };
237
+ expect(f2.children?.some((c) => c.id === 'r1')).toBe(true);
238
+ });
239
+
240
+ // 18
241
+ it('emits reparent-conflict when sides move to different parents', () => {
242
+ const base = doc([frame('f1', [rect('r1')]), frame('f2', []), frame('f3', [])]);
243
+ const ours = doc([frame('f1', []), frame('f2', [rect('r1')]), frame('f3', [])]);
244
+ const theirs = doc([frame('f1', []), frame('f2', []), frame('f3', [rect('r1')])]);
245
+ const result = mergeDocuments({ base, ours, theirs });
246
+ const reparentConflicts = result.nodeConflicts.filter((c) => c.reason === 'reparent-conflict');
247
+ expect(reparentConflicts).toHaveLength(1);
248
+ expect(reparentConflicts[0].nodeId).toBe('r1');
249
+ });
250
+
251
+ // 19
252
+ it('auto-merges reparent when only one side moved', () => {
253
+ const base = doc([frame('f1', [rect('r1')]), frame('f2', [])]);
254
+ const ours = doc([frame('f1', []), frame('f2', [rect('r1')])]);
255
+ const theirs = doc([frame('f1', [rect('r1')]), frame('f2', [])]);
256
+ const result = mergeDocuments({ base, ours, theirs });
257
+ expect(result.nodeConflicts.filter((c) => c.reason === 'reparent-conflict')).toHaveLength(0);
258
+ const f2 = findNode(result.merged, 'f2') as { children?: PenNode[] };
259
+ expect(f2.children?.some((c) => c.id === 'r1')).toBe(true);
260
+ });
261
+
262
+ // 20
263
+ it('prefers ours order on sibling reorder when ours reorders and theirs does not', () => {
264
+ const base = doc([rect('a'), rect('b'), rect('c')]);
265
+ const ours = doc([rect('b'), rect('a'), rect('c')]);
266
+ const theirs = doc([rect('a'), rect('b'), rect('c')]);
267
+ const result = mergeDocuments({ base, ours, theirs });
268
+ const order = result.merged.pages![0].children.map((n) => n.id);
269
+ expect(order).toEqual(['b', 'a', 'c']);
270
+ });
271
+
272
+ // 21
273
+ it('takes a new variable when ours adds it and theirs does not have it', () => {
274
+ const baseDoc: PenDocument = {
275
+ version: '1.0.0',
276
+ pages: [{ id: 'p', name: 'p', children: [] }],
277
+ children: [],
278
+ };
279
+ const oursDoc: PenDocument = {
280
+ ...baseDoc,
281
+ variables: { 'color-1': { type: 'color', value: '#ff0000' } as VariableDefinition },
282
+ };
283
+ const theirsDoc: PenDocument = { ...baseDoc };
284
+ const result = mergeDocuments({ base: baseDoc, ours: oursDoc, theirs: theirsDoc });
285
+ expect(result.docFieldConflicts).toEqual([]);
286
+ expect(result.merged.variables?.['color-1']).toBeDefined();
287
+ });
288
+
289
+ // 22
290
+ it('emits a variables doc-field conflict when both sides add the same variable name with different values', () => {
291
+ const baseDoc: PenDocument = {
292
+ version: '1.0.0',
293
+ pages: [{ id: 'p', name: 'p', children: [] }],
294
+ children: [],
295
+ };
296
+ const oursDoc: PenDocument = {
297
+ ...baseDoc,
298
+ variables: { 'color-1': { type: 'color', value: '#ff0000' } as VariableDefinition },
299
+ };
300
+ const theirsDoc: PenDocument = {
301
+ ...baseDoc,
302
+ variables: { 'color-1': { type: 'color', value: '#00ff00' } as VariableDefinition },
303
+ };
304
+ const result = mergeDocuments({ base: baseDoc, ours: oursDoc, theirs: theirsDoc });
305
+ expect(result.docFieldConflicts).toHaveLength(1);
306
+ expect(result.docFieldConflicts[0].field).toBe('variables');
307
+ expect(result.docFieldConflicts[0].path).toBe('variables.color-1');
308
+ });
309
+
310
+ // 23
311
+ it('emits a variables conflict when ours deletes a variable that theirs modified', () => {
312
+ const baseDoc: PenDocument = {
313
+ version: '1.0.0',
314
+ pages: [{ id: 'p', name: 'p', children: [] }],
315
+ children: [],
316
+ variables: { 'color-1': { type: 'color', value: '#ff0000' } as VariableDefinition },
317
+ };
318
+ const oursDoc: PenDocument = { ...baseDoc, variables: {} };
319
+ const theirsDoc: PenDocument = {
320
+ ...baseDoc,
321
+ variables: { 'color-1': { type: 'color', value: '#0000ff' } as VariableDefinition },
322
+ };
323
+ const result = mergeDocuments({ base: baseDoc, ours: oursDoc, theirs: theirsDoc });
324
+ expect(result.docFieldConflicts).toHaveLength(1);
325
+ expect(result.docFieldConflicts[0].field).toBe('variables');
326
+ expect(result.docFieldConflicts[0].path).toBe('variables.color-1');
327
+ });
328
+
329
+ // 24
330
+ it('auto-merges themes when only one side adds a theme axis', () => {
331
+ const baseDoc: PenDocument = {
332
+ version: '1.0.0',
333
+ pages: [{ id: 'p', name: 'p', children: [] }],
334
+ children: [],
335
+ themes: { mode: ['light', 'dark'] },
336
+ };
337
+ const oursDoc: PenDocument = {
338
+ ...baseDoc,
339
+ themes: { mode: ['light', 'dark'], density: ['compact', 'comfortable'] },
340
+ };
341
+ const theirsDoc: PenDocument = { ...baseDoc };
342
+ const result = mergeDocuments({ base: baseDoc, ours: oursDoc, theirs: theirsDoc });
343
+ expect(result.docFieldConflicts).toEqual([]);
344
+ expect(result.merged.themes?.['density']).toEqual(['compact', 'comfortable']);
345
+ });
346
+
347
+ // 25
348
+ it('emits pages-order conflict when both sides reorder pages differently', () => {
349
+ const baseDoc: PenDocument = {
350
+ version: '1.0.0',
351
+ pages: [
352
+ { id: 'p1', name: 'p1', children: [] },
353
+ { id: 'p2', name: 'p2', children: [] },
354
+ { id: 'p3', name: 'p3', children: [] },
355
+ ],
356
+ children: [],
357
+ };
358
+ const oursDoc: PenDocument = {
359
+ ...baseDoc,
360
+ pages: [
361
+ { id: 'p2', name: 'p2', children: [] },
362
+ { id: 'p1', name: 'p1', children: [] },
363
+ { id: 'p3', name: 'p3', children: [] },
364
+ ],
365
+ };
366
+ const theirsDoc: PenDocument = {
367
+ ...baseDoc,
368
+ pages: [
369
+ { id: 'p3', name: 'p3', children: [] },
370
+ { id: 'p1', name: 'p1', children: [] },
371
+ { id: 'p2', name: 'p2', children: [] },
372
+ ],
373
+ };
374
+ const result = mergeDocuments({ base: baseDoc, ours: oursDoc, theirs: theirsDoc });
375
+ const pageOrderConflicts = result.docFieldConflicts.filter((c) => c.field === 'pages-order');
376
+ expect(pageOrderConflicts).toHaveLength(1);
377
+ });
378
+
379
+ // 26
380
+ it('handles non-overlapping field changes on the same node (ours width, theirs height)', () => {
381
+ const base = doc([rect('r1', { width: 10, height: 10 })]);
382
+ const ours = doc([rect('r1', { width: 20, height: 10 })]);
383
+ const theirs = doc([rect('r1', { width: 10, height: 30 })]);
384
+ const result = mergeDocuments({ base, ours, theirs });
385
+ expect(result.nodeConflicts).toEqual([]);
386
+ const r1 = findNode(result.merged, 'r1') as { width: number; height: number };
387
+ expect(r1.width).toBe(20);
388
+ expect(r1.height).toBe(30);
389
+ });
390
+
391
+ // 27
392
+ it('handles legacy children mode (no pages array) on all three sides', () => {
393
+ const base = docLegacy([rect('r1', { width: 10 })]);
394
+ const ours = docLegacy([rect('r1', { width: 20 })]);
395
+ const theirs = docLegacy([rect('r1', { width: 10 })]);
396
+ const result = mergeDocuments({ base, ours, theirs });
397
+ expect(result.nodeConflicts).toEqual([]);
398
+ expect(result.merged.children).toHaveLength(1);
399
+ expect((result.merged.children[0] as { width: number }).width).toBe(20);
400
+ });
401
+
402
+ // 28
403
+ it('moving a node across pages auto-merges when only one side moves', () => {
404
+ const base = docMulti([
405
+ { id: 'p1', children: [rect('r1')] },
406
+ { id: 'p2', children: [] },
407
+ ]);
408
+ const ours = docMulti([
409
+ { id: 'p1', children: [] },
410
+ { id: 'p2', children: [rect('r1')] },
411
+ ]);
412
+ const theirs = docMulti([
413
+ { id: 'p1', children: [rect('r1')] },
414
+ { id: 'p2', children: [] },
415
+ ]);
416
+ const result = mergeDocuments({ base, ours, theirs });
417
+ expect(result.nodeConflicts.filter((c) => c.reason === 'reparent-conflict')).toHaveLength(0);
418
+ // r1 should now be on p2
419
+ const p1 = result.merged.pages!.find((p) => p.id === 'p1');
420
+ const p2 = result.merged.pages!.find((p) => p.id === 'p2');
421
+ expect(p1!.children).toHaveLength(0);
422
+ expect(p2!.children).toHaveLength(1);
423
+ expect(p2!.children[0].id).toBe('r1');
424
+ });
425
+ });