Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
385
npm/packages/ruvector-extensions/tests/embeddings.test.ts
Normal file
385
npm/packages/ruvector-extensions/tests/embeddings.test.ts
Normal 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!');
|
||||
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');
|
||||
});
|
||||
});
|
||||
329
npm/packages/ruvector-extensions/tests/persistence.test.ts
Normal file
329
npm/packages/ruvector-extensions/tests/persistence.test.ts
Normal 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();
|
||||
});
|
||||
408
npm/packages/ruvector-extensions/tests/temporal.test.js
Normal file
408
npm/packages/ruvector-extensions/tests/temporal.test.js
Normal 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));
|
||||
});
|
||||
Reference in New Issue
Block a user