Files
Profa ec2c243172 fix(embeddings): complete 384-dim standardization across tests and providers
- Update default embed model from nomic-embed-text to all-MiniLM-L6-v2
  in Ollama LLM provider
- Fix 15 test files with hardcoded 768-dim references
- Update mock model names in Ollama health checks
- Update comments referencing old model name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:33:47 +00:00

420 lines
12 KiB
TypeScript

/**
* Agentic QE v3 - NomicEmbedder Unit Tests
* Tests the embedding service with mocked Ollama client
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
NomicEmbedder,
EmbeddingCache,
EMBEDDING_CONFIG,
CodeChunk,
} from '../../../../src/shared/embeddings';
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('NomicEmbedder', () => {
let embedder: NomicEmbedder;
beforeEach(() => {
vi.clearAllMocks();
embedder = new NomicEmbedder({
enableFallback: true,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('should create embedder with default config', () => {
const config = embedder.getConfig();
expect(config.model).toBe(EMBEDDING_CONFIG.MODEL);
expect(config.dimensions).toBe(384);
expect(config.enableFallback).toBe(true);
});
it('should create embedder with custom config', () => {
const customEmbedder = new NomicEmbedder({
ollamaBaseUrl: 'http://custom:11434',
batchSize: 50,
enableFallback: false,
});
const config = customEmbedder.getConfig();
expect(config.batchSize).toBe(50);
expect(config.enableFallback).toBe(false);
});
});
describe('embed (fallback mode)', () => {
beforeEach(() => {
// Mock Ollama health check as unavailable
mockFetch.mockResolvedValue({
ok: false,
});
});
it('should generate pseudo-embedding when Ollama unavailable', async () => {
const embedding = await embedder.embed('function add(a, b) { return a + b; }');
expect(Array.isArray(embedding)).toBe(true);
expect(embedding.length).toBe(EMBEDDING_CONFIG.DIMENSIONS);
});
it('should generate normalized embeddings', async () => {
const embedding = await embedder.embed('test code');
const magnitude = Math.sqrt(embedding.reduce((sum, v) => sum + v * v, 0));
expect(magnitude).toBeCloseTo(1, 5);
});
it('should cache embeddings', async () => {
const text = 'const x = 1;';
const embedding1 = await embedder.embed(text);
const embedding2 = await embedder.embed(text);
expect(embedding1).toEqual(embedding2);
const stats = embedder.getCacheStats();
expect(stats.hits).toBeGreaterThan(0);
});
it('should generate different embeddings for different code', async () => {
const embedding1 = await embedder.embed('function add() {}');
const embedding2 = await embedder.embed('class User {}');
expect(embedding1).not.toEqual(embedding2);
});
it('should throw when fallback disabled and Ollama unavailable', async () => {
const strictEmbedder = new NomicEmbedder({
enableFallback: false,
});
await expect(strictEmbedder.embed('test')).rejects.toThrow(
/Ollama is not available/
);
});
});
describe('embed (with Ollama)', () => {
const mockEmbedding = new Array(384).fill(0).map((_, i) => i / 1000);
beforeEach(() => {
// Mock successful Ollama responses
mockFetch.mockImplementation((url: string) => {
if (url.includes('/api/tags')) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
models: [{ name: 'all-MiniLM-L6-v2:latest', model: 'all-MiniLM-L6-v2' }],
}),
});
}
if (url.includes('/api/embeddings')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ embedding: mockEmbedding }),
});
}
return Promise.resolve({ ok: false });
});
});
it('should generate embedding using Ollama when available', async () => {
const freshEmbedder = new NomicEmbedder({ enableFallback: true });
const embedding = await freshEmbedder.embed('test code');
expect(embedding).toEqual(mockEmbedding);
});
it('should use cached embedding on second call', async () => {
const freshEmbedder = new NomicEmbedder({ enableFallback: true });
await freshEmbedder.embed('cached test');
await freshEmbedder.embed('cached test');
// Should only call API once for the same content
const embeddingCalls = mockFetch.mock.calls.filter((call) =>
call[0].includes('/api/embeddings')
);
expect(embeddingCalls.length).toBe(1);
});
});
describe('formatForEmbedding', () => {
it('should format code chunk with language, type, and name', () => {
const chunk: CodeChunk = {
id: '1',
fileId: 'file-1',
content: 'return a + b;',
startLine: 1,
endLine: 3,
type: 'function',
name: 'add',
language: 'typescript',
};
const formatted = embedder.formatForEmbedding(chunk);
expect(formatted).toBe('typescript function add: return a + b;');
});
it('should handle chunk without name', () => {
const chunk: CodeChunk = {
id: '1',
fileId: 'file-1',
content: 'const x = 1;',
startLine: 1,
endLine: 1,
type: 'variable',
language: 'javascript',
};
const formatted = embedder.formatForEmbedding(chunk);
expect(formatted).toBe('javascript variable: const x = 1;');
});
it('should truncate very long content', () => {
const longContent = 'x'.repeat(50000);
const chunk: CodeChunk = {
id: '1',
fileId: 'file-1',
content: longContent,
startLine: 1,
endLine: 1000,
type: 'file',
language: 'text',
};
const formatted = embedder.formatForEmbedding(chunk);
expect(formatted.length).toBeLessThan(EMBEDDING_CONFIG.CONTEXT_WINDOW * 4 + 100);
expect(formatted.endsWith('...')).toBe(true);
});
});
describe('embedBatch (simple texts)', () => {
beforeEach(() => {
mockFetch.mockResolvedValue({ ok: false });
});
it('should process batch of text strings', async () => {
const texts = ['function add() {}', 'class User {}'];
const result = await embedder.embedBatch(texts);
expect(result.length).toBe(2);
expect(result[0].length).toBe(EMBEDDING_CONFIG.DIMENSIONS);
expect(result[1].length).toBe(EMBEDDING_CONFIG.DIMENSIONS);
});
});
describe('embedCodeChunks', () => {
beforeEach(() => {
// Mock Ollama as unavailable for batch tests
mockFetch.mockResolvedValue({ ok: false });
});
it('should process batch of chunks', async () => {
const chunks: CodeChunk[] = [
{
id: '1',
fileId: 'f1',
content: 'function add() {}',
startLine: 1,
endLine: 1,
type: 'function',
language: 'typescript',
},
{
id: '2',
fileId: 'f1',
content: 'class User {}',
startLine: 2,
endLine: 2,
type: 'class',
language: 'typescript',
},
];
const result = await embedder.embedCodeChunks(chunks);
expect(result.results.length).toBe(2);
expect(result.stats.totalChunks).toBe(2);
expect(result.stats.computedNew).toBe(2);
});
it('should report progress during batch processing', async () => {
const chunks: CodeChunk[] = Array.from({ length: 5 }, (_, i) => ({
id: `${i}`,
fileId: 'f1',
content: `code ${i}`,
startLine: i,
endLine: i,
type: 'code',
language: 'typescript',
}));
const progressUpdates: number[] = [];
await embedder.embedCodeChunks(chunks, (progress) => {
progressUpdates.push(progress.percentage);
});
expect(progressUpdates.length).toBeGreaterThan(0);
expect(progressUpdates[progressUpdates.length - 1]).toBe(100);
});
it('should track cache hits in batch', async () => {
const chunks: CodeChunk[] = [
{
id: '1',
fileId: 'f1',
content: 'same content',
startLine: 1,
endLine: 1,
type: 'code',
language: 'typescript',
},
];
// First batch
await embedder.embedCodeChunks(chunks);
// Second batch with same content
const result = await embedder.embedCodeChunks(chunks);
expect(result.stats.cachedHits).toBe(1);
expect(result.stats.computedNew).toBe(0);
});
});
describe('cache operations', () => {
beforeEach(() => {
mockFetch.mockResolvedValue({ ok: false });
});
it('should clear cache', async () => {
await embedder.embed('test');
embedder.clearCache();
const stats = embedder.getCacheStats();
expect(stats.size).toBe(0);
});
it('should track cache statistics', async () => {
await embedder.embed('test1');
await embedder.embed('test2');
await embedder.embed('test1'); // Cache hit
const stats = embedder.getCacheStats();
expect(stats.size).toBe(2);
expect(stats.hits).toBe(1);
expect(stats.misses).toBe(2);
});
});
describe('healthCheck', () => {
it('should return false when Ollama unavailable', async () => {
mockFetch.mockResolvedValue({ ok: false });
const isHealthy = await embedder.healthCheck();
expect(isHealthy).toBe(false);
});
it('should return true when Ollama available with model', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
models: [{ name: 'all-MiniLM-L6-v2:latest' }],
}),
});
const isHealthy = await embedder.healthCheck();
expect(isHealthy).toBe(true);
});
it('should return false when model not present', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
models: [{ name: 'other-model' }],
}),
});
const isHealthy = await embedder.healthCheck();
expect(isHealthy).toBe(false);
});
});
});
describe('EmbeddingCache', () => {
let cache: EmbeddingCache;
beforeEach(() => {
cache = new EmbeddingCache(100);
});
it('should store and retrieve embeddings', () => {
const embedding = [0.1, 0.2, 0.3];
cache.set('content', 'model', embedding);
const retrieved = cache.get('content', 'model');
expect(retrieved).toEqual(embedding);
});
it('should return null for missing entries', () => {
const result = cache.get('nonexistent', 'model');
expect(result).toBeNull();
});
it('should track hit/miss statistics', () => {
cache.set('content', 'model', [0.1]);
cache.get('content', 'model'); // Hit
cache.get('missing', 'model'); // Miss
const stats = cache.getStats();
expect(stats.hits).toBe(1);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBeCloseTo(0.5, 2);
});
it('should evict oldest entries when full', () => {
const smallCache = new EmbeddingCache(2);
smallCache.set('first', 'model', [0.1]);
smallCache.set('second', 'model', [0.2]);
smallCache.set('third', 'model', [0.3]); // Should evict 'first'
expect(smallCache.get('first', 'model')).toBeNull();
expect(smallCache.get('second', 'model')).toEqual([0.2]);
expect(smallCache.get('third', 'model')).toEqual([0.3]);
});
it('should clear all entries', () => {
cache.set('content', 'model', [0.1]);
cache.clear();
expect(cache.getStats().size).toBe(0);
expect(cache.get('content', 'model')).toBeNull();
});
it('should export and import cache data', () => {
cache.set('c1', 'model', [0.1]);
cache.set('c2', 'model', [0.2]);
const exported = cache.export();
expect(exported.length).toBe(2);
const newCache = new EmbeddingCache(100);
newCache.import(exported);
expect(newCache.getStats().size).toBe(2);
});
});