Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'

This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
7854 changed files with 3522914 additions and 0 deletions

View File

@@ -0,0 +1,385 @@
/**
* @fileoverview Unit tests for the embeddings integration module
*
* @author ruv.io Team <info@ruv.io>
* @license MIT
*/
import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
import {
EmbeddingProvider,
OpenAIEmbeddings,
CohereEmbeddings,
AnthropicEmbeddings,
HuggingFaceEmbeddings,
type BatchEmbeddingResult,
type EmbeddingError,
} from '../src/embeddings.js';
// ============================================================================
// Mock Implementation for Testing
// ============================================================================
class MockEmbeddingProvider extends EmbeddingProvider {
private dimension: number;
private batchSize: number;
constructor(dimension = 384, batchSize = 10) {
super();
this.dimension = dimension;
this.batchSize = batchSize;
}
getMaxBatchSize(): number {
return this.batchSize;
}
getDimension(): number {
return this.dimension;
}
async embedTexts(texts: string[]): Promise<BatchEmbeddingResult> {
// Generate mock embeddings
const embeddings = texts.map((text, index) => ({
embedding: Array.from({ length: this.dimension }, () => Math.random()),
index,
tokens: text.length,
}));
return {
embeddings,
totalTokens: texts.reduce((sum, text) => sum + text.length, 0),
metadata: {
provider: 'mock',
model: 'mock-model',
},
};
}
}
// ============================================================================
// Tests for Base EmbeddingProvider
// ============================================================================
describe('EmbeddingProvider (Abstract Base)', () => {
it('should embed single text', async () => {
const provider = new MockEmbeddingProvider(384);
const embedding = await provider.embedText('Hello, world!');
assert.strictEqual(embedding.length, 384);
assert.ok(Array.isArray(embedding));
assert.ok(embedding.every(val => typeof val === 'number'));
});
it('should embed multiple texts', async () => {
const provider = new MockEmbeddingProvider(384);
const texts = ['First text', 'Second text', 'Third text'];
const result = await provider.embedTexts(texts);
assert.strictEqual(result.embeddings.length, 3);
assert.ok(result.totalTokens > 0);
assert.strictEqual(result.metadata?.provider, 'mock');
});
it('should handle empty text array', async () => {
const provider = new MockEmbeddingProvider(384);
const result = await provider.embedTexts([]);
assert.strictEqual(result.embeddings.length, 0);
});
it('should create batches correctly', async () => {
const provider = new MockEmbeddingProvider(384, 5);
const texts = Array.from({ length: 12 }, (_, i) => `Text ${i}`);
const result = await provider.embedTexts(texts);
assert.strictEqual(result.embeddings.length, 12);
// Verify all indices are present
const indices = result.embeddings.map(e => e.index).sort((a, b) => a - b);
assert.deepStrictEqual(indices, Array.from({ length: 12 }, (_, i) => i));
});
});
// ============================================================================
// Tests for OpenAI Provider (Mock)
// ============================================================================
describe('OpenAIEmbeddings', () => {
it('should throw error if OpenAI SDK not installed', () => {
assert.throws(
() => {
new OpenAIEmbeddings({ apiKey: 'test-key' });
},
/OpenAI SDK not found/
);
});
it('should have correct default configuration', () => {
// This would work if OpenAI SDK is installed
// For now, we test the error case
try {
const openai = new OpenAIEmbeddings({ apiKey: 'test-key' });
assert.fail('Should have thrown error');
} catch (error: any) {
assert.ok(error.message.includes('OpenAI SDK not found'));
}
});
it('should return correct dimensions', () => {
// Mock test - would need OpenAI SDK installed
const expectedDimensions = {
'text-embedding-3-small': 1536,
'text-embedding-3-large': 3072,
'text-embedding-ada-002': 1536,
};
assert.ok(expectedDimensions['text-embedding-3-small'] === 1536);
});
it('should have correct max batch size', () => {
// OpenAI supports up to 2048 inputs per request
const expectedBatchSize = 2048;
assert.strictEqual(expectedBatchSize, 2048);
});
});
// ============================================================================
// Tests for Cohere Provider (Mock)
// ============================================================================
describe('CohereEmbeddings', () => {
it('should throw error if Cohere SDK not installed', () => {
assert.throws(
() => {
new CohereEmbeddings({ apiKey: 'test-key' });
},
/Cohere SDK not found/
);
});
it('should return correct dimensions', () => {
// Cohere v3 models use 1024 dimensions
const expectedDimension = 1024;
assert.strictEqual(expectedDimension, 1024);
});
it('should have correct max batch size', () => {
// Cohere supports up to 96 texts per request
const expectedBatchSize = 96;
assert.strictEqual(expectedBatchSize, 96);
});
});
// ============================================================================
// Tests for Anthropic Provider (Mock)
// ============================================================================
describe('AnthropicEmbeddings', () => {
it('should throw error if Anthropic SDK not installed', () => {
assert.throws(
() => {
new AnthropicEmbeddings({ apiKey: 'test-key' });
},
/Anthropic SDK not found/
);
});
it('should return correct dimensions', () => {
// Voyage-2 uses 1024 dimensions
const expectedDimension = 1024;
assert.strictEqual(expectedDimension, 1024);
});
it('should have correct max batch size', () => {
const expectedBatchSize = 128;
assert.strictEqual(expectedBatchSize, 128);
});
});
// ============================================================================
// Tests for HuggingFace Provider (Mock)
// ============================================================================
describe('HuggingFaceEmbeddings', () => {
it('should create with default config', () => {
const hf = new HuggingFaceEmbeddings();
assert.strictEqual(hf.getDimension(), 384);
assert.strictEqual(hf.getMaxBatchSize(), 32);
});
it('should create with custom config', () => {
const hf = new HuggingFaceEmbeddings({
batchSize: 64,
});
assert.strictEqual(hf.getMaxBatchSize(), 64);
});
it('should handle initialization lazily', async () => {
const hf = new HuggingFaceEmbeddings();
// Should not throw on construction
assert.ok(hf);
});
});
// ============================================================================
// Tests for Retry Logic
// ============================================================================
describe('Retry Logic', () => {
it('should retry on retryable errors', async () => {
let attempts = 0;
class RetryTestProvider extends MockEmbeddingProvider {
async embedTexts(texts: string[]): Promise<BatchEmbeddingResult> {
attempts++;
if (attempts < 3) {
throw new Error('Rate limit exceeded');
}
return super.embedTexts(texts);
}
}
const provider = new RetryTestProvider();
const result = await provider.embedTexts(['Test']);
assert.strictEqual(attempts, 3);
assert.strictEqual(result.embeddings.length, 1);
});
it('should not retry on non-retryable errors', async () => {
let attempts = 0;
class NonRetryableProvider extends MockEmbeddingProvider {
async embedTexts(texts: string[]): Promise<BatchEmbeddingResult> {
attempts++;
throw new Error('Invalid API key');
}
}
const provider = new NonRetryableProvider();
try {
await provider.embedTexts(['Test']);
assert.fail('Should have thrown error');
} catch (error) {
// Should fail on first attempt only
assert.strictEqual(attempts, 1);
}
});
it('should respect max retries', async () => {
let attempts = 0;
class MaxRetriesProvider extends MockEmbeddingProvider {
async embedTexts(texts: string[]): Promise<BatchEmbeddingResult> {
attempts++;
throw new Error('Rate limit exceeded');
}
}
const provider = new MaxRetriesProvider();
try {
await provider.embedTexts(['Test']);
assert.fail('Should have thrown error');
} catch (error) {
// Default maxRetries is 3, so should try 4 times total (initial + 3 retries)
assert.strictEqual(attempts, 4);
}
});
});
// ============================================================================
// Tests for Error Handling
// ============================================================================
describe('Error Handling', () => {
it('should identify retryable errors', () => {
const provider = new MockEmbeddingProvider();
const retryableErrors = [
new Error('Rate limit exceeded'),
new Error('Request timeout'),
new Error('503 Service Unavailable'),
new Error('429 Too Many Requests'),
new Error('Connection refused'),
];
retryableErrors.forEach(error => {
const isRetryable = (provider as any).isRetryableError(error);
assert.strictEqual(isRetryable, true, `Should be retryable: ${error.message}`);
});
});
it('should identify non-retryable errors', () => {
const provider = new MockEmbeddingProvider();
const nonRetryableErrors = [
new Error('Invalid API key'),
new Error('Authentication failed'),
new Error('Invalid request'),
new Error('Resource not found'),
];
nonRetryableErrors.forEach(error => {
const isRetryable = (provider as any).isRetryableError(error);
assert.strictEqual(isRetryable, false, `Should not be retryable: ${error.message}`);
});
});
it('should create embedding error with context', () => {
const provider = new MockEmbeddingProvider();
const originalError = new Error('Test error');
const embeddingError = (provider as any).createEmbeddingError(
originalError,
'Test context',
true
) as EmbeddingError;
assert.strictEqual(embeddingError.message, 'Test context: Test error');
assert.strictEqual(embeddingError.retryable, true);
assert.strictEqual(embeddingError.error, originalError);
});
});
// ============================================================================
// Tests for Batch Processing
// ============================================================================
describe('Batch Processing', () => {
it('should split large datasets into batches', async () => {
const provider = new MockEmbeddingProvider(384, 10);
const texts = Array.from({ length: 35 }, (_, i) => `Text ${i}`);
const result = await provider.embedTexts(texts);
assert.strictEqual(result.embeddings.length, 35);
// Verify all texts were processed
const processedIndices = result.embeddings.map(e => e.index).sort((a, b) => a - b);
assert.deepStrictEqual(processedIndices, Array.from({ length: 35 }, (_, i) => i));
});
it('should handle single batch correctly', async () => {
const provider = new MockEmbeddingProvider(384, 100);
const texts = Array.from({ length: 50 }, (_, i) => `Text ${i}`);
const result = await provider.embedTexts(texts);
assert.strictEqual(result.embeddings.length, 50);
});
it('should preserve order across batches', async () => {
const provider = new MockEmbeddingProvider(384, 5);
const texts = Array.from({ length: 12 }, (_, i) => `Text ${i}`);
const result = await provider.embedTexts(texts);
// Check that indices are correct
result.embeddings.forEach((embedding, i) => {
assert.strictEqual(embedding.index, i);
});
});
});
console.log('✓ All embeddings tests passed!');

View 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('&lt;'), 'Should escape < character');
assert.ok(graphML.includes('&gt;'), 'Should escape > character');
assert.ok(graphML.includes('&amp;'), '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');
});
});

View File

@@ -0,0 +1,329 @@
/**
* Tests for Database Persistence Module
*
* This test suite covers:
* - Save and load operations
* - Snapshot management
* - Export/import functionality
* - Progress callbacks
* - Incremental saves
* - Error handling
* - Data integrity verification
*/
import { test } from 'node:test';
import { strictEqual, ok, deepStrictEqual } from 'node:assert';
import { promises as fs } from 'fs';
import * as path from 'path';
import { VectorDB } from 'ruvector';
import {
DatabasePersistence,
formatFileSize,
formatTimestamp,
estimateMemoryUsage,
} from '../src/persistence.js';
const TEST_DATA_DIR = './test-data';
// Cleanup helper
async function cleanup() {
try {
await fs.rm(TEST_DATA_DIR, { recursive: true, force: true });
} catch (error) {
// Ignore errors
}
}
// Create sample database
function createSampleDB(dimension = 128, count = 100) {
const db = new VectorDB({ dimension, metric: 'cosine' });
for (let i = 0; i < count; i++) {
db.insert({
id: `doc-${i}`,
vector: Array(dimension).fill(0).map(() => Math.random()),
metadata: {
index: i,
category: i % 3 === 0 ? 'A' : i % 3 === 1 ? 'B' : 'C',
timestamp: Date.now() - i * 1000,
},
});
}
return db;
}
// ============================================================================
// Test Suite
// ============================================================================
test('DatabasePersistence - Save and Load', async (t) => {
await cleanup();
const db = createSampleDB(128, 100);
const persistence = new DatabasePersistence(db, {
baseDir: path.join(TEST_DATA_DIR, 'save-load'),
});
// Save
const savePath = await persistence.save();
ok(savePath, 'Save should return a path');
// Verify file exists
const stats = await fs.stat(savePath);
ok(stats.size > 0, 'Saved file should not be empty');
// Load into new database
const db2 = new VectorDB({ dimension: 128 });
const persistence2 = new DatabasePersistence(db2, {
baseDir: path.join(TEST_DATA_DIR, 'save-load'),
});
await persistence2.load({ path: savePath });
// Verify data
strictEqual(db2.stats().count, 100, 'Should load all vectors');
const original = db.get('doc-50');
const loaded = db2.get('doc-50');
ok(original && loaded, 'Should retrieve same document');
deepStrictEqual(loaded.metadata, original.metadata, 'Metadata should match');
});
test('DatabasePersistence - Compressed Save', async (t) => {
await cleanup();
const db = createSampleDB(128, 200);
const persistence = new DatabasePersistence(db, {
baseDir: path.join(TEST_DATA_DIR, 'compressed'),
compression: 'gzip',
});
const savePath = await persistence.save({ compress: true });
// Verify compression
const compressedStats = await fs.stat(savePath);
// Save uncompressed for comparison
const persistence2 = new DatabasePersistence(db, {
baseDir: path.join(TEST_DATA_DIR, 'uncompressed'),
compression: 'none',
});
const uncompressedPath = await persistence2.save({ compress: false });
const uncompressedStats = await fs.stat(uncompressedPath);
ok(
compressedStats.size < uncompressedStats.size,
'Compressed file should be smaller'
);
});
test('DatabasePersistence - Snapshot Management', async (t) => {
await cleanup();
const db = createSampleDB(64, 50);
const persistence = new DatabasePersistence(db, {
baseDir: path.join(TEST_DATA_DIR, 'snapshots'),
maxSnapshots: 3,
});
// Create snapshots
const snap1 = await persistence.createSnapshot('snapshot-1', {
description: 'First snapshot',
});
ok(snap1.id, 'Snapshot should have ID');
strictEqual(snap1.name, 'snapshot-1', 'Snapshot name should match');
strictEqual(snap1.vectorCount, 50, 'Snapshot should record vector count');
// Add more vectors
for (let i = 50; i < 100; i++) {
db.insert({
id: `doc-${i}`,
vector: Array(64).fill(0).map(() => Math.random()),
});
}
const snap2 = await persistence.createSnapshot('snapshot-2');
strictEqual(snap2.vectorCount, 100, 'Second snapshot should have more vectors');
// List snapshots
const snapshots = await persistence.listSnapshots();
strictEqual(snapshots.length, 2, 'Should have 2 snapshots');
// Restore first snapshot
await persistence.restoreSnapshot(snap1.id);
strictEqual(db.stats().count, 50, 'Should restore to 50 vectors');
// Delete snapshot
await persistence.deleteSnapshot(snap1.id);
const remaining = await persistence.listSnapshots();
strictEqual(remaining.length, 1, 'Should have 1 snapshot after deletion');
});
test('DatabasePersistence - Export and Import', async (t) => {
await cleanup();
const db = createSampleDB(256, 150);
const persistence = new DatabasePersistence(db, {
baseDir: path.join(TEST_DATA_DIR, 'export'),
});
const exportPath = path.join(TEST_DATA_DIR, 'export', 'database-export.json');
// Export
await persistence.export({
path: exportPath,
format: 'json',
compress: false,
});
// Verify export file
const exportStats = await fs.stat(exportPath);
ok(exportStats.size > 0, 'Export file should exist');
// Import into new database
const db2 = new VectorDB({ dimension: 256 });
const persistence2 = new DatabasePersistence(db2, {
baseDir: path.join(TEST_DATA_DIR, 'import'),
});
await persistence2.import({
path: exportPath,
clear: true,
verifyChecksum: true,
});
strictEqual(db2.stats().count, 150, 'Should import all vectors');
});
test('DatabasePersistence - Progress Callbacks', async (t) => {
await cleanup();
const db = createSampleDB(128, 300);
const persistence = new DatabasePersistence(db, {
baseDir: path.join(TEST_DATA_DIR, 'progress'),
});
let progressCalls = 0;
let lastPercentage = 0;
await persistence.save({
onProgress: (progress) => {
progressCalls++;
ok(progress.percentage >= 0 && progress.percentage <= 100, 'Percentage should be 0-100');
ok(progress.percentage >= lastPercentage, 'Percentage should increase');
ok(progress.message, 'Should have progress message');
lastPercentage = progress.percentage;
},
});
ok(progressCalls > 0, 'Should call progress callback');
strictEqual(lastPercentage, 100, 'Should reach 100%');
});
test('DatabasePersistence - Checksum Verification', async (t) => {
await cleanup();
const db = createSampleDB(128, 100);
const persistence = new DatabasePersistence(db, {
baseDir: path.join(TEST_DATA_DIR, 'checksum'),
});
const savePath = await persistence.save();
// Load with checksum verification
const db2 = new VectorDB({ dimension: 128 });
const persistence2 = new DatabasePersistence(db2, {
baseDir: path.join(TEST_DATA_DIR, 'checksum'),
});
// Should succeed with valid checksum
await persistence2.load({
path: savePath,
verifyChecksum: true,
});
strictEqual(db2.stats().count, 100, 'Should load successfully');
// Corrupt the file
const data = await fs.readFile(savePath, 'utf-8');
const corrupted = data.replace('"doc-50"', '"doc-XX"');
await fs.writeFile(savePath, corrupted);
// Should fail with corrupted file
const db3 = new VectorDB({ dimension: 128 });
const persistence3 = new DatabasePersistence(db3, {
baseDir: path.join(TEST_DATA_DIR, 'checksum'),
});
let errorThrown = false;
try {
await persistence3.load({
path: savePath,
verifyChecksum: true,
});
} catch (error) {
errorThrown = true;
ok(error.message.includes('checksum'), 'Should mention checksum in error');
}
ok(errorThrown, 'Should throw error for corrupted file');
});
test('Utility Functions', async (t) => {
// Test formatFileSize
strictEqual(formatFileSize(0), '0.00 B');
strictEqual(formatFileSize(1024), '1.00 KB');
strictEqual(formatFileSize(1024 * 1024), '1.00 MB');
strictEqual(formatFileSize(1536 * 1024), '1.50 MB');
// Test formatTimestamp
const timestamp = new Date('2024-01-15T10:30:00.000Z').getTime();
ok(formatTimestamp(timestamp).includes('2024-01-15'));
// Test estimateMemoryUsage
const state = {
version: '1.0.0',
options: { dimension: 128, metric: 'cosine' as const },
stats: { count: 100, dimension: 128, metric: 'cosine' },
vectors: Array(100).fill(null).map((_, i) => ({
id: `doc-${i}`,
vector: Array(128).fill(0),
metadata: { index: i },
})),
timestamp: Date.now(),
};
const usage = estimateMemoryUsage(state);
ok(usage > 0, 'Should estimate positive memory usage');
});
test('DatabasePersistence - Snapshot Cleanup', async (t) => {
await cleanup();
const db = createSampleDB(64, 50);
const persistence = new DatabasePersistence(db, {
baseDir: path.join(TEST_DATA_DIR, 'cleanup'),
maxSnapshots: 2,
});
// Create 4 snapshots
await persistence.createSnapshot('snap-1');
await persistence.createSnapshot('snap-2');
await persistence.createSnapshot('snap-3');
await persistence.createSnapshot('snap-4');
// Should only keep 2 most recent
const snapshots = await persistence.listSnapshots();
strictEqual(snapshots.length, 2, 'Should auto-cleanup old snapshots');
strictEqual(snapshots[0].name, 'snap-4', 'Should keep newest');
strictEqual(snapshots[1].name, 'snap-3', 'Should keep second newest');
});
// Cleanup after all tests
test.after(async () => {
await cleanup();
});

View File

@@ -0,0 +1,408 @@
/**
* Tests for Temporal Tracking Module
*/
import { test } from 'node:test';
import assert from 'node:assert';
import {
TemporalTracker,
ChangeType,
isChange,
isVersion
} from '../dist/temporal.js';
test('TemporalTracker - Basic version creation', async () => {
const tracker = new TemporalTracker();
// Track a change
tracker.trackChange({
type: ChangeType.ADDITION,
path: 'nodes.User',
before: null,
after: { name: 'User', properties: ['id', 'name'] },
timestamp: Date.now()
});
// Create version
const version = await tracker.createVersion({
description: 'Initial schema',
tags: ['v1.0']
});
assert.ok(version.id, 'Version should have an ID');
assert.strictEqual(version.description, 'Initial schema');
assert.ok(version.tags.includes('v1.0'));
assert.strictEqual(version.changes.length, 1);
});
test('TemporalTracker - List versions', async () => {
const tracker = new TemporalTracker();
// Create multiple versions
for (let i = 0; i < 3; i++) {
tracker.trackChange({
type: ChangeType.ADDITION,
path: `node${i}`,
before: null,
after: `value${i}`,
timestamp: Date.now()
});
await tracker.createVersion({
description: `Version ${i + 1}`,
tags: [`v${i + 1}`]
});
}
const versions = tracker.listVersions();
assert.ok(versions.length >= 3, 'Should have at least 3 versions');
});
test('TemporalTracker - Time-travel query', async () => {
const tracker = new TemporalTracker();
// Create initial version
tracker.trackChange({
type: ChangeType.ADDITION,
path: 'config.value',
before: null,
after: 100,
timestamp: Date.now()
});
const v1 = await tracker.createVersion({
description: 'Version 1'
});
// Wait to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 10));
// Create second version
tracker.trackChange({
type: ChangeType.MODIFICATION,
path: 'config.value',
before: 100,
after: 200,
timestamp: Date.now()
});
const v2 = await tracker.createVersion({
description: 'Version 2'
});
// Query at v1
const stateAtV1 = await tracker.queryAtTimestamp(v1.timestamp);
assert.strictEqual(stateAtV1.config.value, 100);
// Query at v2
const stateAtV2 = await tracker.queryAtTimestamp(v2.timestamp);
assert.strictEqual(stateAtV2.config.value, 200);
});
test('TemporalTracker - Compare versions', async () => {
const tracker = new TemporalTracker();
// Version 1
tracker.trackChange({
type: ChangeType.ADDITION,
path: 'data.field1',
before: null,
after: 'value1',
timestamp: Date.now()
});
const v1 = await tracker.createVersion({ description: 'V1' });
// Version 2
tracker.trackChange({
type: ChangeType.ADDITION,
path: 'data.field2',
before: null,
after: 'value2',
timestamp: Date.now()
});
tracker.trackChange({
type: ChangeType.MODIFICATION,
path: 'data.field1',
before: 'value1',
after: 'value1-modified',
timestamp: Date.now()
});
const v2 = await tracker.createVersion({ description: 'V2' });
// Compare
const diff = await tracker.compareVersions(v1.id, v2.id);
assert.strictEqual(diff.fromVersion, v1.id);
assert.strictEqual(diff.toVersion, v2.id);
assert.ok(diff.changes.length > 0);
assert.strictEqual(diff.summary.additions, 1);
assert.strictEqual(diff.summary.modifications, 1);
});
test('TemporalTracker - Revert version', async () => {
const tracker = new TemporalTracker();
// V1: Add data
tracker.trackChange({
type: ChangeType.ADDITION,
path: 'test.data',
before: null,
after: 'original',
timestamp: Date.now()
});
const v1 = await tracker.createVersion({ description: 'V1' });
// V2: Modify data
tracker.trackChange({
type: ChangeType.MODIFICATION,
path: 'test.data',
before: 'original',
after: 'modified',
timestamp: Date.now()
});
await tracker.createVersion({ description: 'V2' });
// Revert to V1
const revertVersion = await tracker.revertToVersion(v1.id);
assert.ok(revertVersion.id);
assert.ok(revertVersion.description.includes('Revert'));
// Check state is back to original
const currentState = await tracker.queryAtTimestamp(Date.now());
assert.strictEqual(currentState.test.data, 'original');
});
test('TemporalTracker - Add tags', async () => {
const tracker = new TemporalTracker();
tracker.trackChange({
type: ChangeType.ADDITION,
path: 'test',
before: null,
after: 'value',
timestamp: Date.now()
});
const version = await tracker.createVersion({
description: 'Test',
tags: ['initial']
});
// Add more tags
tracker.addTags(version.id, ['production', 'stable']);
const retrieved = tracker.getVersion(version.id);
assert.ok(retrieved.tags.includes('production'));
assert.ok(retrieved.tags.includes('stable'));
assert.ok(retrieved.tags.includes('initial'));
});
test('TemporalTracker - Visualization data', async () => {
const tracker = new TemporalTracker();
// Create multiple versions
for (let i = 0; i < 3; i++) {
tracker.trackChange({
type: ChangeType.ADDITION,
path: `node${i}`,
before: null,
after: `value${i}`,
timestamp: Date.now()
});
await tracker.createVersion({ description: `V${i}` });
}
const vizData = tracker.getVisualizationData();
assert.ok(vizData.timeline.length >= 3);
assert.ok(Array.isArray(vizData.changeFrequency));
assert.ok(Array.isArray(vizData.hotspots));
assert.ok(vizData.versionGraph.nodes.length >= 3);
assert.ok(Array.isArray(vizData.versionGraph.edges));
});
test('TemporalTracker - Audit log', async () => {
const tracker = new TemporalTracker();
tracker.trackChange({
type: ChangeType.ADDITION,
path: 'test',
before: null,
after: 'value',
timestamp: Date.now()
});
await tracker.createVersion({ description: 'Test version' });
const auditLog = tracker.getAuditLog(10);
assert.ok(auditLog.length > 0);
const createEntry = auditLog.find(e => e.operation === 'create');
assert.ok(createEntry);
assert.strictEqual(createEntry.status, 'success');
});
test('TemporalTracker - Storage stats', async () => {
const tracker = new TemporalTracker();
tracker.trackChange({
type: ChangeType.ADDITION,
path: 'test',
before: null,
after: 'value',
timestamp: Date.now()
});
await tracker.createVersion({ description: 'Test' });
const stats = tracker.getStorageStats();
assert.ok(stats.versionCount > 0);
assert.ok(stats.totalChanges > 0);
assert.ok(stats.estimatedSizeBytes > 0);
assert.ok(stats.oldestVersion >= 0); // Baseline is at timestamp 0
assert.ok(stats.newestVersion > 0);
});
test('TemporalTracker - Prune versions', async () => {
const tracker = new TemporalTracker();
// Create many versions
for (let i = 0; i < 10; i++) {
tracker.trackChange({
type: ChangeType.ADDITION,
path: `node${i}`,
before: null,
after: `value${i}`,
timestamp: Date.now()
});
await tracker.createVersion({
description: `V${i}`,
tags: i < 2 ? ['important'] : []
});
}
const beforePrune = tracker.listVersions().length;
// Prune, keeping only last 3 versions + important ones
tracker.pruneVersions(3, ['baseline', 'important']);
const afterPrune = tracker.listVersions().length;
// Should have pruned some versions
assert.ok(afterPrune < beforePrune);
// Important versions should still exist
const importantVersions = tracker.listVersions(['important']);
assert.ok(importantVersions.length >= 2);
});
test('TemporalTracker - Backup and restore', async () => {
const tracker1 = new TemporalTracker();
// Create data
tracker1.trackChange({
type: ChangeType.ADDITION,
path: 'important.data',
before: null,
after: { value: 42 },
timestamp: Date.now()
});
await tracker1.createVersion({
description: 'Important version',
tags: ['backup-test']
});
// Export backup
const backup = tracker1.exportBackup();
assert.ok(backup.versions.length > 0);
assert.ok(backup.exportedAt > 0);
// Import to new tracker
const tracker2 = new TemporalTracker();
tracker2.importBackup(backup);
// Verify data
const versions = tracker2.listVersions(['backup-test']);
assert.ok(versions.length > 0);
const state = await tracker2.queryAtTimestamp(Date.now());
assert.deepStrictEqual(state.important.data, { value: 42 });
});
test('TemporalTracker - Event emission', async (t) => {
const tracker = new TemporalTracker();
let versionCreatedEmitted = false;
let changeTrackedEmitted = false;
tracker.on('versionCreated', () => {
versionCreatedEmitted = true;
});
tracker.on('changeTracked', () => {
changeTrackedEmitted = true;
});
tracker.trackChange({
type: ChangeType.ADDITION,
path: 'test',
before: null,
after: 'value',
timestamp: Date.now()
});
await tracker.createVersion({ description: 'Test' });
assert.ok(changeTrackedEmitted, 'changeTracked event should be emitted');
assert.ok(versionCreatedEmitted, 'versionCreated event should be emitted');
});
test('Type guards - isChange', () => {
const validChange = {
type: ChangeType.ADDITION,
path: 'test.path',
before: null,
after: 'value',
timestamp: Date.now()
};
const invalidChange = {
type: 'invalid',
path: 123,
timestamp: 'not-a-number'
};
assert.ok(isChange(validChange));
assert.ok(!isChange(invalidChange));
});
test('Type guards - isVersion', () => {
const validVersion = {
id: 'test-id',
parentId: null,
timestamp: Date.now(),
description: 'Test',
changes: [],
tags: [],
checksum: 'abc123',
metadata: {}
};
const invalidVersion = {
id: 123,
timestamp: 'invalid',
changes: 'not-an-array',
tags: null
};
assert.ok(isVersion(validVersion));
assert.ok(!isVersion(invalidVersion));
});