/**
* WikiLinkAutocomplete Component Tests
*
* Tests for the wiki-style [[ link autocomplete functionality.
* Includes tests for link insertion and waypoint connection creation.
*
* Requirements: 41.1, 41.2
*/
import { describe, expect, it, vi } from 'vitest';
import type { Memory } from '../../types/api';
// Mock the API client module before importing the component
vi.mock('../../api/client', () => ({
getDefaultClient: vi.fn(() => ({
updateMemory: vi.fn().mockResolvedValue({
memoryId: 'source-mem',
embeddingsRegenerated: false,
connectionsUpdated: true,
processingTimeMs: 50,
}),
})),
}));
// Import functions after mocking
import {
calculateMatchScore,
createWaypointLink,
detectWikiLinkPattern,
extractTitle,
filterMemoriesByQuery,
insertWikiLinkWithWaypoint,
} from '../../components/hud/WikiLinkAutocomplete';
// ============================================================================
// Test Data
// ============================================================================
const createMockMemory = (
id: string,
content: string,
metadata: Partial<Memory['metadata']> = {}
): Memory => ({
id,
content,
createdAt: '2024-01-01T00:00:00Z',
lastAccessed: '2024-01-01T00:00:00Z',
accessCount: 1,
salience: 0.5,
strength: 0.5,
userId: 'test-user',
sessionId: 'test-session',
primarySector: 'semantic',
metadata: {
keywords: [],
tags: [],
...metadata,
},
});
const mockMemories: Memory[] = [
createMockMemory('mem-1', 'Introduction to machine learning', {
keywords: ['ML', 'AI'],
tags: ['tech', 'learning'],
category: 'education',
}),
createMockMemory('mem-2', 'React component patterns', {
keywords: ['React', 'patterns'],
tags: ['frontend', 'development'],
category: 'programming',
}),
createMockMemory('mem-3', 'Database optimization techniques', {
keywords: ['SQL', 'performance'],
tags: ['backend', 'database'],
category: 'programming',
}),
createMockMemory('mem-4', 'Project management best practices', {
keywords: ['agile', 'scrum'],
tags: ['management'],
category: 'business',
}),
createMockMemory('mem-5', 'Learning TypeScript fundamentals', {
keywords: ['TypeScript', 'JavaScript'],
tags: ['frontend', 'learning'],
category: 'programming',
}),
];
// ============================================================================
// detectWikiLinkPattern Tests
// ============================================================================
describe('detectWikiLinkPattern', () => {
it('should detect [[ at cursor position', () => {
const result = detectWikiLinkPattern('Hello [[', 8);
expect(result).not.toBeNull();
expect(result?.query).toBe('');
expect(result?.startIndex).toBe(6);
});
it('should extract query text after [[', () => {
const result = detectWikiLinkPattern('Hello [[machine', 15);
expect(result).not.toBeNull();
expect(result?.query).toBe('machine');
expect(result?.startIndex).toBe(6);
});
it('should return null when no [[ is present', () => {
const result = detectWikiLinkPattern('Hello world', 11);
expect(result).toBeNull();
});
it('should return null when [[ is closed with ]]', () => {
const result = detectWikiLinkPattern('Hello [[link]] world', 20);
expect(result).toBeNull();
});
it('should detect [[ even with text before it', () => {
const result = detectWikiLinkPattern('Some text before [[query', 24);
expect(result).not.toBeNull();
expect(result?.query).toBe('query');
expect(result?.startIndex).toBe(17);
});
it('should handle multiple [[ and return the last unclosed one', () => {
const result = detectWikiLinkPattern('[[first]] and [[second', 22);
expect(result).not.toBeNull();
expect(result?.query).toBe('second');
expect(result?.startIndex).toBe(14);
});
it('should handle cursor in the middle of text', () => {
const result = detectWikiLinkPattern('Hello [[mac world', 11);
expect(result).not.toBeNull();
expect(result?.query).toBe('mac');
});
it('should return null when cursor is before [[', () => {
const result = detectWikiLinkPattern('Hello [[machine', 5);
expect(result).toBeNull();
});
it('should handle empty string', () => {
const result = detectWikiLinkPattern('', 0);
expect(result).toBeNull();
});
it('should handle [[ at the start of text', () => {
const result = detectWikiLinkPattern('[[query', 7);
expect(result).not.toBeNull();
expect(result?.query).toBe('query');
expect(result?.startIndex).toBe(0);
});
});
// ============================================================================
// extractTitle Tests
// ============================================================================
describe('extractTitle', () => {
it('should return first line of content', () => {
const result = extractTitle('First line\nSecond line\nThird line');
expect(result).toBe('First line');
});
it('should return full content if no newlines and under 50 chars', () => {
const result = extractTitle('Short content');
expect(result).toBe('Short content');
});
it('should truncate to 50 characters if first line is too long', () => {
const longLine =
'This is a very long first line that exceeds fifty characters and should be truncated';
const result = extractTitle(longLine);
expect(result.length).toBeLessThanOrEqual(50);
expect(result).toBe('This is a very long first line that exceeds fifty');
});
it('should trim whitespace from result', () => {
const result = extractTitle(' Padded content \nSecond line');
expect(result).toBe('Padded content');
});
it('should handle empty content', () => {
const result = extractTitle('');
expect(result).toBe('');
});
it('should handle content with only newlines', () => {
const result = extractTitle('\n\n\n');
expect(result).toBe('');
});
});
// ============================================================================
// calculateMatchScore Tests
// ============================================================================
describe('calculateMatchScore', () => {
const createTestMemory = (content: string, metadata: Partial<Memory['metadata']> = {}): Memory =>
createMockMemory('test-mem', content, metadata);
it('should return high score for exact title match', () => {
const memory = createTestMemory('React patterns');
const score = calculateMatchScore(memory, 'React patterns');
expect(score).toBeGreaterThanOrEqual(100); // Exact title match = 100 points
});
it('should return high score for title starts with query', () => {
const memory = createTestMemory('React patterns for beginners');
const score = calculateMatchScore(memory, 'React');
expect(score).toBeGreaterThanOrEqual(80); // Title starts with = 80 points
});
it('should return medium score for title contains query', () => {
const memory = createTestMemory('Advanced React patterns');
const score = calculateMatchScore(memory, 'React');
expect(score).toBeGreaterThanOrEqual(60); // Title contains = 60 points
});
it('should return score for content match', () => {
const memory = createTestMemory('First line\nThis content mentions React somewhere');
const score = calculateMatchScore(memory, 'React');
expect(score).toBeGreaterThan(0);
expect(score).toBeLessThan(60); // Content match is lower than title match
});
it('should return score for keyword match', () => {
const memory = createTestMemory('Some content', { keywords: ['React', 'TypeScript'] });
const score = calculateMatchScore(memory, 'React');
expect(score).toBe(20); // Keywords match = 20 points
});
it('should return score for tag match', () => {
const memory = createTestMemory('Some content', { tags: ['frontend', 'react'] });
const score = calculateMatchScore(memory, 'react');
expect(score).toBe(15); // Tags match = 15 points
});
it('should return score for category match', () => {
const memory = createTestMemory('Some content', { category: 'programming' });
const score = calculateMatchScore(memory, 'programming');
expect(score).toBe(10); // Category match = 10 points
});
it('should accumulate scores from multiple matches', () => {
const memory = createTestMemory('React patterns', {
keywords: ['React'],
tags: ['react'],
});
const score = calculateMatchScore(memory, 'React');
// Title starts with (80) + keywords (20) + tags (15) = 115
expect(score).toBeGreaterThanOrEqual(115);
});
it('should be case-insensitive', () => {
const memory = createTestMemory('REACT PATTERNS');
const score = calculateMatchScore(memory, 'react');
expect(score).toBeGreaterThanOrEqual(80); // Title starts with
});
it('should return zero for no match', () => {
const memory = createTestMemory('Some content about databases');
const score = calculateMatchScore(memory, 'react');
expect(score).toBe(0);
});
it('should return recency-based score for empty query', () => {
// Create a memory with a recent date
const recentMemory: Memory = {
...createTestMemory('Some content'),
createdAt: new Date().toISOString(), // Today's date
};
const score = calculateMatchScore(recentMemory, '');
expect(score).toBeGreaterThan(0);
expect(score).toBeLessThanOrEqual(100);
});
});
// ============================================================================
// filterMemoriesByQuery Tests
// ============================================================================
describe('filterMemoriesByQuery', () => {
it('should return all memories for empty query (sorted by recency)', () => {
const result = filterMemoriesByQuery(mockMemories, '');
expect(result.length).toBeLessThanOrEqual(10);
expect(result.length).toBe(mockMemories.length);
});
it('should filter by content match', () => {
const result = filterMemoriesByQuery(mockMemories, 'machine');
expect(result.length).toBe(1);
expect(result[0]?.id).toBe('mem-1');
});
it('should be case-insensitive', () => {
const result = filterMemoriesByQuery(mockMemories, 'MACHINE');
expect(result.length).toBe(1);
expect(result[0]?.id).toBe('mem-1');
});
it('should filter by keyword match', () => {
const result = filterMemoriesByQuery(mockMemories, 'React');
expect(result.length).toBe(1);
expect(result[0]?.id).toBe('mem-2');
});
it('should filter by tag match', () => {
const result = filterMemoriesByQuery(mockMemories, 'frontend');
expect(result.length).toBe(2);
expect(result.map((m) => m.id)).toContain('mem-2');
expect(result.map((m) => m.id)).toContain('mem-5');
});
it('should filter by category match', () => {
const result = filterMemoriesByQuery(mockMemories, 'programming');
expect(result.length).toBe(3);
});
it('should return empty array when no matches', () => {
const result = filterMemoriesByQuery(mockMemories, 'nonexistent');
expect(result.length).toBe(0);
});
it('should handle partial matches', () => {
const result = filterMemoriesByQuery(mockMemories, 'learn');
expect(result.length).toBe(2);
expect(result.map((m) => m.id)).toContain('mem-1');
expect(result.map((m) => m.id)).toContain('mem-5');
});
it('should trim whitespace from query', () => {
const result = filterMemoriesByQuery(mockMemories, ' machine ');
expect(result.length).toBe(1);
expect(result[0]?.id).toBe('mem-1');
});
it('should handle memories with empty metadata', () => {
const memoriesWithEmptyMetadata = [createMockMemory('mem-empty', 'Test content', {})];
const result = filterMemoriesByQuery(memoriesWithEmptyMetadata, 'Test');
expect(result.length).toBe(1);
});
it('should handle memories with undefined metadata arrays', () => {
const memoriesWithUndefined: Memory[] = [
{
...createMockMemory('mem-undef', 'Test content', {}),
metadata: {},
},
];
const result = filterMemoriesByQuery(memoriesWithUndefined, 'Test');
expect(result.length).toBe(1);
});
it('should prioritize title matches over content matches', () => {
const memoriesWithTitles = [
createMockMemory('mem-title', 'React patterns for beginners', {}),
createMockMemory('mem-content', 'This article discusses various React patterns', {}),
];
const result = filterMemoriesByQuery(memoriesWithTitles, 'React');
expect(result.length).toBe(2);
// Title starts with "React" should be first
expect(result[0]?.id).toBe('mem-title');
});
it('should sort results by match score (best matches first)', () => {
const memoriesForScoring = [
createMockMemory('mem-low', 'Some content about databases', {
tags: ['programming'],
}),
createMockMemory('mem-high', 'Programming best practices', {
category: 'programming',
}),
];
const result = filterMemoriesByQuery(memoriesForScoring, 'programming');
expect(result.length).toBe(2);
// "Programming" in title should score higher than just in tags/category
expect(result[0]?.id).toBe('mem-high');
});
});
// ============================================================================
// createWaypointLink Tests
// ============================================================================
describe('createWaypointLink', () => {
it('should return error when source memory ID is missing', async () => {
const result = await createWaypointLink({
sourceMemoryId: '',
targetMemoryId: 'target-mem',
userId: 'user-1',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Source and target memory IDs are required');
});
it('should return error when target memory ID is missing', async () => {
const result = await createWaypointLink({
sourceMemoryId: 'source-mem',
targetMemoryId: '',
userId: 'user-1',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Source and target memory IDs are required');
});
it('should return error when trying to link memory to itself', async () => {
const result = await createWaypointLink({
sourceMemoryId: 'same-mem',
targetMemoryId: 'same-mem',
userId: 'user-1',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Cannot create a link from a memory to itself');
});
it('should use semantic as default link type', async () => {
const result = await createWaypointLink({
sourceMemoryId: 'source-mem',
targetMemoryId: 'target-mem',
userId: 'user-1',
});
expect(result.linkType).toBe('semantic');
});
it('should use provided link type', async () => {
const result = await createWaypointLink({
sourceMemoryId: 'source-mem',
targetMemoryId: 'target-mem',
userId: 'user-1',
linkType: 'causal',
});
expect(result.linkType).toBe('causal');
});
it('should return source and target IDs in result', async () => {
const result = await createWaypointLink({
sourceMemoryId: 'source-mem',
targetMemoryId: 'target-mem',
userId: 'user-1',
});
expect(result.sourceId).toBe('source-mem');
expect(result.targetId).toBe('target-mem');
});
});
// ============================================================================
// insertWikiLinkWithWaypoint Tests
// ============================================================================
describe('insertWikiLinkWithWaypoint', () => {
const testMemory: Memory = {
id: 'mem-123',
content: 'This is a test memory with some content',
createdAt: '2024-01-01T00:00:00Z',
lastAccessed: '2024-01-01T00:00:00Z',
accessCount: 1,
salience: 0.5,
strength: 0.5,
userId: 'test-user',
sessionId: 'test-session',
primarySector: 'semantic',
metadata: {
keywords: [],
tags: [],
},
};
it('should insert link text at correct position', async () => {
const result = await insertWikiLinkWithWaypoint('Hello [[', 6, 8, testMemory);
expect(result.newText).toContain('[[mem-123|');
expect(result.newText).toContain(']]');
expect(result.newText.startsWith('Hello ')).toBe(true);
});
it('should return correct link text format', async () => {
const result = await insertWikiLinkWithWaypoint('Hello [[', 6, 8, testMemory);
expect(result.linkText).toMatch(/^\[\[mem-123\|.+\]\]$/);
});
it('should return correct new cursor position', async () => {
const result = await insertWikiLinkWithWaypoint('Hello [[', 6, 8, testMemory);
// Cursor should be after the inserted link
expect(result.newCursorPosition).toBe(6 + result.linkText.length);
});
it('should preserve text after cursor', async () => {
const result = await insertWikiLinkWithWaypoint('Hello [[ world', 6, 8, testMemory);
expect(result.newText).toContain(' world');
});
it('should not create waypoint when options not provided', async () => {
const result = await insertWikiLinkWithWaypoint('Hello [[', 6, 8, testMemory);
expect(result.waypointResult).toBeUndefined();
});
it('should not create waypoint when createWaypoint is false', async () => {
const result = await insertWikiLinkWithWaypoint('Hello [[', 6, 8, testMemory, {
sourceMemoryId: 'source-mem',
userId: 'user-1',
createWaypoint: false,
});
expect(result.waypointResult).toBeUndefined();
});
it('should not create waypoint when sourceMemoryId is missing', async () => {
const result = await insertWikiLinkWithWaypoint('Hello [[', 6, 8, testMemory, {
userId: 'user-1',
createWaypoint: true,
});
expect(result.waypointResult).toBeUndefined();
});
it('should not create waypoint when userId is missing', async () => {
const result = await insertWikiLinkWithWaypoint('Hello [[', 6, 8, testMemory, {
sourceMemoryId: 'source-mem',
createWaypoint: true,
});
expect(result.waypointResult).toBeUndefined();
});
it('should truncate long content in preview', async () => {
const longContentMemory: Memory = {
...testMemory,
content:
'This is a very long memory content that should be truncated in the preview to keep the link text manageable',
};
const result = await insertWikiLinkWithWaypoint('Hello [[', 6, 8, longContentMemory);
// Preview should be truncated (30 chars max + ...)
expect(result.linkText.length).toBeLessThan(50);
expect(result.linkText).toContain('...');
});
it('should handle empty text before link', async () => {
const result = await insertWikiLinkWithWaypoint('[[', 0, 2, testMemory);
expect(result.newText.startsWith('[[')).toBe(true);
expect(result.newCursorPosition).toBe(result.linkText.length);
});
it('should handle query text after [[', async () => {
const result = await insertWikiLinkWithWaypoint('Hello [[test query', 6, 18, testMemory);
// The query text should be replaced with the link
expect(result.newText).not.toContain('test query');
expect(result.newText).toContain('[[mem-123|');
});
});