@fragments-sdk/cli 0.6.0 → 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/dist/bin.js +294 -50
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-D35RGPAG.js → chunk-7OPWMLOE.js} +435 -19
- package/dist/chunk-7OPWMLOE.js.map +1 -0
- package/dist/{chunk-SSLQXHNX.js → chunk-CVXKXVOY.js} +1 -1
- package/dist/{chunk-SSLQXHNX.js.map → chunk-CVXKXVOY.js.map} +1 -1
- package/dist/{chunk-Q7GOHVOK.js → chunk-TJ34N7C7.js} +39 -2
- package/dist/{chunk-Q7GOHVOK.js.map → chunk-TJ34N7C7.js.map} +1 -1
- package/dist/{chunk-F7ITZPDJ.js → chunk-XHUDJNN3.js} +2 -2
- package/dist/{core-SKRPJQZG.js → core-W2HYIQW6.js} +2 -2
- package/dist/{generate-7AF7WRVK.js → generate-LMTISDIJ.js} +3 -3
- package/dist/index.js +3 -3
- package/dist/{init-WKGDPYI4.js → init-7CHRKQ7P.js} +3 -3
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-K6JNMCGM.js → scan-WY23TJCP.js} +4 -4
- package/dist/{service-F3E4JJM7.js → service-T2L7VLTE.js} +2 -2
- package/dist/{static-viewer-4LQZ5AGA.js → static-viewer-GBR7YNF3.js} +2 -2
- package/dist/{test-CJDNJTPZ.js → test-OJRXNDO2.js} +2 -2
- package/dist/{tokens-JAJABYXP.js → tokens-3BWDESVM.js} +3 -3
- package/dist/{viewer-R3Q6WAMJ.js → viewer-SUFOISZM.js} +12 -12
- package/package.json +2 -2
- package/src/bin.ts +23 -0
- package/src/build.ts +43 -0
- package/src/commands/graph.ts +274 -0
- package/src/core/composition.ts +64 -1
- package/src/core/graph-extractor.test.ts +542 -0
- package/src/core/graph-extractor.ts +601 -0
- package/src/core/importAnalyzer.ts +5 -0
- package/src/viewer/components/App.tsx +128 -30
- package/src/viewer/components/Icons.tsx +53 -1
- package/src/viewer/components/Layout.tsx +7 -3
- package/src/viewer/components/LeftSidebar.tsx +65 -87
- package/src/viewer/components/PreviewFrameHost.tsx +30 -1
- package/src/viewer/components/PreviewToolbar.tsx +57 -10
- package/src/viewer/components/ViewportSelector.tsx +56 -45
- package/src/viewer/constants/ui.ts +4 -4
- package/src/viewer/preview-frame.html +22 -13
- package/src/viewer/styles/globals.css +42 -81
- package/dist/chunk-D35RGPAG.js.map +0 -1
- /package/dist/{chunk-F7ITZPDJ.js.map → chunk-XHUDJNN3.js.map} +0 -0
- /package/dist/{core-SKRPJQZG.js.map → core-W2HYIQW6.js.map} +0 -0
- /package/dist/{generate-7AF7WRVK.js.map → generate-LMTISDIJ.js.map} +0 -0
- /package/dist/{init-WKGDPYI4.js.map → init-7CHRKQ7P.js.map} +0 -0
- /package/dist/{scan-K6JNMCGM.js.map → scan-WY23TJCP.js.map} +0 -0
- /package/dist/{service-F3E4JJM7.js.map → service-T2L7VLTE.js.map} +0 -0
- /package/dist/{static-viewer-4LQZ5AGA.js.map → static-viewer-GBR7YNF3.js.map} +0 -0
- /package/dist/{test-CJDNJTPZ.js.map → test-OJRXNDO2.js.map} +0 -0
- /package/dist/{tokens-JAJABYXP.js.map → tokens-3BWDESVM.js.map} +0 -0
- /package/dist/{viewer-R3Q6WAMJ.js.map → viewer-SUFOISZM.js.map} +0 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { CompiledSegment, CompiledBlock } from './types.js';
|
|
3
|
+
import {
|
|
4
|
+
buildComponentGraph,
|
|
5
|
+
extractJsxUsageEdges,
|
|
6
|
+
extractBlockEdges,
|
|
7
|
+
extractRelationEdges,
|
|
8
|
+
inferRequiredChildren,
|
|
9
|
+
generateCommonPatterns,
|
|
10
|
+
mergeAndDeduplicate,
|
|
11
|
+
} from './graph-extractor.js';
|
|
12
|
+
import type { GraphEdge } from '@fragments-sdk/context/graph';
|
|
13
|
+
import { EDGE_TYPE_WEIGHTS } from '@fragments-sdk/context/graph';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Test helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function makeSegment(name: string, overrides: Partial<CompiledSegment> = {}): CompiledSegment {
|
|
20
|
+
return {
|
|
21
|
+
filePath: `src/components/${name}/${name}.fragment.tsx`,
|
|
22
|
+
meta: {
|
|
23
|
+
name,
|
|
24
|
+
description: `The ${name} component`,
|
|
25
|
+
category: 'test',
|
|
26
|
+
status: 'stable',
|
|
27
|
+
...overrides.meta,
|
|
28
|
+
},
|
|
29
|
+
usage: {
|
|
30
|
+
when: [`Use ${name} for testing`],
|
|
31
|
+
whenNot: [],
|
|
32
|
+
...overrides.usage,
|
|
33
|
+
},
|
|
34
|
+
props: overrides.props ?? {},
|
|
35
|
+
variants: overrides.variants ?? [
|
|
36
|
+
{ name: 'Default', description: `Default ${name}` },
|
|
37
|
+
],
|
|
38
|
+
relations: overrides.relations,
|
|
39
|
+
ai: overrides.ai,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeBlock(name: string, components: string[], category = 'test'): CompiledBlock {
|
|
44
|
+
return {
|
|
45
|
+
filePath: `src/blocks/${name}.block.ts`,
|
|
46
|
+
name,
|
|
47
|
+
description: `${name} block`,
|
|
48
|
+
category,
|
|
49
|
+
components,
|
|
50
|
+
code: `<${components[0]}></${components[0]}>`,
|
|
51
|
+
tags: [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// extractJsxUsageEdges
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
describe('extractJsxUsageEdges()', () => {
|
|
60
|
+
it('detects JSX component usage in variant code', () => {
|
|
61
|
+
const segments: Record<string, CompiledSegment> = {
|
|
62
|
+
Dialog: makeSegment('Dialog', {
|
|
63
|
+
variants: [
|
|
64
|
+
{
|
|
65
|
+
name: 'Default',
|
|
66
|
+
description: 'Default dialog',
|
|
67
|
+
code: '<Dialog>\n <Button>Close</Button>\n</Dialog>',
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
}),
|
|
71
|
+
Button: makeSegment('Button'),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const edges = extractJsxUsageEdges(segments, new Set(['Dialog', 'Button']));
|
|
75
|
+
expect(edges.length).toBe(1);
|
|
76
|
+
expect(edges[0].source).toBe('Dialog');
|
|
77
|
+
expect(edges[0].target).toBe('Button');
|
|
78
|
+
expect(edges[0].type).toBe('renders');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('handles compound component notation (Header.Nav)', () => {
|
|
82
|
+
const segments: Record<string, CompiledSegment> = {
|
|
83
|
+
AppShell: makeSegment('AppShell', {
|
|
84
|
+
variants: [
|
|
85
|
+
{
|
|
86
|
+
name: 'Default',
|
|
87
|
+
description: 'Default shell',
|
|
88
|
+
code: '<AppShell>\n <Header.Nav>...</Header.Nav>\n <Sidebar.Content>...</Sidebar.Content>\n</AppShell>',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
}),
|
|
92
|
+
Header: makeSegment('Header'),
|
|
93
|
+
Sidebar: makeSegment('Sidebar'),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const edges = extractJsxUsageEdges(
|
|
97
|
+
segments,
|
|
98
|
+
new Set(['AppShell', 'Header', 'Sidebar']),
|
|
99
|
+
);
|
|
100
|
+
expect(edges.length).toBe(2);
|
|
101
|
+
expect(edges.some(e => e.target === 'Header')).toBe(true);
|
|
102
|
+
expect(edges.some(e => e.target === 'Sidebar')).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does not create self-referencing edges', () => {
|
|
106
|
+
const segments: Record<string, CompiledSegment> = {
|
|
107
|
+
Button: makeSegment('Button', {
|
|
108
|
+
variants: [
|
|
109
|
+
{
|
|
110
|
+
name: 'Default',
|
|
111
|
+
description: 'Default',
|
|
112
|
+
code: '<Button>Click me</Button>',
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const edges = extractJsxUsageEdges(segments, new Set(['Button']));
|
|
119
|
+
expect(edges.length).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('deduplicates across variants', () => {
|
|
123
|
+
const segments: Record<string, CompiledSegment> = {
|
|
124
|
+
Dialog: makeSegment('Dialog', {
|
|
125
|
+
variants: [
|
|
126
|
+
{ name: 'V1', description: 'V1', code: '<Dialog><Button>OK</Button></Dialog>' },
|
|
127
|
+
{ name: 'V2', description: 'V2', code: '<Dialog><Button>Cancel</Button></Dialog>' },
|
|
128
|
+
],
|
|
129
|
+
}),
|
|
130
|
+
Button: makeSegment('Button'),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const edges = extractJsxUsageEdges(segments, new Set(['Dialog', 'Button']));
|
|
134
|
+
// Should only create one edge even though Button appears in two variants
|
|
135
|
+
expect(edges.length).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('skips variants without code', () => {
|
|
139
|
+
const segments: Record<string, CompiledSegment> = {
|
|
140
|
+
Dialog: makeSegment('Dialog', {
|
|
141
|
+
variants: [
|
|
142
|
+
{ name: 'NoCode', description: 'No code variant' },
|
|
143
|
+
],
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const edges = extractJsxUsageEdges(segments, new Set(['Dialog']));
|
|
148
|
+
expect(edges.length).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('ignores unknown component tags', () => {
|
|
152
|
+
const segments: Record<string, CompiledSegment> = {
|
|
153
|
+
Dialog: makeSegment('Dialog', {
|
|
154
|
+
variants: [
|
|
155
|
+
{ name: 'Default', description: 'Default', code: '<Dialog><UnknownThing /></Dialog>' },
|
|
156
|
+
],
|
|
157
|
+
}),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const edges = extractJsxUsageEdges(segments, new Set(['Dialog']));
|
|
161
|
+
expect(edges.length).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// extractBlockEdges
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
describe('extractBlockEdges()', () => {
|
|
170
|
+
it('creates pairwise composes edges', () => {
|
|
171
|
+
const blocks: Record<string, CompiledBlock> = {
|
|
172
|
+
LoginForm: makeBlock('LoginForm', ['Dialog', 'Input', 'Button']),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const edges = extractBlockEdges(blocks);
|
|
176
|
+
// 3 components → 3 pairwise edges (Dialog-Input, Dialog-Button, Input-Button)
|
|
177
|
+
expect(edges.length).toBe(3);
|
|
178
|
+
expect(edges.every(e => e.type === 'composes')).toBe(true);
|
|
179
|
+
expect(edges.every(e => e.provenance === 'block:LoginForm')).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('handles single-component blocks', () => {
|
|
183
|
+
const blocks: Record<string, CompiledBlock> = {
|
|
184
|
+
Solo: makeBlock('Solo', ['Button']),
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const edges = extractBlockEdges(blocks);
|
|
188
|
+
expect(edges.length).toBe(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('handles multiple blocks', () => {
|
|
192
|
+
const blocks: Record<string, CompiledBlock> = {
|
|
193
|
+
Login: makeBlock('Login', ['Dialog', 'Button']),
|
|
194
|
+
Search: makeBlock('Search', ['Input', 'Button']),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const edges = extractBlockEdges(blocks);
|
|
198
|
+
// Login: Dialog-Button = 1 edge; Search: Input-Button = 1 edge
|
|
199
|
+
expect(edges.length).toBe(2);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('preserves block provenance', () => {
|
|
203
|
+
const blocks: Record<string, CompiledBlock> = {
|
|
204
|
+
Dashboard: makeBlock('Dashboard', ['Header', 'Sidebar']),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const edges = extractBlockEdges(blocks);
|
|
208
|
+
expect(edges[0].provenance).toBe('block:Dashboard');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// extractRelationEdges
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
describe('extractRelationEdges()', () => {
|
|
217
|
+
it('maps parent relation correctly', () => {
|
|
218
|
+
const segments: Record<string, CompiledSegment> = {
|
|
219
|
+
Button: makeSegment('Button', {
|
|
220
|
+
relations: [
|
|
221
|
+
{ component: 'Header', relationship: 'parent', note: 'Used in Header actions' },
|
|
222
|
+
],
|
|
223
|
+
}),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const edges = extractRelationEdges(segments);
|
|
227
|
+
expect(edges.length).toBe(1);
|
|
228
|
+
// parent relation: Header is parent of Button → source=Header, target=Button
|
|
229
|
+
expect(edges[0].source).toBe('Header');
|
|
230
|
+
expect(edges[0].target).toBe('Button');
|
|
231
|
+
expect(edges[0].type).toBe('parent-of');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('maps child relation correctly', () => {
|
|
235
|
+
const segments: Record<string, CompiledSegment> = {
|
|
236
|
+
Header: makeSegment('Header', {
|
|
237
|
+
relations: [
|
|
238
|
+
{ component: 'Button', relationship: 'child', note: 'Renders action buttons' },
|
|
239
|
+
],
|
|
240
|
+
}),
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const edges = extractRelationEdges(segments);
|
|
244
|
+
expect(edges.length).toBe(1);
|
|
245
|
+
expect(edges[0].source).toBe('Header');
|
|
246
|
+
expect(edges[0].target).toBe('Button');
|
|
247
|
+
expect(edges[0].type).toBe('parent-of');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('maps alternative relation', () => {
|
|
251
|
+
const segments: Record<string, CompiledSegment> = {
|
|
252
|
+
Dialog: makeSegment('Dialog', {
|
|
253
|
+
relations: [
|
|
254
|
+
{ component: 'Popover', relationship: 'alternative', note: 'For non-modal overlays' },
|
|
255
|
+
],
|
|
256
|
+
}),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const edges = extractRelationEdges(segments);
|
|
260
|
+
expect(edges[0].type).toBe('alternative-to');
|
|
261
|
+
expect(edges[0].note).toBe('For non-modal overlays');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('maps sibling relation', () => {
|
|
265
|
+
const segments: Record<string, CompiledSegment> = {
|
|
266
|
+
Header: makeSegment('Header', {
|
|
267
|
+
relations: [
|
|
268
|
+
{ component: 'Sidebar', relationship: 'sibling', note: 'Both in AppShell' },
|
|
269
|
+
],
|
|
270
|
+
}),
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const edges = extractRelationEdges(segments);
|
|
274
|
+
expect(edges[0].type).toBe('sibling-of');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('maps composition relation', () => {
|
|
278
|
+
const segments: Record<string, CompiledSegment> = {
|
|
279
|
+
Input: makeSegment('Input', {
|
|
280
|
+
relations: [
|
|
281
|
+
{ component: 'Button', relationship: 'composition', note: 'Often paired in forms' },
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const edges = extractRelationEdges(segments);
|
|
287
|
+
expect(edges[0].type).toBe('composes');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('skips segments without relations', () => {
|
|
291
|
+
const segments: Record<string, CompiledSegment> = {
|
|
292
|
+
Button: makeSegment('Button'),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const edges = extractRelationEdges(segments);
|
|
296
|
+
expect(edges.length).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('skips unknown relationship types', () => {
|
|
300
|
+
const segments: Record<string, CompiledSegment> = {
|
|
301
|
+
Button: makeSegment('Button', {
|
|
302
|
+
relations: [
|
|
303
|
+
{ component: 'Icon', relationship: 'unknown-type' as any, note: 'Custom relation' },
|
|
304
|
+
],
|
|
305
|
+
}),
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const edges = extractRelationEdges(segments);
|
|
309
|
+
expect(edges.length).toBe(0);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// inferRequiredChildren
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
describe('inferRequiredChildren()', () => {
|
|
318
|
+
it('identifies sub-components present in ALL variants', () => {
|
|
319
|
+
const segments: Record<string, CompiledSegment> = {
|
|
320
|
+
Dialog: makeSegment('Dialog', {
|
|
321
|
+
variants: [
|
|
322
|
+
{ name: 'V1', description: 'V1', code: '<Dialog><Dialog.Content>...</Dialog.Content><Dialog.Footer>...</Dialog.Footer></Dialog>' },
|
|
323
|
+
{ name: 'V2', description: 'V2', code: '<Dialog><Dialog.Content>...</Dialog.Content></Dialog>' },
|
|
324
|
+
],
|
|
325
|
+
}),
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const autoDetected = new Map([['Dialog', { subComponents: ['Content', 'Footer'] }]]);
|
|
329
|
+
const result = inferRequiredChildren(segments, autoDetected);
|
|
330
|
+
|
|
331
|
+
expect(result.get('Dialog')).toEqual(['Content']);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('returns empty for components with no subs', () => {
|
|
335
|
+
const segments: Record<string, CompiledSegment> = {
|
|
336
|
+
Button: makeSegment('Button', {
|
|
337
|
+
variants: [{ name: 'Default', description: 'Default', code: '<Button>Click</Button>' }],
|
|
338
|
+
}),
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const autoDetected = new Map<string, any>();
|
|
342
|
+
const result = inferRequiredChildren(segments, autoDetected);
|
|
343
|
+
expect(result.size).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('uses ai.subComponents when autoDetected is empty', () => {
|
|
347
|
+
const segments: Record<string, CompiledSegment> = {
|
|
348
|
+
Dialog: makeSegment('Dialog', {
|
|
349
|
+
ai: { subComponents: ['Content', 'Footer'] },
|
|
350
|
+
variants: [
|
|
351
|
+
{ name: 'V1', description: 'V1', code: '<Dialog><Dialog.Content>...</Dialog.Content><Dialog.Footer>...</Dialog.Footer></Dialog>' },
|
|
352
|
+
{ name: 'V2', description: 'V2', code: '<Dialog><Dialog.Content>...</Dialog.Content><Dialog.Footer>...</Dialog.Footer></Dialog>' },
|
|
353
|
+
],
|
|
354
|
+
}),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const autoDetected = new Map<string, any>();
|
|
358
|
+
const result = inferRequiredChildren(segments, autoDetected);
|
|
359
|
+
expect(result.get('Dialog')).toEqual(['Content', 'Footer']);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// generateCommonPatterns
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
describe('generateCommonPatterns()', () => {
|
|
368
|
+
it('generates skeleton patterns from variant code', () => {
|
|
369
|
+
const segments: Record<string, CompiledSegment> = {
|
|
370
|
+
Dialog: makeSegment('Dialog', {
|
|
371
|
+
variants: [
|
|
372
|
+
{
|
|
373
|
+
name: 'Default',
|
|
374
|
+
description: 'Default',
|
|
375
|
+
code: '<Dialog>\n <Dialog.Content>Hello</Dialog.Content>\n <Dialog.Footer>Close</Dialog.Footer>\n</Dialog>',
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
}),
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const autoDetected = new Map([['Dialog', { subComponents: ['Content', 'Footer'] }]]);
|
|
382
|
+
const result = generateCommonPatterns(segments, autoDetected);
|
|
383
|
+
|
|
384
|
+
expect(result.has('Dialog')).toBe(true);
|
|
385
|
+
const patterns = result.get('Dialog')!;
|
|
386
|
+
expect(patterns[0]).toContain('<Dialog>');
|
|
387
|
+
expect(patterns[0]).toContain('Dialog.Content');
|
|
388
|
+
expect(patterns[0]).toContain('Dialog.Footer');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('returns empty for components without sub-components', () => {
|
|
392
|
+
const segments: Record<string, CompiledSegment> = {
|
|
393
|
+
Button: makeSegment('Button'),
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const autoDetected = new Map<string, any>();
|
|
397
|
+
const result = generateCommonPatterns(segments, autoDetected);
|
|
398
|
+
expect(result.size).toBe(0);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// mergeAndDeduplicate
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
describe('mergeAndDeduplicate()', () => {
|
|
407
|
+
it('deduplicates by (source, target, type)', () => {
|
|
408
|
+
const edges: GraphEdge[] = [
|
|
409
|
+
{ source: 'A', target: 'B', type: 'imports', weight: 0.5, provenance: 'test1' },
|
|
410
|
+
{ source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test2' },
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
const result = mergeAndDeduplicate(edges);
|
|
414
|
+
expect(result.length).toBe(1);
|
|
415
|
+
expect(result[0].weight).toBe(1.0); // keeps highest weight
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('preserves different edge types between same components', () => {
|
|
419
|
+
const edges: GraphEdge[] = [
|
|
420
|
+
{ source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test' },
|
|
421
|
+
{ source: 'A', target: 'B', type: 'renders', weight: 0.5, provenance: 'test' },
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
const result = mergeAndDeduplicate(edges);
|
|
425
|
+
expect(result.length).toBe(2);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('preserves edges in different directions', () => {
|
|
429
|
+
const edges: GraphEdge[] = [
|
|
430
|
+
{ source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test' },
|
|
431
|
+
{ source: 'B', target: 'A', type: 'imports', weight: 1.0, provenance: 'test' },
|
|
432
|
+
];
|
|
433
|
+
|
|
434
|
+
const result = mergeAndDeduplicate(edges);
|
|
435
|
+
expect(result.length).toBe(2);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('handles empty input', () => {
|
|
439
|
+
expect(mergeAndDeduplicate([])).toEqual([]);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// buildComponentGraph (integration, with skipSourceAnalysis)
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
describe('buildComponentGraph()', () => {
|
|
448
|
+
it('builds a graph from segments and blocks', async () => {
|
|
449
|
+
const segments: Record<string, CompiledSegment> = {
|
|
450
|
+
Button: makeSegment('Button'),
|
|
451
|
+
Dialog: makeSegment('Dialog', {
|
|
452
|
+
variants: [
|
|
453
|
+
{ name: 'Default', description: 'Default', code: '<Dialog><Button>OK</Button></Dialog>' },
|
|
454
|
+
],
|
|
455
|
+
relations: [
|
|
456
|
+
{ component: 'Popover', relationship: 'alternative', note: 'Non-modal' },
|
|
457
|
+
],
|
|
458
|
+
}),
|
|
459
|
+
Input: makeSegment('Input'),
|
|
460
|
+
Popover: makeSegment('Popover'),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const blocks: Record<string, CompiledBlock> = {
|
|
464
|
+
LoginForm: makeBlock('LoginForm', ['Dialog', 'Input', 'Button']),
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const result = await buildComponentGraph(segments, blocks, '/tmp/fake', {
|
|
468
|
+
skipSourceAnalysis: true,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(result.graph.nodes.length).toBe(4);
|
|
472
|
+
expect(result.graph.edges.length).toBeGreaterThan(0);
|
|
473
|
+
expect(result.graph.health.nodeCount).toBe(4);
|
|
474
|
+
|
|
475
|
+
// Check renders edge: Dialog → Button
|
|
476
|
+
expect(result.graph.edges.some(
|
|
477
|
+
e => e.source === 'Dialog' && e.target === 'Button' && e.type === 'renders'
|
|
478
|
+
)).toBe(true);
|
|
479
|
+
|
|
480
|
+
// Check composes edges from block
|
|
481
|
+
expect(result.graph.edges.some(e => e.type === 'composes')).toBe(true);
|
|
482
|
+
|
|
483
|
+
// Check relation edge: Dialog → Popover alternative
|
|
484
|
+
expect(result.graph.edges.some(
|
|
485
|
+
e => e.source === 'Dialog' && e.target === 'Popover' && e.type === 'alternative-to'
|
|
486
|
+
)).toBe(true);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('auto-detects metadata and reports warnings', async () => {
|
|
490
|
+
const segments: Record<string, CompiledSegment> = {
|
|
491
|
+
Dialog: makeSegment('Dialog', {
|
|
492
|
+
ai: {
|
|
493
|
+
compositionPattern: 'compound',
|
|
494
|
+
subComponents: ['Content'],
|
|
495
|
+
// Missing Footer which is auto-detected below
|
|
496
|
+
},
|
|
497
|
+
variants: [
|
|
498
|
+
{ name: 'Default', description: 'Default', code: '<Dialog><Dialog.Content>...</Dialog.Content></Dialog>' },
|
|
499
|
+
],
|
|
500
|
+
}),
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const result = await buildComponentGraph(segments, {}, '/tmp/fake', {
|
|
504
|
+
skipSourceAnalysis: true,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Should produce a valid graph even with skipSourceAnalysis
|
|
508
|
+
expect(result.graph.nodes.length).toBe(1);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('handles empty segments', async () => {
|
|
512
|
+
const result = await buildComponentGraph({}, {}, '/tmp/fake', {
|
|
513
|
+
skipSourceAnalysis: true,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
expect(result.graph.nodes.length).toBe(0);
|
|
517
|
+
expect(result.graph.edges.length).toBe(0);
|
|
518
|
+
expect(result.graph.health.nodeCount).toBe(0);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('computes health metrics', async () => {
|
|
522
|
+
const segments: Record<string, CompiledSegment> = {
|
|
523
|
+
Button: makeSegment('Button'),
|
|
524
|
+
Input: makeSegment('Input'),
|
|
525
|
+
Orphan: makeSegment('Orphan'),
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const blocks: Record<string, CompiledBlock> = {
|
|
529
|
+
Form: makeBlock('Form', ['Button', 'Input']),
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const result = await buildComponentGraph(segments, blocks, '/tmp/fake', {
|
|
533
|
+
skipSourceAnalysis: true,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
expect(result.graph.health.nodeCount).toBe(3);
|
|
537
|
+
// Button and Input have composes edge, Orphan has none
|
|
538
|
+
expect(result.graph.health.orphans).toContain('Orphan');
|
|
539
|
+
// Composition coverage: 2/3 components are in blocks
|
|
540
|
+
expect(result.graph.health.compositionCoverage).toBe(67);
|
|
541
|
+
});
|
|
542
|
+
});
|