382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import {
|
|
extractConcepts,
|
|
findRelationships,
|
|
buildDependencyGraph,
|
|
toDotFormat,
|
|
toJsonFormat,
|
|
toCytoscapeFormat,
|
|
generateGraphVisualization,
|
|
} from '../src/graph-view';
|
|
import type { VaultIndexEntry } from '../src/types';
|
|
import type { DependencyGraph } from '../src/graph-view';
|
|
|
|
describe('Graph Utilities', () => {
|
|
const mockFiles: VaultIndexEntry[] = [
|
|
{
|
|
path: 'algorithms.md',
|
|
title: 'Algorithms',
|
|
content:
|
|
'# Sorting Algorithms\n\n## Bubble Sort\nThe **bubble sort** is a simple sorting algorithm.\n\n## Quick Sort\n*Quick sort* is more efficient.',
|
|
score: 1,
|
|
},
|
|
{
|
|
path: 'data-structures.md',
|
|
title: 'Data Structures',
|
|
content:
|
|
'# Data Structures\n\n## Trees\nBinary trees are fundamental.\n\n## Graphs\nGraph algorithms build on *Sorting Algorithms*.',
|
|
score: 2,
|
|
},
|
|
{
|
|
path: 'patterns.md',
|
|
title: 'Design Patterns',
|
|
content:
|
|
'# Design Patterns\n\n## Factory Pattern\nCreates objects.\n\n## Observer Pattern\n**bubble sort** mentions the Factory Pattern.',
|
|
score: 3,
|
|
},
|
|
];
|
|
|
|
describe('extractConcepts', () => {
|
|
it('should extract headings as concepts', () => {
|
|
const content = '# Main Heading\n## Sub Heading\n### Deep Heading';
|
|
const concepts = extractConcepts(content, 'test.md');
|
|
expect(concepts).toContain('Main Heading');
|
|
expect(concepts).toContain('Sub Heading');
|
|
expect(concepts).toContain('Deep Heading');
|
|
});
|
|
|
|
it('should extract bold text as concepts', () => {
|
|
const content = 'This is **important concept** and **another concept**.';
|
|
const concepts = extractConcepts(content, 'test.md');
|
|
expect(concepts).toContain('important concept');
|
|
expect(concepts).toContain('another concept');
|
|
});
|
|
|
|
it('should not duplicate concepts', () => {
|
|
const content = '# Heading\n\n# Heading\n\n**Heading**';
|
|
const concepts = extractConcepts(content, 'test.md');
|
|
expect(concepts).toEqual(['Heading']);
|
|
});
|
|
|
|
it('should return empty array for content without concepts', () => {
|
|
const content = 'Plain text without any special formatting.';
|
|
const concepts = extractConcepts(content, 'test.md');
|
|
expect(concepts).toEqual([]);
|
|
});
|
|
|
|
it('should handle empty content', () => {
|
|
const concepts = extractConcepts('', 'test.md');
|
|
expect(concepts).toEqual([]);
|
|
});
|
|
|
|
it('should extract all heading levels', () => {
|
|
const content = '# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6';
|
|
const concepts = extractConcepts(content, 'test.md');
|
|
expect(concepts).toEqual(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
|
|
});
|
|
});
|
|
|
|
describe('findRelationships', () => {
|
|
it('should find relationships between files sharing concepts', () => {
|
|
const conceptIndex: Record<string, string[]> = {
|
|
'Sorting Algorithms': ['algorithms.md'],
|
|
'bubble sort': ['algorithms.md', 'patterns.md'],
|
|
};
|
|
|
|
const relationships = findRelationships(mockFiles, conceptIndex);
|
|
// Algorithms.md references patterns.md (via "bubble sort")
|
|
// Data-structures.md references algorithms.md (via "Sorting Algorithms")
|
|
// Patterns.md references algorithms.md (via "bubble sort")
|
|
expect(relationships).toHaveLength(3);
|
|
expect(
|
|
relationships.some((r) => r.source === 'patterns.md' && r.target === 'algorithms.md')
|
|
).toBe(true);
|
|
expect(
|
|
relationships.some((r) => r.source === 'data-structures.md' && r.target === 'algorithms.md')
|
|
).toBe(true);
|
|
expect(
|
|
relationships.some((r) => r.source === 'algorithms.md' && r.target === 'patterns.md')
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should exclude self-references', () => {
|
|
const conceptIndex: Record<string, string[]> = {
|
|
'bubble sort': ['algorithms.md'],
|
|
};
|
|
|
|
const relationships = findRelationships(mockFiles, conceptIndex);
|
|
const selfRefs = relationships.filter((r) => r.source === r.target);
|
|
expect(selfRefs).toHaveLength(0);
|
|
});
|
|
|
|
it('should return empty array for empty files', () => {
|
|
const relationships = findRelationships([], {});
|
|
expect(relationships).toEqual([]);
|
|
});
|
|
|
|
it('should return empty array when no concepts overlap', () => {
|
|
const conceptIndex: Record<string, string[]> = {
|
|
'Unique Concept A': ['file1.md'],
|
|
'Unique Concept B': ['file2.md'],
|
|
};
|
|
|
|
const isolatedFiles: VaultIndexEntry[] = [
|
|
{ path: 'file1.md', title: 'File 1', content: '# Unique Concept A', score: 1 },
|
|
{ path: 'file2.md', title: 'File 2', content: '# Unique Concept B', score: 2 },
|
|
];
|
|
|
|
const relationships = findRelationships(isolatedFiles, conceptIndex);
|
|
expect(relationships).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('buildDependencyGraph', () => {
|
|
it('should create nodes for each file', () => {
|
|
const graph = buildDependencyGraph(mockFiles);
|
|
expect(graph.nodes).toHaveLength(3);
|
|
expect(graph.nodes.map((n) => n.id)).toEqual([
|
|
'algorithms.md',
|
|
'data-structures.md',
|
|
'patterns.md',
|
|
]);
|
|
});
|
|
|
|
it('should set node type to file', () => {
|
|
const graph = buildDependencyGraph(mockFiles);
|
|
graph.nodes.forEach((node) => {
|
|
expect(node.type).toBe('file');
|
|
});
|
|
});
|
|
|
|
it('should include file properties', () => {
|
|
const graph = buildDependencyGraph(mockFiles);
|
|
const node = graph.nodes[0];
|
|
expect(node.properties).toEqual({
|
|
path: 'algorithms.md',
|
|
title: 'Algorithms',
|
|
contentPreview: expect.stringContaining('Sorting Algorithms'),
|
|
});
|
|
});
|
|
|
|
it('should create edges for concept relationships', () => {
|
|
const filesWithRelationship: VaultIndexEntry[] = [
|
|
{
|
|
path: 'file1.md',
|
|
title: 'File 1',
|
|
content: '# Shared Concept\n\nSome content about **Shared Concept**.',
|
|
score: 1,
|
|
},
|
|
{
|
|
path: 'file2.md',
|
|
title: 'File 2',
|
|
content: '# Shared Concept\n\nMore content.',
|
|
score: 2,
|
|
},
|
|
];
|
|
|
|
const graph = buildDependencyGraph(filesWithRelationship);
|
|
expect(graph.edges).toHaveLength(2); // Each mentions the other's concept
|
|
});
|
|
|
|
it('should handle empty file list', () => {
|
|
const graph = buildDependencyGraph([]);
|
|
expect(graph.nodes).toEqual([]);
|
|
expect(graph.edges).toEqual([]);
|
|
});
|
|
|
|
it('should truncate content preview to 100 chars', () => {
|
|
const longFile: VaultIndexEntry = {
|
|
path: 'long.md',
|
|
title: 'Long',
|
|
content: 'A'.repeat(200),
|
|
score: 1,
|
|
};
|
|
const graph = buildDependencyGraph([longFile]);
|
|
expect(graph.nodes[0].properties.contentPreview).toHaveLength(101); // 100 + '.'
|
|
});
|
|
});
|
|
|
|
describe('toDotFormat', () => {
|
|
it('should generate valid DOT syntax', () => {
|
|
const graph: DependencyGraph = {
|
|
nodes: [
|
|
{
|
|
id: 'node1',
|
|
label: 'Node 1',
|
|
file: { path: 'file1.md', title: 'File 1', content: 'Content', score: 1 },
|
|
type: 'file',
|
|
properties: {},
|
|
},
|
|
{
|
|
id: 'node2',
|
|
label: 'Node 2',
|
|
file: { path: 'file2.md', title: 'File 2', content: 'Content', score: 2 },
|
|
type: 'file',
|
|
properties: {},
|
|
},
|
|
],
|
|
edges: [
|
|
{
|
|
id: 'edge1',
|
|
source: 'node1',
|
|
target: 'node2',
|
|
label: 'references',
|
|
relationship: 'references',
|
|
properties: {},
|
|
},
|
|
],
|
|
};
|
|
|
|
const dot = toDotFormat(graph);
|
|
expect(dot).toContain('digraph G {');
|
|
expect(dot).toContain('"node1" [label="Node 1"]');
|
|
expect(dot).toContain('"node2" [label="Node 2"]');
|
|
expect(dot).toContain('"node1" -> "node2" [label="references"]');
|
|
expect(dot).toContain('}');
|
|
});
|
|
|
|
it('should handle empty graph', () => {
|
|
const dot = toDotFormat({ nodes: [], edges: [] });
|
|
expect(dot).toContain('digraph G {');
|
|
expect(dot).toContain('}');
|
|
});
|
|
|
|
it('should escape quotes in labels', () => {
|
|
const graph: DependencyGraph = {
|
|
nodes: [
|
|
{
|
|
id: 'node1',
|
|
label: 'Node with "quotes"',
|
|
file: { path: 'file1.md', title: 'File 1', content: 'C', score: 1 },
|
|
type: 'file',
|
|
properties: {},
|
|
},
|
|
],
|
|
edges: [],
|
|
};
|
|
|
|
const dot = toDotFormat(graph);
|
|
expect(dot).toContain('label="Node with \\"quotes\\""');
|
|
});
|
|
});
|
|
|
|
describe('toJsonFormat', () => {
|
|
it('should produce valid JSON', () => {
|
|
const graph: DependencyGraph = {
|
|
nodes: [
|
|
{
|
|
id: 'n1',
|
|
label: 'Node',
|
|
file: { path: 'f.md', title: 'T', content: 'C', score: 1 },
|
|
type: 'file',
|
|
properties: {},
|
|
},
|
|
],
|
|
edges: [],
|
|
};
|
|
|
|
const json = toJsonFormat(graph);
|
|
const parsed = JSON.parse(json);
|
|
expect(parsed.nodes).toHaveLength(1);
|
|
expect(parsed.edges).toEqual([]);
|
|
});
|
|
|
|
it('should include all node and edge data', () => {
|
|
const graph: DependencyGraph = {
|
|
nodes: [],
|
|
edges: [
|
|
{
|
|
id: 'e1',
|
|
source: 's',
|
|
target: 't',
|
|
label: 'rel',
|
|
relationship: 'rel',
|
|
properties: {},
|
|
},
|
|
],
|
|
};
|
|
|
|
const json = toJsonFormat(graph);
|
|
const parsed = JSON.parse(json);
|
|
expect(parsed.edges[0].source).toBe('s');
|
|
expect(parsed.edges[0].target).toBe('t');
|
|
});
|
|
});
|
|
|
|
describe('toCytoscapeFormat', () => {
|
|
it('should produce valid Cytoscape JSON structure', () => {
|
|
const graph: DependencyGraph = {
|
|
nodes: [
|
|
{
|
|
id: 'n1',
|
|
label: 'Node',
|
|
file: { path: 'f.md', title: 'T', content: 'C', score: 1 },
|
|
type: 'file',
|
|
properties: { extra: true },
|
|
},
|
|
],
|
|
edges: [],
|
|
};
|
|
|
|
const json = toCytoscapeFormat(graph);
|
|
const parsed = JSON.parse(json);
|
|
expect(parsed.elements.nodes).toHaveLength(1);
|
|
expect(parsed.elements.nodes[0].data.id).toBe('n1');
|
|
expect(parsed.elements.nodes[0].data.type).toBe('file');
|
|
expect(parsed.elements.nodes[0].data.extra).toBe(true);
|
|
});
|
|
|
|
it('should include edge data in Cytoscape format', () => {
|
|
const graph: DependencyGraph = {
|
|
nodes: [],
|
|
edges: [
|
|
{
|
|
id: 'e1',
|
|
source: 's',
|
|
target: 't',
|
|
label: 'rel',
|
|
relationship: 'rel',
|
|
properties: {},
|
|
},
|
|
],
|
|
};
|
|
|
|
const json = toCytoscapeFormat(graph);
|
|
const parsed = JSON.parse(json);
|
|
expect(parsed.elements.edges[0].data.source).toBe('s');
|
|
expect(parsed.elements.edges[0].data.target).toBe('t');
|
|
expect(parsed.elements.edges[0].data.relationship).toBe('rel');
|
|
});
|
|
});
|
|
|
|
describe('generateGraphVisualization', () => {
|
|
it('should generate DOT format by default', () => {
|
|
const result = generateGraphVisualization(mockFiles);
|
|
expect(result).toContain('digraph G {');
|
|
});
|
|
|
|
it('should generate DOT format when specified', () => {
|
|
const result = generateGraphVisualization(mockFiles, 'dot');
|
|
expect(result).toContain('digraph G {');
|
|
});
|
|
|
|
it('should generate JSON format when specified', () => {
|
|
const result = generateGraphVisualization(mockFiles, 'json');
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed.nodes).toBeDefined();
|
|
expect(parsed.edges).toBeDefined();
|
|
});
|
|
|
|
it('should generate Cytoscape format when specified', () => {
|
|
const result = generateGraphVisualization(mockFiles, 'cytoscape');
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed.elements).toBeDefined();
|
|
expect(parsed.elements.nodes).toBeDefined();
|
|
});
|
|
|
|
it('should fall back to DOT for unknown formats', () => {
|
|
const result = generateGraphVisualization(mockFiles, 'dot' as any);
|
|
expect(result).toContain('digraph G {');
|
|
});
|
|
});
|
|
});
|