Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
488
npm/packages/ruvector-extensions/tests/exporters.test.ts
Normal file
488
npm/packages/ruvector-extensions/tests/exporters.test.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* Tests for Graph Export Module
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import {
|
||||
buildGraphFromEntries,
|
||||
exportToGraphML,
|
||||
exportToGEXF,
|
||||
exportToNeo4j,
|
||||
exportToD3,
|
||||
exportToNetworkX,
|
||||
exportGraph,
|
||||
validateGraph,
|
||||
type VectorEntry,
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
type GraphEdge
|
||||
} from '../src/exporters.js';
|
||||
|
||||
// Sample test data
|
||||
const sampleEntries: VectorEntry[] = [
|
||||
{
|
||||
id: 'vec1',
|
||||
vector: [1.0, 0.0, 0.0],
|
||||
metadata: { label: 'Vector 1', category: 'A' }
|
||||
},
|
||||
{
|
||||
id: 'vec2',
|
||||
vector: [0.9, 0.1, 0.0],
|
||||
metadata: { label: 'Vector 2', category: 'A' }
|
||||
},
|
||||
{
|
||||
id: 'vec3',
|
||||
vector: [0.0, 1.0, 0.0],
|
||||
metadata: { label: 'Vector 3', category: 'B' }
|
||||
}
|
||||
];
|
||||
|
||||
const sampleGraph: Graph = {
|
||||
nodes: [
|
||||
{ id: 'n1', label: 'Node 1', attributes: { type: 'test' } },
|
||||
{ id: 'n2', label: 'Node 2', attributes: { type: 'test' } }
|
||||
],
|
||||
edges: [
|
||||
{ source: 'n1', target: 'n2', weight: 0.95, type: 'similar' }
|
||||
]
|
||||
};
|
||||
|
||||
describe('Graph Building', () => {
|
||||
it('should build graph from vector entries', () => {
|
||||
const graph = buildGraphFromEntries(sampleEntries, {
|
||||
maxNeighbors: 2,
|
||||
threshold: 0.5
|
||||
});
|
||||
|
||||
assert.strictEqual(graph.nodes.length, 3, 'Should have 3 nodes');
|
||||
assert.ok(graph.edges.length > 0, 'Should have edges');
|
||||
assert.ok(graph.metadata, 'Should have metadata');
|
||||
});
|
||||
|
||||
it('should respect threshold parameter', () => {
|
||||
const highThreshold = buildGraphFromEntries(sampleEntries, {
|
||||
threshold: 0.95
|
||||
});
|
||||
|
||||
const lowThreshold = buildGraphFromEntries(sampleEntries, {
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
highThreshold.edges.length <= lowThreshold.edges.length,
|
||||
'Higher threshold should result in fewer edges'
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect maxNeighbors parameter', () => {
|
||||
const graph = buildGraphFromEntries(sampleEntries, {
|
||||
maxNeighbors: 1,
|
||||
threshold: 0.0
|
||||
});
|
||||
|
||||
// Each node should have at most 1 outgoing edge
|
||||
const outgoingEdges = new Map<string, number>();
|
||||
for (const edge of graph.edges) {
|
||||
outgoingEdges.set(edge.source, (outgoingEdges.get(edge.source) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const count of outgoingEdges.values()) {
|
||||
assert.ok(count <= 1, 'Should respect maxNeighbors limit');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include metadata when requested', () => {
|
||||
const graph = buildGraphFromEntries(sampleEntries, {
|
||||
includeMetadata: true
|
||||
});
|
||||
|
||||
const nodeWithMetadata = graph.nodes.find(n => n.attributes);
|
||||
assert.ok(nodeWithMetadata, 'Should include metadata in nodes');
|
||||
assert.ok(nodeWithMetadata!.attributes!.category, 'Should preserve metadata fields');
|
||||
});
|
||||
|
||||
it('should include vectors when requested', () => {
|
||||
const graph = buildGraphFromEntries(sampleEntries, {
|
||||
includeVectors: true
|
||||
});
|
||||
|
||||
const nodeWithVector = graph.nodes.find(n => n.vector);
|
||||
assert.ok(nodeWithVector, 'Should include vectors in nodes');
|
||||
assert.ok(Array.isArray(nodeWithVector!.vector), 'Vector should be an array');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GraphML Export', () => {
|
||||
it('should export valid GraphML XML', () => {
|
||||
const graphML = exportToGraphML(sampleGraph);
|
||||
|
||||
assert.ok(graphML.includes('<?xml'), 'Should have XML declaration');
|
||||
assert.ok(graphML.includes('<graphml'), 'Should have graphml root element');
|
||||
assert.ok(graphML.includes('<node'), 'Should have node elements');
|
||||
assert.ok(graphML.includes('<edge'), 'Should have edge elements');
|
||||
assert.ok(graphML.includes('</graphml>'), 'Should close graphml element');
|
||||
});
|
||||
|
||||
it('should include node labels', () => {
|
||||
const graphML = exportToGraphML(sampleGraph);
|
||||
assert.ok(graphML.includes('Node 1'), 'Should include node labels');
|
||||
assert.ok(graphML.includes('Node 2'), 'Should include node labels');
|
||||
});
|
||||
|
||||
it('should include edge weights', () => {
|
||||
const graphML = exportToGraphML(sampleGraph);
|
||||
assert.ok(graphML.includes('0.95'), 'Should include edge weight');
|
||||
});
|
||||
|
||||
it('should include node attributes', () => {
|
||||
const graphML = exportToGraphML(sampleGraph, { includeMetadata: true });
|
||||
assert.ok(graphML.includes('type'), 'Should include attribute keys');
|
||||
assert.ok(graphML.includes('test'), 'Should include attribute values');
|
||||
});
|
||||
|
||||
it('should escape XML special characters', () => {
|
||||
const graph: Graph = {
|
||||
nodes: [
|
||||
{ id: 'n1', label: 'Test <>&"\'' },
|
||||
{ id: 'n2', label: 'Normal' }
|
||||
],
|
||||
edges: [
|
||||
{ source: 'n1', target: 'n2', weight: 1.0 }
|
||||
]
|
||||
};
|
||||
|
||||
const graphML = exportToGraphML(graph);
|
||||
assert.ok(graphML.includes('<'), 'Should escape < character');
|
||||
assert.ok(graphML.includes('>'), 'Should escape > character');
|
||||
assert.ok(graphML.includes('&'), 'Should escape & character');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GEXF Export', () => {
|
||||
it('should export valid GEXF XML', () => {
|
||||
const gexf = exportToGEXF(sampleGraph);
|
||||
|
||||
assert.ok(gexf.includes('<?xml'), 'Should have XML declaration');
|
||||
assert.ok(gexf.includes('<gexf'), 'Should have gexf root element');
|
||||
assert.ok(gexf.includes('<nodes>'), 'Should have nodes section');
|
||||
assert.ok(gexf.includes('<edges>'), 'Should have edges section');
|
||||
assert.ok(gexf.includes('</gexf>'), 'Should close gexf element');
|
||||
});
|
||||
|
||||
it('should include metadata', () => {
|
||||
const gexf = exportToGEXF(sampleGraph, {
|
||||
graphName: 'Test Graph',
|
||||
graphDescription: 'A test graph'
|
||||
});
|
||||
|
||||
assert.ok(gexf.includes('<meta'), 'Should have meta section');
|
||||
assert.ok(gexf.includes('A test graph'), 'Should include description');
|
||||
});
|
||||
|
||||
it('should define attributes', () => {
|
||||
const gexf = exportToGEXF(sampleGraph);
|
||||
assert.ok(gexf.includes('<attributes'), 'Should define attributes');
|
||||
assert.ok(gexf.includes('weight'), 'Should define weight attribute');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Neo4j Export', () => {
|
||||
it('should export valid Cypher queries', () => {
|
||||
const cypher = exportToNeo4j(sampleGraph);
|
||||
|
||||
assert.ok(cypher.includes('CREATE (:Vector'), 'Should have CREATE statements');
|
||||
assert.ok(cypher.includes('MATCH'), 'Should have MATCH statements for edges');
|
||||
assert.ok(cypher.includes('CREATE CONSTRAINT'), 'Should create constraints');
|
||||
});
|
||||
|
||||
it('should create nodes with properties', () => {
|
||||
const cypher = exportToNeo4j(sampleGraph, { includeMetadata: true });
|
||||
|
||||
assert.ok(cypher.includes('id: "n1"'), 'Should include node ID');
|
||||
assert.ok(cypher.includes('label: "Node 1"'), 'Should include node label');
|
||||
assert.ok(cypher.includes('type: "test"'), 'Should include node attributes');
|
||||
});
|
||||
|
||||
it('should create relationships with weights', () => {
|
||||
const cypher = exportToNeo4j(sampleGraph);
|
||||
|
||||
assert.ok(cypher.includes('weight: 0.95'), 'Should include edge weight');
|
||||
assert.ok(cypher.includes('[:'), 'Should create relationships');
|
||||
});
|
||||
|
||||
it('should escape special characters in Cypher', () => {
|
||||
const graph: Graph = {
|
||||
nodes: [
|
||||
{ id: 'n1', label: 'Test "quoted"' },
|
||||
{ id: 'n2', label: 'Normal' }
|
||||
],
|
||||
edges: [
|
||||
{ source: 'n1', target: 'n2', weight: 1.0 }
|
||||
]
|
||||
};
|
||||
|
||||
const cypher = exportToNeo4j(graph);
|
||||
assert.ok(cypher.includes('\\"'), 'Should escape quotes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('D3.js Export', () => {
|
||||
it('should export valid D3 JSON format', () => {
|
||||
const d3Data = exportToD3(sampleGraph);
|
||||
|
||||
assert.ok(d3Data.nodes, 'Should have nodes array');
|
||||
assert.ok(d3Data.links, 'Should have links array');
|
||||
assert.ok(Array.isArray(d3Data.nodes), 'Nodes should be an array');
|
||||
assert.ok(Array.isArray(d3Data.links), 'Links should be an array');
|
||||
});
|
||||
|
||||
it('should include node properties', () => {
|
||||
const d3Data = exportToD3(sampleGraph, { includeMetadata: true });
|
||||
|
||||
const node = d3Data.nodes[0];
|
||||
assert.ok(node.id, 'Node should have ID');
|
||||
assert.ok(node.name, 'Node should have name');
|
||||
assert.strictEqual(node.type, 'test', 'Node should include attributes');
|
||||
});
|
||||
|
||||
it('should include link properties', () => {
|
||||
const d3Data = exportToD3(sampleGraph);
|
||||
|
||||
const link = d3Data.links[0];
|
||||
assert.ok(link.source, 'Link should have source');
|
||||
assert.ok(link.target, 'Link should have target');
|
||||
assert.strictEqual(link.value, 0.95, 'Link should have value (weight)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NetworkX Export', () => {
|
||||
it('should export valid NetworkX JSON format', () => {
|
||||
const nxData = exportToNetworkX(sampleGraph);
|
||||
|
||||
assert.strictEqual(nxData.directed, true, 'Should be directed graph');
|
||||
assert.ok(nxData.nodes, 'Should have nodes array');
|
||||
assert.ok(nxData.links, 'Should have links array');
|
||||
assert.ok(nxData.graph, 'Should have graph metadata');
|
||||
});
|
||||
|
||||
it('should include node attributes', () => {
|
||||
const nxData = exportToNetworkX(sampleGraph, { includeMetadata: true });
|
||||
|
||||
const node = nxData.nodes.find((n: any) => n.id === 'n1');
|
||||
assert.ok(node, 'Should find node');
|
||||
assert.strictEqual(node.label, 'Node 1', 'Should have label');
|
||||
assert.strictEqual(node.type, 'test', 'Should have attributes');
|
||||
});
|
||||
|
||||
it('should include edge attributes', () => {
|
||||
const nxData = exportToNetworkX(sampleGraph);
|
||||
|
||||
const link = nxData.links[0];
|
||||
assert.strictEqual(link.weight, 0.95, 'Should have weight');
|
||||
assert.strictEqual(link.type, 'similar', 'Should have type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unified Export Function', () => {
|
||||
it('should export to all formats', () => {
|
||||
const formats = ['graphml', 'gexf', 'neo4j', 'd3', 'networkx'] as const;
|
||||
|
||||
for (const format of formats) {
|
||||
const result = exportGraph(sampleGraph, format);
|
||||
|
||||
assert.strictEqual(result.format, format, `Should return correct format: ${format}`);
|
||||
assert.ok(result.data, 'Should have data');
|
||||
assert.strictEqual(result.nodeCount, 2, 'Should have correct node count');
|
||||
assert.strictEqual(result.edgeCount, 1, 'Should have correct edge count');
|
||||
assert.ok(result.metadata, 'Should have metadata');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error for unsupported format', () => {
|
||||
assert.throws(
|
||||
() => exportGraph(sampleGraph, 'invalid' as any),
|
||||
/Unsupported export format/,
|
||||
'Should throw error for invalid format'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Graph Validation', () => {
|
||||
it('should validate correct graph', () => {
|
||||
assert.doesNotThrow(() => validateGraph(sampleGraph), 'Should not throw for valid graph');
|
||||
});
|
||||
|
||||
it('should reject graph without nodes array', () => {
|
||||
const invalidGraph = { edges: [] } as any;
|
||||
assert.throws(
|
||||
() => validateGraph(invalidGraph),
|
||||
/must have a nodes array/,
|
||||
'Should reject graph without nodes'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject graph without edges array', () => {
|
||||
const invalidGraph = { nodes: [] } as any;
|
||||
assert.throws(
|
||||
() => validateGraph(invalidGraph),
|
||||
/must have an edges array/,
|
||||
'Should reject graph without edges'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject nodes without IDs', () => {
|
||||
const invalidGraph: Graph = {
|
||||
nodes: [{ id: '', label: 'Invalid' }],
|
||||
edges: []
|
||||
};
|
||||
assert.throws(
|
||||
() => validateGraph(invalidGraph),
|
||||
/must have an id/,
|
||||
'Should reject nodes without IDs'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject edges with missing nodes', () => {
|
||||
const invalidGraph: Graph = {
|
||||
nodes: [{ id: 'n1' }],
|
||||
edges: [{ source: 'n1', target: 'n99', weight: 1.0 }]
|
||||
};
|
||||
assert.throws(
|
||||
() => validateGraph(invalidGraph),
|
||||
/non-existent.*node/,
|
||||
'Should reject edges referencing non-existent nodes'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject edges without weight', () => {
|
||||
const invalidGraph: Graph = {
|
||||
nodes: [{ id: 'n1' }, { id: 'n2' }],
|
||||
edges: [{ source: 'n1', target: 'n2', weight: 'invalid' as any }]
|
||||
};
|
||||
assert.throws(
|
||||
() => validateGraph(invalidGraph),
|
||||
/numeric weight/,
|
||||
'Should reject edges without numeric weight'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty graph', () => {
|
||||
const emptyGraph: Graph = { nodes: [], edges: [] };
|
||||
|
||||
const graphML = exportToGraphML(emptyGraph);
|
||||
assert.ok(graphML.includes('<graphml'), 'Should export empty graph');
|
||||
|
||||
const d3Data = exportToD3(emptyGraph);
|
||||
assert.strictEqual(d3Data.nodes.length, 0, 'Should have no nodes');
|
||||
assert.strictEqual(d3Data.links.length, 0, 'Should have no links');
|
||||
});
|
||||
|
||||
it('should handle graph with nodes but no edges', () => {
|
||||
const graph: Graph = {
|
||||
nodes: [{ id: 'n1' }, { id: 'n2' }],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const result = exportGraph(graph, 'd3');
|
||||
assert.strictEqual(result.nodeCount, 2, 'Should have 2 nodes');
|
||||
assert.strictEqual(result.edgeCount, 0, 'Should have 0 edges');
|
||||
});
|
||||
|
||||
it('should handle large attribute values', () => {
|
||||
const graph: Graph = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'n1',
|
||||
label: 'Node with long text',
|
||||
attributes: {
|
||||
description: 'A'.repeat(1000),
|
||||
largeArray: Array(100).fill(1)
|
||||
}
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
assert.doesNotThrow(
|
||||
() => exportToGraphML(graph, { includeMetadata: true }),
|
||||
'Should handle large attributes'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle special characters in all formats', () => {
|
||||
const graph: Graph = {
|
||||
nodes: [
|
||||
{ id: 'n1', label: 'Test <>&"\' special chars' },
|
||||
{ id: 'n2', label: 'Normal' }
|
||||
],
|
||||
edges: [{ source: 'n1', target: 'n2', weight: 1.0 }]
|
||||
};
|
||||
|
||||
// Should not throw for any format
|
||||
assert.doesNotThrow(() => exportToGraphML(graph), 'GraphML should handle special chars');
|
||||
assert.doesNotThrow(() => exportToGEXF(graph), 'GEXF should handle special chars');
|
||||
assert.doesNotThrow(() => exportToNeo4j(graph), 'Neo4j should handle special chars');
|
||||
assert.doesNotThrow(() => exportToD3(graph), 'D3 should handle special chars');
|
||||
assert.doesNotThrow(() => exportToNetworkX(graph), 'NetworkX should handle special chars');
|
||||
});
|
||||
|
||||
it('should handle circular references in graph', () => {
|
||||
const graph: Graph = {
|
||||
nodes: [
|
||||
{ id: 'n1' },
|
||||
{ id: 'n2' },
|
||||
{ id: 'n3' }
|
||||
],
|
||||
edges: [
|
||||
{ source: 'n1', target: 'n2', weight: 1.0 },
|
||||
{ source: 'n2', target: 'n3', weight: 1.0 },
|
||||
{ source: 'n3', target: 'n1', weight: 1.0 }
|
||||
]
|
||||
};
|
||||
|
||||
assert.doesNotThrow(
|
||||
() => exportGraph(graph, 'd3'),
|
||||
'Should handle circular graph'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should handle moderately large graphs', () => {
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
|
||||
// Create 100 nodes
|
||||
for (let i = 0; i < 100; i++) {
|
||||
nodes.push({
|
||||
id: `node${i}`,
|
||||
label: `Node ${i}`,
|
||||
attributes: { index: i }
|
||||
});
|
||||
}
|
||||
|
||||
// Create edges (each node connects to next 5)
|
||||
for (let i = 0; i < 95; i++) {
|
||||
for (let j = i + 1; j < Math.min(i + 6, 100); j++) {
|
||||
edges.push({
|
||||
source: `node${i}`,
|
||||
target: `node${j}`,
|
||||
weight: Math.random()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const graph: Graph = { nodes, edges };
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = exportGraph(graph, 'graphml');
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
assert.ok(duration < 1000, `Export should complete in under 1s (took ${duration}ms)`);
|
||||
assert.strictEqual(result.nodeCount, 100, 'Should export all nodes');
|
||||
assert.ok(result.edgeCount > 0, 'Should export edges');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user