@usefragments/core 1.0.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/LICENSE +21 -0
- package/dist/chunk-243QYRUF.js +2145 -0
- package/dist/chunk-243QYRUF.js.map +1 -0
- package/dist/chunk-3LLRNCPX.js +111 -0
- package/dist/chunk-3LLRNCPX.js.map +1 -0
- package/dist/chunk-EIYNNS77.js +48 -0
- package/dist/chunk-EIYNNS77.js.map +1 -0
- package/dist/chunk-JNBFJ34I.js +326 -0
- package/dist/chunk-JNBFJ34I.js.map +1 -0
- package/dist/chunk-R3X7UBHU.js +228 -0
- package/dist/chunk-R3X7UBHU.js.map +1 -0
- package/dist/chunk-SH4KPIYH.js +393 -0
- package/dist/chunk-SH4KPIYH.js.map +1 -0
- package/dist/chunk-V4VQB57N.js +56 -0
- package/dist/chunk-V4VQB57N.js.map +1 -0
- package/dist/chunk-X34IA4LR.js +319 -0
- package/dist/chunk-X34IA4LR.js.map +1 -0
- package/dist/chunk-YF65VYRY.js +493 -0
- package/dist/chunk-YF65VYRY.js.map +1 -0
- package/dist/codes/index.d.ts +40 -0
- package/dist/codes/index.js +23 -0
- package/dist/codes/index.js.map +1 -0
- package/dist/compiled-types/index.d.ts +5 -0
- package/dist/compiled-types/index.js +1 -0
- package/dist/compiled-types/index.js.map +1 -0
- package/dist/generate/index.d.ts +47 -0
- package/dist/generate/index.js +11 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/governance-BOa3KyiJ.d.ts +3279 -0
- package/dist/graph/index.d.ts +66 -0
- package/dist/graph/index.js +17 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +11388 -0
- package/dist/index.js +8711 -0
- package/dist/index.js.map +1 -0
- package/dist/package-identity.d.ts +42 -0
- package/dist/package-identity.js +23 -0
- package/dist/package-identity.js.map +1 -0
- package/dist/preview/index.d.ts +484 -0
- package/dist/preview/index.js +1129 -0
- package/dist/preview/index.js.map +1 -0
- package/dist/react-types.d.ts +22 -0
- package/dist/react-types.js +1 -0
- package/dist/react-types.js.map +1 -0
- package/dist/registry.d.ts +2367 -0
- package/dist/registry.js +62 -0
- package/dist/registry.js.map +1 -0
- package/dist/schemas/index.d.ts +2584 -0
- package/dist/schemas/index.js +55 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/severity.d.ts +18 -0
- package/dist/severity.js +27 -0
- package/dist/severity.js.map +1 -0
- package/dist/test-utils.d.ts +14 -0
- package/dist/test-utils.js +63 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/topology/index.d.ts +48 -0
- package/dist/topology/index.js +7 -0
- package/dist/topology/index.js.map +1 -0
- package/dist/types-xJ2xyp_G.d.ts +119 -0
- package/package.json +120 -0
- package/src/__tests__/contract-parser.test.ts +614 -0
- package/src/__tests__/dtcg-generators.test.ts +439 -0
- package/src/__tests__/dtcg-parser.test.ts +884 -0
- package/src/__tests__/preview-runtime.test.tsx +111 -0
- package/src/__tests__/schema.test.ts +31 -0
- package/src/__tests__/severity.test.ts +34 -0
- package/src/__tests__/style-comparison.test.ts +662 -0
- package/src/__tests__/tokens-compat.test.ts +51 -0
- package/src/agent-format.test.ts +105 -0
- package/src/agent-format.ts +141 -0
- package/src/bundle.ts +149 -0
- package/src/codes/__tests__/codes.test.ts +73 -0
- package/src/codes/codes.ts +463 -0
- package/src/codes/index.ts +7 -0
- package/src/codes/source-finding-bridge.ts +113 -0
- package/src/compiled-types/index.ts +293 -0
- package/src/component-contract.ts +231 -0
- package/src/component-discovery.ts +80 -0
- package/src/composition.test.ts +262 -0
- package/src/composition.ts +318 -0
- package/src/config-paths.test.ts +78 -0
- package/src/config-paths.ts +47 -0
- package/src/config.ts +18 -0
- package/src/conform-prove.test.ts +274 -0
- package/src/conform-prove.ts +326 -0
- package/src/conform.ts +160 -0
- package/src/constants.ts +114 -0
- package/src/context.ts +2 -0
- package/src/contract/fixture.ts +110 -0
- package/src/contract/hash.test.ts +86 -0
- package/src/contract/hash.ts +195 -0
- package/src/contract/index.ts +43 -0
- package/src/contract/preimage.test.ts +254 -0
- package/src/contract/preimage.ts +365 -0
- package/src/contract/stamp.test.ts +100 -0
- package/src/contract/stamp.ts +105 -0
- package/src/contract-parser.ts +141 -0
- package/src/defineFragment.test.ts +257 -0
- package/src/defineFragment.ts +293 -0
- package/src/dtcg-generators.ts +324 -0
- package/src/dtcg-parser.ts +1 -0
- package/src/dtcg.ts +237 -0
- package/src/facts/__snapshots__/facts.test.ts.snap +124 -0
- package/src/facts/builders.ts +835 -0
- package/src/facts/compile.ts +422 -0
- package/src/facts/fact-index.ts +394 -0
- package/src/facts/facts.test.ts +494 -0
- package/src/facts/ids.ts +125 -0
- package/src/facts/index.ts +119 -0
- package/src/facts/types.ts +614 -0
- package/src/figma.ts +263 -0
- package/src/fragment-types.ts +214 -0
- package/src/generate/generate.test.ts +594 -0
- package/src/generate/index.ts +438 -0
- package/src/governance-integrity.test.ts +186 -0
- package/src/governance-integrity.ts +282 -0
- package/src/governance.test.ts +300 -0
- package/src/governance.ts +605 -0
- package/src/governance.type-test.ts +75 -0
- package/src/graph/engine.ts +446 -0
- package/src/graph/graph.test.ts +746 -0
- package/src/graph/index.ts +32 -0
- package/src/graph/serialization.ts +55 -0
- package/src/graph/types.ts +178 -0
- package/src/index.ts +891 -0
- package/src/local-canonical.ts +102 -0
- package/src/mcp-snapshot.ts +222 -0
- package/src/package-identity-match.ts +33 -0
- package/src/package-identity.generated.ts +233 -0
- package/src/package-identity.test.ts +99 -0
- package/src/package-identity.ts +157 -0
- package/src/performance-presets.ts +142 -0
- package/src/preview/comparison-artifact.test.ts +228 -0
- package/src/preview/comparison-artifact.ts +181 -0
- package/src/preview/comparison-context.ts +93 -0
- package/src/preview/comparison-evidence.ts +112 -0
- package/src/preview/comparison-format.ts +21 -0
- package/src/preview/comparison-presentation.ts +187 -0
- package/src/preview/comparison-references.test.ts +430 -0
- package/src/preview/comparison-references.ts +72 -0
- package/src/preview/comparison-score.ts +59 -0
- package/src/preview/comparison-styles.ts +197 -0
- package/src/preview/comparison.test.ts +559 -0
- package/src/preview/comparison.ts +488 -0
- package/src/preview/index.ts +10 -0
- package/src/preview/types.ts +309 -0
- package/src/preview/validation.test.ts +290 -0
- package/src/preview/validation.ts +356 -0
- package/src/preview-runtime.tsx +144 -0
- package/src/raw-html-canonical.test.ts +41 -0
- package/src/raw-html-canonical.ts +179 -0
- package/src/react-types.ts +21 -0
- package/src/registry-install-plan.ts +344 -0
- package/src/registry.test.ts +245 -0
- package/src/registry.ts +369 -0
- package/src/rules/a11y-required-accessible-name.ts +93 -0
- package/src/rules/color-math.test.ts +72 -0
- package/src/rules/color-math.ts +192 -0
- package/src/rules/components-forbidden-prop-value.ts +108 -0
- package/src/rules/components-prefer-library.ts +943 -0
- package/src/rules/components-unknown-prop.ts +94 -0
- package/src/rules/composition-pattern-schema.ts +125 -0
- package/src/rules/composition-pattern.test.ts +320 -0
- package/src/rules/composition-pattern.ts +271 -0
- package/src/rules/emit-gate.test.ts +42 -0
- package/src/rules/emit-gate.ts +60 -0
- package/src/rules/finding.ts +54 -0
- package/src/rules/index.ts +235 -0
- package/src/rules/jsx-preferred-component.ts +68 -0
- package/src/rules/jsx-preferred-import-path.ts +67 -0
- package/src/rules/presets.ts +57 -0
- package/src/rules/props-invalid-value.ts +115 -0
- package/src/rules/rules.test.ts +3636 -0
- package/src/rules/spacing-resolution.ts +184 -0
- package/src/rules/styles-no-raw-color.ts +329 -0
- package/src/rules/styles-no-raw-dimensions.ts +357 -0
- package/src/rules/styles-no-raw-spacing.ts +236 -0
- package/src/rules/styles-no-raw-typography.ts +237 -0
- package/src/rules/tailwind-arbitrary-color.test.ts +128 -0
- package/src/rules/tailwind-arbitrary-color.ts +93 -0
- package/src/rules/tailwind-arbitrary-spacing.test.ts +157 -0
- package/src/rules/tailwind-arbitrary-spacing.ts +120 -0
- package/src/rules/tailwind-forbidden-palette.test.ts +116 -0
- package/src/rules/tailwind-forbidden-palette.ts +107 -0
- package/src/rules/tailwind-off-scale-spacing-token.test.ts +100 -0
- package/src/rules/tailwind-off-scale-spacing-token.ts +108 -0
- package/src/rules/tailwind-raw-color-via-token.test.ts +108 -0
- package/src/rules/tailwind-raw-color-via-token.ts +82 -0
- package/src/rules/tailwind-rule-utils.ts +106 -0
- package/src/rules/tailwind-unknown-class.test.ts +157 -0
- package/src/rules/tailwind-unknown-class.ts +121 -0
- package/src/rules/taxonomy.test.ts +238 -0
- package/src/rules/theme-no-theme-coupled-literal.ts +132 -0
- package/src/rules/tiers.ts +111 -0
- package/src/rules/tokens-css-vars-must-be-defined.test.ts +244 -0
- package/src/rules/tokens-css-vars-must-be-defined.ts +156 -0
- package/src/rules/tokens-require-dual-fallback.ts +78 -0
- package/src/rules/types.ts +27 -0
- package/src/rules/utils.test.ts +110 -0
- package/src/rules/utils.ts +599 -0
- package/src/schema.ts +427 -0
- package/src/schemas/index.ts +275 -0
- package/src/schemas/schemas.test.ts +124 -0
- package/src/severity.ts +54 -0
- package/src/storyAdapter.test.ts +571 -0
- package/src/storyAdapter.ts +761 -0
- package/src/storyFilters.test.ts +350 -0
- package/src/storyFilters.ts +253 -0
- package/src/storybook-csf.ts +11 -0
- package/src/style-comparison.ts +423 -0
- package/src/test-utils.ts +68 -0
- package/src/token-parser.ts +2 -0
- package/src/token-types.ts +287 -0
- package/src/tokens/categories.ts +131 -0
- package/src/tokens/color.ts +193 -0
- package/src/tokens/design-token-parser.test.ts +159 -0
- package/src/tokens/design-token-parser.ts +402 -0
- package/src/tokens/dtcg.ts +428 -0
- package/src/tokens/index.ts +130 -0
- package/src/tokens/lookup.ts +19 -0
- package/src/tokens/scss.ts +171 -0
- package/src/tokens/tailwind-v4.ts +187 -0
- package/src/tokens/types.ts +43 -0
- package/src/topology/index.ts +9 -0
- package/src/topology/resolve-area.test.ts +174 -0
- package/src/topology/resolve-area.ts +215 -0
- package/src/types.ts +1057 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ComponentGraphEngine, computeHealthFromData } from './engine.js';
|
|
3
|
+
import { serializeGraph, deserializeGraph } from './serialization.js';
|
|
4
|
+
import type {
|
|
5
|
+
ComponentGraph,
|
|
6
|
+
ComponentNode,
|
|
7
|
+
GraphEdge,
|
|
8
|
+
GraphEdgeType,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
import { GRAPH_EDGE_TYPES, EDGE_TYPE_WEIGHTS } from './types.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Test fixtures — 6-node graph
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
function createTestNodes(): ComponentNode[] {
|
|
17
|
+
return [
|
|
18
|
+
{ name: 'Button', category: 'actions', status: 'stable', compositionPattern: 'simple' },
|
|
19
|
+
{ name: 'Dialog', category: 'overlays', status: 'stable', compositionPattern: 'compound', subComponents: ['Trigger', 'Content', 'Header', 'Body', 'Footer', 'Close'] },
|
|
20
|
+
{ name: 'Header', category: 'navigation', status: 'stable', compositionPattern: 'compound', subComponents: ['Brand', 'Nav', 'Actions'] },
|
|
21
|
+
{ name: 'Sidebar', category: 'navigation', status: 'stable', compositionPattern: 'compound', subComponents: ['Toggle', 'Content', 'Footer'] },
|
|
22
|
+
{ name: 'AppShell', category: 'layout', status: 'stable', compositionPattern: 'compound', subComponents: ['Header', 'Sidebar', 'Main'] },
|
|
23
|
+
{ name: 'Input', category: 'forms', status: 'stable', compositionPattern: 'simple' },
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createTestEdges(): GraphEdge[] {
|
|
28
|
+
return [
|
|
29
|
+
// AppShell imports Header and Sidebar
|
|
30
|
+
{ source: 'AppShell', target: 'Header', type: 'imports', weight: 1.0, provenance: 'source:AppShell/index.tsx' },
|
|
31
|
+
{ source: 'AppShell', target: 'Sidebar', type: 'imports', weight: 1.0, provenance: 'source:AppShell/index.tsx' },
|
|
32
|
+
// Header imports Button
|
|
33
|
+
{ source: 'Header', target: 'Button', type: 'imports', weight: 1.0, provenance: 'source:Header/index.tsx' },
|
|
34
|
+
// Dialog renders Button in variants
|
|
35
|
+
{ source: 'Dialog', target: 'Button', type: 'renders', weight: 0.5, provenance: 'variant:Default' },
|
|
36
|
+
// Dialog and Sidebar use hooks from each other (fictional)
|
|
37
|
+
{ source: 'Dialog', target: 'Sidebar', type: 'hook-depends', weight: 0.75, provenance: 'source:Dialog/index.tsx' },
|
|
38
|
+
// AppShell and Dialog compose in a block
|
|
39
|
+
{ source: 'AppShell', target: 'Dialog', type: 'composes', weight: 0.5, provenance: 'block:DashboardLayout' },
|
|
40
|
+
// Header parent-of Button (Button is a sub-component in Header)
|
|
41
|
+
{ source: 'Header', target: 'Button', type: 'parent-of', weight: 1.0, provenance: 'relation' },
|
|
42
|
+
// Dialog alternative to a Popover (using Header as proxy)
|
|
43
|
+
{ source: 'Dialog', target: 'Header', type: 'alternative-to', weight: 1.0, provenance: 'relation', note: 'Dialog provides modal overlay, Header provides persistent navigation' },
|
|
44
|
+
// Header sibling of Sidebar
|
|
45
|
+
{ source: 'Header', target: 'Sidebar', type: 'sibling-of', weight: 0.75, provenance: 'relation' },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createTestBlocks(): Record<string, { components: string[] }> {
|
|
50
|
+
return {
|
|
51
|
+
'DashboardLayout': { components: ['AppShell', 'Header', 'Sidebar'] },
|
|
52
|
+
'LoginForm': { components: ['Dialog', 'Input', 'Button'] },
|
|
53
|
+
'SearchBar': { components: ['Input', 'Button'] },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createTestGraph(): ComponentGraph {
|
|
58
|
+
const nodes = createTestNodes();
|
|
59
|
+
const edges = createTestEdges();
|
|
60
|
+
const blockIndex = new Map<string, string[]>();
|
|
61
|
+
const blocks = createTestBlocks();
|
|
62
|
+
for (const [blockName, block] of Object.entries(blocks)) {
|
|
63
|
+
for (const comp of block.components) {
|
|
64
|
+
const existing = blockIndex.get(comp);
|
|
65
|
+
if (existing) existing.push(blockName);
|
|
66
|
+
else blockIndex.set(comp, [blockName]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const health = computeHealthFromData(nodes, edges, blockIndex);
|
|
70
|
+
return { nodes, edges, health };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createEngine(): ComponentGraphEngine {
|
|
74
|
+
return new ComponentGraphEngine(createTestGraph(), createTestBlocks());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Type constants tests
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
describe('graph types', () => {
|
|
82
|
+
it('defines all 7 edge types', () => {
|
|
83
|
+
expect(GRAPH_EDGE_TYPES).toHaveLength(7);
|
|
84
|
+
expect(GRAPH_EDGE_TYPES).toContain('imports');
|
|
85
|
+
expect(GRAPH_EDGE_TYPES).toContain('hook-depends');
|
|
86
|
+
expect(GRAPH_EDGE_TYPES).toContain('renders');
|
|
87
|
+
expect(GRAPH_EDGE_TYPES).toContain('composes');
|
|
88
|
+
expect(GRAPH_EDGE_TYPES).toContain('parent-of');
|
|
89
|
+
expect(GRAPH_EDGE_TYPES).toContain('alternative-to');
|
|
90
|
+
expect(GRAPH_EDGE_TYPES).toContain('sibling-of');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('assigns correct default weights', () => {
|
|
94
|
+
expect(EDGE_TYPE_WEIGHTS['imports']).toBe(1.0);
|
|
95
|
+
expect(EDGE_TYPE_WEIGHTS['hook-depends']).toBe(0.75);
|
|
96
|
+
expect(EDGE_TYPE_WEIGHTS['renders']).toBe(0.5);
|
|
97
|
+
expect(EDGE_TYPE_WEIGHTS['composes']).toBe(0.5);
|
|
98
|
+
expect(EDGE_TYPE_WEIGHTS['parent-of']).toBe(1.0);
|
|
99
|
+
expect(EDGE_TYPE_WEIGHTS['alternative-to']).toBe(1.0);
|
|
100
|
+
expect(EDGE_TYPE_WEIGHTS['sibling-of']).toBe(0.75);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Engine — core queries
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
describe('ComponentGraphEngine', () => {
|
|
109
|
+
describe('constructor', () => {
|
|
110
|
+
it('builds adjacency lists from graph data', () => {
|
|
111
|
+
const engine = createEngine();
|
|
112
|
+
expect(engine.hasNode('Button')).toBe(true);
|
|
113
|
+
expect(engine.hasNode('NonExistent')).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('stores node metadata', () => {
|
|
117
|
+
const engine = createEngine();
|
|
118
|
+
const node = engine.getNode('Dialog');
|
|
119
|
+
expect(node).toBeDefined();
|
|
120
|
+
expect(node!.category).toBe('overlays');
|
|
121
|
+
expect(node!.compositionPattern).toBe('compound');
|
|
122
|
+
expect(node!.subComponents).toContain('Trigger');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('dependencies()', () => {
|
|
127
|
+
it('returns outgoing edges', () => {
|
|
128
|
+
const engine = createEngine();
|
|
129
|
+
const deps = engine.dependencies('AppShell');
|
|
130
|
+
expect(deps.length).toBeGreaterThanOrEqual(2);
|
|
131
|
+
expect(deps.some(e => e.target === 'Header' && e.type === 'imports')).toBe(true);
|
|
132
|
+
expect(deps.some(e => e.target === 'Sidebar' && e.type === 'imports')).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('filters by edge type', () => {
|
|
136
|
+
const engine = createEngine();
|
|
137
|
+
const importDeps = engine.dependencies('AppShell', ['imports']);
|
|
138
|
+
expect(importDeps.every(e => e.type === 'imports')).toBe(true);
|
|
139
|
+
expect(importDeps.length).toBe(2); // Header, Sidebar
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns empty array for unknown component', () => {
|
|
143
|
+
const engine = createEngine();
|
|
144
|
+
expect(engine.dependencies('NonExistent')).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns empty array for component with no outgoing edges', () => {
|
|
148
|
+
const engine = createEngine();
|
|
149
|
+
// Input has no outgoing edges in our test graph
|
|
150
|
+
expect(engine.dependencies('Input')).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('dependents()', () => {
|
|
155
|
+
it('returns incoming edges', () => {
|
|
156
|
+
const engine = createEngine();
|
|
157
|
+
const deps = engine.dependents('Button');
|
|
158
|
+
expect(deps.length).toBeGreaterThanOrEqual(2);
|
|
159
|
+
expect(deps.some(e => e.source === 'Header')).toBe(true);
|
|
160
|
+
expect(deps.some(e => e.source === 'Dialog')).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('filters by edge type', () => {
|
|
164
|
+
const engine = createEngine();
|
|
165
|
+
const renderDeps = engine.dependents('Button', ['renders']);
|
|
166
|
+
expect(renderDeps.every(e => e.type === 'renders')).toBe(true);
|
|
167
|
+
expect(renderDeps.length).toBe(1); // Dialog
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('returns empty array for component with no dependents', () => {
|
|
171
|
+
const engine = createEngine();
|
|
172
|
+
expect(engine.dependents('AppShell')).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('impact()', () => {
|
|
177
|
+
it('finds transitively affected components', () => {
|
|
178
|
+
const engine = createEngine();
|
|
179
|
+
const result = engine.impact('Button');
|
|
180
|
+
expect(result.component).toBe('Button');
|
|
181
|
+
// Header and Dialog depend on Button
|
|
182
|
+
expect(result.affected.some(a => a.component === 'Header')).toBe(true);
|
|
183
|
+
expect(result.affected.some(a => a.component === 'Dialog')).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('tracks depth levels', () => {
|
|
187
|
+
const engine = createEngine();
|
|
188
|
+
const result = engine.impact('Button');
|
|
189
|
+
const headerEntry = result.affected.find(a => a.component === 'Header');
|
|
190
|
+
expect(headerEntry?.depth).toBe(1);
|
|
191
|
+
// AppShell depends on Header, so it's at depth 2
|
|
192
|
+
const appShellEntry = result.affected.find(a => a.component === 'AppShell');
|
|
193
|
+
expect(appShellEntry?.depth).toBe(2);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('includes path from source', () => {
|
|
197
|
+
const engine = createEngine();
|
|
198
|
+
const result = engine.impact('Button');
|
|
199
|
+
const headerEntry = result.affected.find(a => a.component === 'Header');
|
|
200
|
+
expect(headerEntry?.path).toEqual(['Button', 'Header']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('respects maxDepth', () => {
|
|
204
|
+
const engine = createEngine();
|
|
205
|
+
const result = engine.impact('Button', 1);
|
|
206
|
+
// Only depth 1 — direct dependents
|
|
207
|
+
expect(result.affected.every(a => a.depth === 1)).toBe(true);
|
|
208
|
+
expect(result.affected.some(a => a.component === 'AppShell')).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('finds affected blocks', () => {
|
|
212
|
+
const engine = createEngine();
|
|
213
|
+
const result = engine.impact('Button');
|
|
214
|
+
expect(result.affectedBlocks).toContain('DashboardLayout');
|
|
215
|
+
expect(result.affectedBlocks).toContain('LoginForm');
|
|
216
|
+
expect(result.affectedBlocks).toContain('SearchBar');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('returns totalAffected count', () => {
|
|
220
|
+
const engine = createEngine();
|
|
221
|
+
const result = engine.impact('Button');
|
|
222
|
+
expect(result.totalAffected).toBe(result.affected.length);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('handles component with no dependents', () => {
|
|
226
|
+
const engine = createEngine();
|
|
227
|
+
const result = engine.impact('AppShell');
|
|
228
|
+
expect(result.totalAffected).toBe(0);
|
|
229
|
+
expect(result.affected).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('path()', () => {
|
|
234
|
+
it('finds shortest path between components', () => {
|
|
235
|
+
const engine = createEngine();
|
|
236
|
+
const result = engine.path('Button', 'Sidebar');
|
|
237
|
+
expect(result.found).toBe(true);
|
|
238
|
+
expect(result.path.length).toBeGreaterThanOrEqual(2);
|
|
239
|
+
expect(result.path[0]).toBe('Button');
|
|
240
|
+
expect(result.path[result.path.length - 1]).toBe('Sidebar');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('returns edges along the path', () => {
|
|
244
|
+
const engine = createEngine();
|
|
245
|
+
const result = engine.path('Button', 'Sidebar');
|
|
246
|
+
expect(result.edges.length).toBe(result.path.length - 1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('returns self-path for same component', () => {
|
|
250
|
+
const engine = createEngine();
|
|
251
|
+
const result = engine.path('Button', 'Button');
|
|
252
|
+
expect(result.found).toBe(true);
|
|
253
|
+
expect(result.path).toEqual(['Button']);
|
|
254
|
+
expect(result.edges).toEqual([]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('returns not found for disconnected components', () => {
|
|
258
|
+
// Create a graph with an isolated node
|
|
259
|
+
const nodes: ComponentNode[] = [
|
|
260
|
+
{ name: 'A', category: 'test', status: 'stable' },
|
|
261
|
+
{ name: 'B', category: 'test', status: 'stable' },
|
|
262
|
+
];
|
|
263
|
+
const graph: ComponentGraph = {
|
|
264
|
+
nodes,
|
|
265
|
+
edges: [],
|
|
266
|
+
health: computeHealthFromData(nodes, []),
|
|
267
|
+
};
|
|
268
|
+
const engine = new ComponentGraphEngine(graph);
|
|
269
|
+
const result = engine.path('A', 'B');
|
|
270
|
+
expect(result.found).toBe(false);
|
|
271
|
+
expect(result.path).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('islands()', () => {
|
|
276
|
+
it('finds connected components', () => {
|
|
277
|
+
const engine = createEngine();
|
|
278
|
+
const result = engine.islands();
|
|
279
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
280
|
+
// All 6 nodes should be connected in our test graph
|
|
281
|
+
const allNodes = result.flat();
|
|
282
|
+
expect(allNodes).toContain('Button');
|
|
283
|
+
expect(allNodes).toContain('Dialog');
|
|
284
|
+
expect(allNodes).toContain('Header');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('sorts largest island first', () => {
|
|
288
|
+
const engine = createEngine();
|
|
289
|
+
const result = engine.islands();
|
|
290
|
+
for (let i = 1; i < result.length; i++) {
|
|
291
|
+
expect(result[i - 1].length).toBeGreaterThanOrEqual(result[i].length);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('detects isolated nodes as separate islands', () => {
|
|
296
|
+
const nodes: ComponentNode[] = [
|
|
297
|
+
{ name: 'A', category: 'test', status: 'stable' },
|
|
298
|
+
{ name: 'B', category: 'test', status: 'stable' },
|
|
299
|
+
{ name: 'C', category: 'test', status: 'stable' },
|
|
300
|
+
];
|
|
301
|
+
const edges: GraphEdge[] = [
|
|
302
|
+
{ source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test' },
|
|
303
|
+
];
|
|
304
|
+
const graph: ComponentGraph = {
|
|
305
|
+
nodes,
|
|
306
|
+
edges,
|
|
307
|
+
health: computeHealthFromData(nodes, edges),
|
|
308
|
+
};
|
|
309
|
+
const engine = new ComponentGraphEngine(graph);
|
|
310
|
+
const result = engine.islands();
|
|
311
|
+
expect(result.length).toBe(2);
|
|
312
|
+
expect(result[0]).toEqual(['A', 'B']);
|
|
313
|
+
expect(result[1]).toEqual(['C']);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('neighbors()', () => {
|
|
318
|
+
it('returns direct neighbors at 1 hop', () => {
|
|
319
|
+
const engine = createEngine();
|
|
320
|
+
const result = engine.neighbors('Header');
|
|
321
|
+
expect(result.component).toBe('Header');
|
|
322
|
+
expect(result.neighbors.some(n => n.component === 'Button')).toBe(true);
|
|
323
|
+
expect(result.neighbors.some(n => n.component === 'AppShell')).toBe(true);
|
|
324
|
+
expect(result.neighbors.every(n => n.hops === 1)).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('expands to multi-hop neighbors', () => {
|
|
328
|
+
const engine = createEngine();
|
|
329
|
+
const result = engine.neighbors('Header', 2);
|
|
330
|
+
// At 2 hops we should also reach Dialog (via Button or Sidebar)
|
|
331
|
+
expect(result.neighbors.some(n => n.component === 'Dialog')).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('returns empty for unknown component', () => {
|
|
335
|
+
const engine = createEngine();
|
|
336
|
+
const result = engine.neighbors('NonExistent');
|
|
337
|
+
expect(result.neighbors).toEqual([]);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
// Design-system queries
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
describe('composition()', () => {
|
|
346
|
+
it('returns compound component tree', () => {
|
|
347
|
+
const engine = createEngine();
|
|
348
|
+
const result = engine.composition('Dialog');
|
|
349
|
+
expect(result.component).toBe('Dialog');
|
|
350
|
+
expect(result.compositionPattern).toBe('compound');
|
|
351
|
+
expect(result.subComponents).toContain('Trigger');
|
|
352
|
+
expect(result.subComponents).toContain('Content');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('finds parent from parent-of edges', () => {
|
|
356
|
+
const engine = createEngine();
|
|
357
|
+
const result = engine.composition('Button');
|
|
358
|
+
// Header has a parent-of edge to Button
|
|
359
|
+
expect(result.parent).toBe('Header');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('finds siblings from sibling-of edges', () => {
|
|
363
|
+
const engine = createEngine();
|
|
364
|
+
const result = engine.composition('Header');
|
|
365
|
+
expect(result.siblings).toContain('Sidebar');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('finds blocks using the component', () => {
|
|
369
|
+
const engine = createEngine();
|
|
370
|
+
const result = engine.composition('Button');
|
|
371
|
+
expect(result.blocks).toContain('LoginForm');
|
|
372
|
+
expect(result.blocks).toContain('SearchBar');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('returns empty arrays for simple component', () => {
|
|
376
|
+
const engine = createEngine();
|
|
377
|
+
const result = engine.composition('Input');
|
|
378
|
+
expect(result.subComponents).toEqual([]);
|
|
379
|
+
expect(result.children).toEqual([]);
|
|
380
|
+
expect(result.siblings).toEqual([]);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe('alternatives()', () => {
|
|
385
|
+
it('finds alternative components', () => {
|
|
386
|
+
const engine = createEngine();
|
|
387
|
+
const result = engine.alternatives('Dialog');
|
|
388
|
+
expect(result.some(a => a.component === 'Header')).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('includes notes on alternatives', () => {
|
|
392
|
+
const engine = createEngine();
|
|
393
|
+
const result = engine.alternatives('Dialog');
|
|
394
|
+
const headerAlt = result.find(a => a.component === 'Header');
|
|
395
|
+
expect(headerAlt?.note).toBeDefined();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('finds bidirectional alternatives', () => {
|
|
399
|
+
const engine = createEngine();
|
|
400
|
+
// Header should also see Dialog as alternative (incoming edge)
|
|
401
|
+
const result = engine.alternatives('Header');
|
|
402
|
+
expect(result.some(a => a.component === 'Dialog')).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('returns empty for component with no alternatives', () => {
|
|
406
|
+
const engine = createEngine();
|
|
407
|
+
expect(engine.alternatives('Input')).toEqual([]);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe('blocksUsing()', () => {
|
|
412
|
+
it('returns blocks that use a component', () => {
|
|
413
|
+
const engine = createEngine();
|
|
414
|
+
expect(engine.blocksUsing('Button')).toContain('LoginForm');
|
|
415
|
+
expect(engine.blocksUsing('Button')).toContain('SearchBar');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('returns empty for component not in any block', () => {
|
|
419
|
+
// In our test blocks, all 6 components are used
|
|
420
|
+
// Create a fresh graph with no blocks
|
|
421
|
+
const engine = new ComponentGraphEngine(createTestGraph());
|
|
422
|
+
expect(engine.blocksUsing('Button')).toEqual([]);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('subgraph()', () => {
|
|
427
|
+
it('extracts induced subgraph', () => {
|
|
428
|
+
const engine = createEngine();
|
|
429
|
+
const sub = engine.subgraph(['Button', 'Header', 'AppShell']);
|
|
430
|
+
expect(sub.nodes.length).toBe(3);
|
|
431
|
+
// Only edges between these 3 components
|
|
432
|
+
for (const edge of sub.edges) {
|
|
433
|
+
expect(['Button', 'Header', 'AppShell']).toContain(edge.source);
|
|
434
|
+
expect(['Button', 'Header', 'AppShell']).toContain(edge.target);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('recomputes health for subgraph', () => {
|
|
439
|
+
const engine = createEngine();
|
|
440
|
+
const sub = engine.subgraph(['Button', 'Header']);
|
|
441
|
+
expect(sub.health.nodeCount).toBe(2);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('handles empty component list', () => {
|
|
445
|
+
const engine = createEngine();
|
|
446
|
+
const sub = engine.subgraph([]);
|
|
447
|
+
expect(sub.nodes).toEqual([]);
|
|
448
|
+
expect(sub.edges).toEqual([]);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe('getHealth()', () => {
|
|
453
|
+
it('returns health metrics', () => {
|
|
454
|
+
const engine = createEngine();
|
|
455
|
+
const health = engine.getHealth();
|
|
456
|
+
expect(health.nodeCount).toBe(6);
|
|
457
|
+
expect(health.edgeCount).toBe(9);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('identifies hubs', () => {
|
|
461
|
+
const engine = createEngine();
|
|
462
|
+
const health = engine.getHealth();
|
|
463
|
+
// Button, Header, Sidebar should be high-degree
|
|
464
|
+
expect(health.hubs.length).toBeGreaterThan(0);
|
|
465
|
+
expect(health.hubs[0].degree).toBeGreaterThan(0);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('computes composition coverage', () => {
|
|
469
|
+
const engine = createEngine();
|
|
470
|
+
const health = engine.getHealth();
|
|
471
|
+
// 5 out of 6 components are in blocks (all except... let's check)
|
|
472
|
+
// DashboardLayout: AppShell, Header, Sidebar; LoginForm: Dialog, Input, Button; SearchBar: Input, Button
|
|
473
|
+
// All 6 components are in at least one block
|
|
474
|
+
expect(health.compositionCoverage).toBe(100);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('computes average degree', () => {
|
|
478
|
+
const engine = createEngine();
|
|
479
|
+
const health = engine.getHealth();
|
|
480
|
+
// 9 edges * 2 / 6 nodes = 3.0
|
|
481
|
+
expect(health.averageDegree).toBe(3);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// Health computation
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
describe('computeHealthFromData()', () => {
|
|
491
|
+
it('identifies orphan nodes', () => {
|
|
492
|
+
const nodes: ComponentNode[] = [
|
|
493
|
+
{ name: 'A', category: 'test', status: 'stable' },
|
|
494
|
+
{ name: 'B', category: 'test', status: 'stable' },
|
|
495
|
+
{ name: 'Orphan', category: 'test', status: 'stable' },
|
|
496
|
+
];
|
|
497
|
+
const edges: GraphEdge[] = [
|
|
498
|
+
{ source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test' },
|
|
499
|
+
];
|
|
500
|
+
const health = computeHealthFromData(nodes, edges);
|
|
501
|
+
expect(health.orphans).toContain('Orphan');
|
|
502
|
+
expect(health.orphans).not.toContain('A');
|
|
503
|
+
expect(health.orphans).not.toContain('B');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('handles empty graph', () => {
|
|
507
|
+
const health = computeHealthFromData([], []);
|
|
508
|
+
expect(health.nodeCount).toBe(0);
|
|
509
|
+
expect(health.edgeCount).toBe(0);
|
|
510
|
+
expect(health.orphans).toEqual([]);
|
|
511
|
+
expect(health.hubs).toEqual([]);
|
|
512
|
+
expect(health.averageDegree).toBe(0);
|
|
513
|
+
expect(health.compositionCoverage).toBe(0);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('computes connected components correctly', () => {
|
|
517
|
+
const nodes: ComponentNode[] = [
|
|
518
|
+
{ name: 'A', category: 'test', status: 'stable' },
|
|
519
|
+
{ name: 'B', category: 'test', status: 'stable' },
|
|
520
|
+
{ name: 'C', category: 'test', status: 'stable' },
|
|
521
|
+
{ name: 'D', category: 'test', status: 'stable' },
|
|
522
|
+
];
|
|
523
|
+
const edges: GraphEdge[] = [
|
|
524
|
+
{ source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test' },
|
|
525
|
+
{ source: 'C', target: 'D', type: 'imports', weight: 1.0, provenance: 'test' },
|
|
526
|
+
];
|
|
527
|
+
const health = computeHealthFromData(nodes, edges);
|
|
528
|
+
expect(health.connectedComponents.length).toBe(2);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('limits hubs to top 10', () => {
|
|
532
|
+
const nodes: ComponentNode[] = Array.from({ length: 15 }, (_, i) => ({
|
|
533
|
+
name: `C${i}`,
|
|
534
|
+
category: 'test',
|
|
535
|
+
status: 'stable',
|
|
536
|
+
}));
|
|
537
|
+
const edges: GraphEdge[] = Array.from({ length: 14 }, (_, i) => ({
|
|
538
|
+
source: 'C0',
|
|
539
|
+
target: `C${i + 1}`,
|
|
540
|
+
type: 'imports' as GraphEdgeType,
|
|
541
|
+
weight: 1.0,
|
|
542
|
+
provenance: 'test',
|
|
543
|
+
}));
|
|
544
|
+
const health = computeHealthFromData(nodes, edges);
|
|
545
|
+
expect(health.hubs.length).toBe(10);
|
|
546
|
+
expect(health.hubs[0].name).toBe('C0');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('computes composition coverage with block index', () => {
|
|
550
|
+
const nodes: ComponentNode[] = [
|
|
551
|
+
{ name: 'A', category: 'test', status: 'stable' },
|
|
552
|
+
{ name: 'B', category: 'test', status: 'stable' },
|
|
553
|
+
{ name: 'C', category: 'test', status: 'stable' },
|
|
554
|
+
{ name: 'D', category: 'test', status: 'stable' },
|
|
555
|
+
];
|
|
556
|
+
const blockIndex = new Map<string, string[]>();
|
|
557
|
+
blockIndex.set('A', ['Block1']);
|
|
558
|
+
blockIndex.set('B', ['Block1']);
|
|
559
|
+
// C and D not in any block
|
|
560
|
+
const health = computeHealthFromData(nodes, [], blockIndex);
|
|
561
|
+
expect(health.compositionCoverage).toBe(50);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// Serialization round-trip
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
describe('serialization', () => {
|
|
570
|
+
it('round-trips through serialize → deserialize', () => {
|
|
571
|
+
const graph = createTestGraph();
|
|
572
|
+
const serialized = serializeGraph(graph);
|
|
573
|
+
const deserialized = deserializeGraph(serialized);
|
|
574
|
+
|
|
575
|
+
expect(deserialized.nodes).toEqual(graph.nodes);
|
|
576
|
+
expect(deserialized.edges.length).toBe(graph.edges.length);
|
|
577
|
+
expect(deserialized.health).toEqual(graph.health);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('preserves edge data', () => {
|
|
581
|
+
const graph = createTestGraph();
|
|
582
|
+
const serialized = serializeGraph(graph);
|
|
583
|
+
const deserialized = deserializeGraph(serialized);
|
|
584
|
+
|
|
585
|
+
for (let i = 0; i < graph.edges.length; i++) {
|
|
586
|
+
expect(deserialized.edges[i].source).toBe(graph.edges[i].source);
|
|
587
|
+
expect(deserialized.edges[i].target).toBe(graph.edges[i].target);
|
|
588
|
+
expect(deserialized.edges[i].type).toBe(graph.edges[i].type);
|
|
589
|
+
expect(deserialized.edges[i].weight).toBe(graph.edges[i].weight);
|
|
590
|
+
expect(deserialized.edges[i].provenance).toBe(graph.edges[i].provenance);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('preserves optional note field', () => {
|
|
595
|
+
const graph = createTestGraph();
|
|
596
|
+
const edgeWithNote = graph.edges.find(e => e.note);
|
|
597
|
+
expect(edgeWithNote).toBeDefined();
|
|
598
|
+
|
|
599
|
+
const serialized = serializeGraph(graph);
|
|
600
|
+
const deserialized = deserializeGraph(serialized);
|
|
601
|
+
const matchingEdge = deserialized.edges.find(
|
|
602
|
+
e => e.source === edgeWithNote!.source &&
|
|
603
|
+
e.target === edgeWithNote!.target &&
|
|
604
|
+
e.type === edgeWithNote!.type
|
|
605
|
+
);
|
|
606
|
+
expect(matchingEdge?.note).toBe(edgeWithNote!.note);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('uses compact keys in serialized format', () => {
|
|
610
|
+
const graph = createTestGraph();
|
|
611
|
+
const serialized = serializeGraph(graph);
|
|
612
|
+
const edge = serialized.edges[0];
|
|
613
|
+
expect('s' in edge).toBe(true);
|
|
614
|
+
expect('t' in edge).toBe(true);
|
|
615
|
+
expect('ty' in edge).toBe(true);
|
|
616
|
+
expect('w' in edge).toBe(true);
|
|
617
|
+
expect('p' in edge).toBe(true);
|
|
618
|
+
// Full keys should not be present
|
|
619
|
+
expect('source' in edge).toBe(false);
|
|
620
|
+
expect('target' in edge).toBe(false);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('omits note from serialized edge when undefined', () => {
|
|
624
|
+
const edge: GraphEdge = {
|
|
625
|
+
source: 'A',
|
|
626
|
+
target: 'B',
|
|
627
|
+
type: 'imports',
|
|
628
|
+
weight: 1.0,
|
|
629
|
+
provenance: 'test',
|
|
630
|
+
};
|
|
631
|
+
const graph: ComponentGraph = {
|
|
632
|
+
nodes: [
|
|
633
|
+
{ name: 'A', category: 'test', status: 'stable' },
|
|
634
|
+
{ name: 'B', category: 'test', status: 'stable' },
|
|
635
|
+
],
|
|
636
|
+
edges: [edge],
|
|
637
|
+
health: computeHealthFromData(
|
|
638
|
+
[{ name: 'A', category: 'test', status: 'stable' }, { name: 'B', category: 'test', status: 'stable' }],
|
|
639
|
+
[edge]
|
|
640
|
+
),
|
|
641
|
+
};
|
|
642
|
+
const serialized = serializeGraph(graph);
|
|
643
|
+
expect(serialized.edges[0].no).toBeUndefined();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('produces valid JSON', () => {
|
|
647
|
+
const graph = createTestGraph();
|
|
648
|
+
const serialized = serializeGraph(graph);
|
|
649
|
+
const json = JSON.stringify(serialized);
|
|
650
|
+
const parsed = JSON.parse(json);
|
|
651
|
+
expect(parsed.nodes.length).toBe(6);
|
|
652
|
+
expect(parsed.edges.length).toBe(9);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// Edge cases
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
describe('edge cases', () => {
|
|
661
|
+
it('engine handles graph with no edges', () => {
|
|
662
|
+
const nodes: ComponentNode[] = [
|
|
663
|
+
{ name: 'Lonely', category: 'test', status: 'stable' },
|
|
664
|
+
];
|
|
665
|
+
const graph: ComponentGraph = {
|
|
666
|
+
nodes,
|
|
667
|
+
edges: [],
|
|
668
|
+
health: computeHealthFromData(nodes, []),
|
|
669
|
+
};
|
|
670
|
+
const engine = new ComponentGraphEngine(graph);
|
|
671
|
+
expect(engine.dependencies('Lonely')).toEqual([]);
|
|
672
|
+
expect(engine.dependents('Lonely')).toEqual([]);
|
|
673
|
+
expect(engine.impact('Lonely').totalAffected).toBe(0);
|
|
674
|
+
expect(engine.neighbors('Lonely').neighbors).toEqual([]);
|
|
675
|
+
expect(engine.islands()).toEqual([['Lonely']]);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('engine handles single-node graph', () => {
|
|
679
|
+
const nodes: ComponentNode[] = [
|
|
680
|
+
{ name: 'Solo', category: 'test', status: 'stable' },
|
|
681
|
+
];
|
|
682
|
+
const graph: ComponentGraph = {
|
|
683
|
+
nodes,
|
|
684
|
+
edges: [],
|
|
685
|
+
health: computeHealthFromData(nodes, []),
|
|
686
|
+
};
|
|
687
|
+
const engine = new ComponentGraphEngine(graph);
|
|
688
|
+
expect(engine.getHealth().orphans).toEqual(['Solo']);
|
|
689
|
+
expect(engine.getHealth().averageDegree).toBe(0);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('engine handles self-referencing edge gracefully', () => {
|
|
693
|
+
const nodes: ComponentNode[] = [
|
|
694
|
+
{ name: 'Self', category: 'test', status: 'stable' },
|
|
695
|
+
];
|
|
696
|
+
const edges: GraphEdge[] = [
|
|
697
|
+
{ source: 'Self', target: 'Self', type: 'renders', weight: 0.5, provenance: 'test' },
|
|
698
|
+
];
|
|
699
|
+
const graph: ComponentGraph = {
|
|
700
|
+
nodes,
|
|
701
|
+
edges,
|
|
702
|
+
health: computeHealthFromData(nodes, edges),
|
|
703
|
+
};
|
|
704
|
+
const engine = new ComponentGraphEngine(graph);
|
|
705
|
+
// Should not infinite-loop on impact
|
|
706
|
+
const result = engine.impact('Self');
|
|
707
|
+
expect(result.totalAffected).toBe(0); // Can't affect self
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('engine handles multiple edge types between same components', () => {
|
|
711
|
+
const nodes: ComponentNode[] = [
|
|
712
|
+
{ name: 'A', category: 'test', status: 'stable' },
|
|
713
|
+
{ name: 'B', category: 'test', status: 'stable' },
|
|
714
|
+
];
|
|
715
|
+
const edges: GraphEdge[] = [
|
|
716
|
+
{ source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test' },
|
|
717
|
+
{ source: 'A', target: 'B', type: 'renders', weight: 0.5, provenance: 'test' },
|
|
718
|
+
{ source: 'A', target: 'B', type: 'parent-of', weight: 1.0, provenance: 'test' },
|
|
719
|
+
];
|
|
720
|
+
const graph: ComponentGraph = {
|
|
721
|
+
nodes,
|
|
722
|
+
edges,
|
|
723
|
+
health: computeHealthFromData(nodes, edges),
|
|
724
|
+
};
|
|
725
|
+
const engine = new ComponentGraphEngine(graph);
|
|
726
|
+
expect(engine.dependencies('A').length).toBe(3);
|
|
727
|
+
expect(engine.dependencies('A', ['imports']).length).toBe(1);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('composition with no parent returns undefined parent', () => {
|
|
731
|
+
const engine = createEngine();
|
|
732
|
+
const result = engine.composition('AppShell');
|
|
733
|
+
expect(result.parent).toBeUndefined();
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('handles hasNode for all test components', () => {
|
|
737
|
+
const engine = createEngine();
|
|
738
|
+
expect(engine.hasNode('Button')).toBe(true);
|
|
739
|
+
expect(engine.hasNode('Dialog')).toBe(true);
|
|
740
|
+
expect(engine.hasNode('Header')).toBe(true);
|
|
741
|
+
expect(engine.hasNode('Sidebar')).toBe(true);
|
|
742
|
+
expect(engine.hasNode('AppShell')).toBe(true);
|
|
743
|
+
expect(engine.hasNode('Input')).toBe(true);
|
|
744
|
+
expect(engine.hasNode('Tooltip')).toBe(false);
|
|
745
|
+
});
|
|
746
|
+
});
|