1071 lines
30 KiB
TypeScript
1071 lines
30 KiB
TypeScript
import { SearchUtils, SearchOptions } from '../src/utils/search-utils';
|
|
import { createMockVaultAdapter, createMockTFile } from './__mocks__/adapters';
|
|
import { IVaultAdapter } from '../src/adapters/interfaces';
|
|
|
|
describe('SearchUtils', () => {
|
|
describe('search()', () => {
|
|
describe('basic literal search', () => {
|
|
it('should find matches in file content', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note1.md'),
|
|
createMockTFile('note2.md')
|
|
]),
|
|
read: jest.fn()
|
|
.mockResolvedValueOnce('This is a test note\nwith test content')
|
|
.mockResolvedValueOnce('Another note without the word')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
isRegex: false,
|
|
caseSensitive: false
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches.length).toBeGreaterThan(0);
|
|
expect(result.stats.filesSearched).toBe(2);
|
|
expect(result.stats.filesWithMatches).toBe(1);
|
|
expect(result.stats.totalMatches).toBe(2); // 2 matches in first file
|
|
});
|
|
|
|
it('should escape special regex characters in literal search', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('Cost is $5.00 or [maybe] (more)')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: '$5.00',
|
|
isRegex: false
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches).toHaveLength(1);
|
|
expect(result.matches[0].snippet).toContain('$5.00');
|
|
});
|
|
});
|
|
|
|
describe('regex search', () => {
|
|
it('should support regex patterns', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('test123\ntest456\nabc789')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test\\d+',
|
|
isRegex: true
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches).toHaveLength(2);
|
|
expect(result.matches[0].snippet).toContain('test123');
|
|
expect(result.matches[1].snippet).toContain('test456');
|
|
});
|
|
|
|
it('should support case-sensitive regex search', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('Test TEST test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
isRegex: true,
|
|
caseSensitive: true
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches).toHaveLength(1);
|
|
expect(result.matches[0].snippet).toBe('Test TEST test');
|
|
});
|
|
|
|
it('should throw error for invalid regex pattern', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([])
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: '[invalid(',
|
|
isRegex: true
|
|
};
|
|
|
|
await expect(SearchUtils.search(mockVault, options)).rejects.toThrow('Invalid regex pattern');
|
|
});
|
|
|
|
it('should handle zero-width regex matches without infinite loop', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('abc')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: '\\b', // Zero-width word boundary
|
|
isRegex: true,
|
|
maxResults: 10
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
// Should not hang, should return matches
|
|
expect(result.matches.length).toBeGreaterThan(0);
|
|
expect(result.matches.length).toBeLessThanOrEqual(10);
|
|
});
|
|
});
|
|
|
|
describe('case sensitivity', () => {
|
|
it('should be case insensitive by default', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('Test TEST tEsT')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
caseSensitive: false
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches).toHaveLength(3);
|
|
});
|
|
|
|
it('should support case sensitive search', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('Test TEST test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
caseSensitive: true
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches).toHaveLength(1);
|
|
expect(result.matches[0].snippet).toBe('Test TEST test');
|
|
});
|
|
});
|
|
|
|
describe('folder filtering', () => {
|
|
it('should filter files by folder path', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('folder/note1.md'),
|
|
createMockTFile('folder/subfolder/note2.md'),
|
|
createMockTFile('other/note3.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('test content')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
folder: 'folder'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.filesSearched).toBe(2);
|
|
expect(mockVault.read).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should handle folder path with trailing slash', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('folder/note1.md'),
|
|
createMockTFile('other/note2.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
folder: 'folder/'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.filesSearched).toBe(1);
|
|
});
|
|
|
|
it('should match exact folder path', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('folder.md'), // This has path equal to "folder"
|
|
createMockTFile('folder/note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
folder: 'folder.md'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.filesSearched).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('glob filtering', () => {
|
|
it('should filter files by include patterns', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('test.md'),
|
|
createMockTFile('note.md'),
|
|
createMockTFile('testing.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('content')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'content',
|
|
includes: ['test*.md']
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.filesSearched).toBe(2); // test.md and testing.md
|
|
});
|
|
|
|
it('should filter files by exclude patterns', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md'),
|
|
createMockTFile('draft.md'),
|
|
createMockTFile('published.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('content')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'content',
|
|
excludes: ['draft*.md']
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.filesSearched).toBe(2); // note.md and published.md
|
|
});
|
|
|
|
it('should combine includes and excludes', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('docs/draft.md'),
|
|
createMockTFile('docs/final.md'),
|
|
createMockTFile('notes/test.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('content')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'content',
|
|
includes: ['docs/*.md'],
|
|
excludes: ['**/draft*.md']
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.filesSearched).toBe(1); // Only docs/final.md
|
|
});
|
|
});
|
|
|
|
describe('snippet extraction', () => {
|
|
it('should return snippets by default', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('This is a test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches[0].snippet).toBe('This is a test');
|
|
expect(result.matches[0].matchRanges).toEqual([{ start: 10, end: 14 }]);
|
|
});
|
|
|
|
it('should truncate long lines to snippet length', async () => {
|
|
const longLine = 'a'.repeat(200) + 'test' + 'b'.repeat(200);
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(longLine)
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
snippetLength: 100
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches[0].snippet.length).toBe(100);
|
|
expect(result.matches[0].snippet).toContain('test');
|
|
});
|
|
|
|
it('should center match in snippet when possible', async () => {
|
|
const longLine = 'a'.repeat(100) + 'test' + 'b'.repeat(100);
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(longLine)
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
snippetLength: 50
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
const snippet = result.matches[0].snippet;
|
|
expect(snippet.length).toBe(50);
|
|
expect(snippet).toContain('test');
|
|
// Match should be roughly centered
|
|
const matchStart = result.matches[0].matchRanges[0].start;
|
|
expect(matchStart).toBeGreaterThan(15);
|
|
expect(matchStart).toBeLessThan(35);
|
|
});
|
|
|
|
it('should adjust snippet when match is at end of line', async () => {
|
|
const longLine = 'a'.repeat(200) + 'test';
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(longLine)
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
snippetLength: 100
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches[0].snippet.length).toBe(100);
|
|
expect(result.matches[0].snippet).toContain('test');
|
|
// Should show end of line
|
|
expect(result.matches[0].snippet.endsWith('test')).toBe(true);
|
|
});
|
|
|
|
it('should disable snippets when returnSnippets is false', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('This is a test line')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
returnSnippets: false
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
// Still returns the full line as snippet when returnSnippets is false
|
|
expect(result.matches[0].snippet).toBe('This is a test line');
|
|
});
|
|
});
|
|
|
|
describe('filename matching', () => {
|
|
it('should search in filenames', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('test-note.md'),
|
|
createMockTFile('another.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('no match')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
const filenameMatches = result.matches.filter(m => m.line === 0);
|
|
expect(filenameMatches).toHaveLength(1);
|
|
expect(filenameMatches[0].path).toBe('test-note.md');
|
|
expect(filenameMatches[0].snippet).toBe('test-note');
|
|
expect(filenameMatches[0].column).toBe(1); // 1-indexed
|
|
});
|
|
|
|
it('should mark filename matches with line 0', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('testing.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('content')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
const filenameMatch = result.matches.find(m => m.line === 0);
|
|
expect(filenameMatch).toBeDefined();
|
|
expect(filenameMatch!.line).toBe(0);
|
|
});
|
|
|
|
it('should handle zero-width matches in filenames', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('test-note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('content')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: '\\b',
|
|
isRegex: true,
|
|
maxResults: 5
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
const filenameMatches = result.matches.filter(m => m.line === 0);
|
|
expect(filenameMatches.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('maxResults limiting', () => {
|
|
it('should limit total matches to maxResults', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note1.md'),
|
|
createMockTFile('note2.md'),
|
|
createMockTFile('note3.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('test test test test test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
maxResults: 5
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches.length).toBeLessThanOrEqual(5);
|
|
});
|
|
|
|
it('should stop searching files once maxResults is reached', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note1.md'),
|
|
createMockTFile('note2.md'),
|
|
createMockTFile('note3.md')
|
|
]),
|
|
read: jest.fn()
|
|
.mockResolvedValueOnce('test test test')
|
|
.mockResolvedValueOnce('test test test')
|
|
.mockResolvedValueOnce('test test test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
maxResults: 3
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches).toHaveLength(3);
|
|
// Should have stopped after first file
|
|
expect(mockVault.read).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should respect default maxResults of 100', async () => {
|
|
const content = 'test '.repeat(200); // 200 matches
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
// maxResults defaults to 100
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.matches.length).toBeLessThanOrEqual(100);
|
|
});
|
|
|
|
it('should stop processing lines within file when maxMatches reached', async () => {
|
|
// Create a file with many lines, each containing matches
|
|
const lines = Array(50).fill('test test test').join('\n');
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(lines)
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test',
|
|
maxResults: 5
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
// Should have stopped processing lines early
|
|
expect(result.matches.length).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('line and column tracking', () => {
|
|
it('should track line numbers (1-indexed)', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('line 1\nline 2 test\nline 3')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
const contentMatch = result.matches.find(m => m.line > 0);
|
|
expect(contentMatch!.line).toBe(2);
|
|
});
|
|
|
|
it('should track column positions (1-indexed)', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('prefix test suffix')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
const contentMatch = result.matches.find(m => m.line > 0);
|
|
expect(contentMatch!.column).toBe(8); // 1-indexed, 'test' starts at position 7 (0-indexed)
|
|
});
|
|
|
|
it('should find multiple matches on same line', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('test and test and test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
const contentMatches = result.matches.filter(m => m.line > 0);
|
|
expect(contentMatches.length).toBe(3);
|
|
expect(contentMatches[0].column).toBe(1);
|
|
expect(contentMatches[1].column).toBe(10);
|
|
expect(contentMatches[2].column).toBe(19);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should skip files that cannot be read', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('good.md'),
|
|
createMockTFile('bad.md'),
|
|
createMockTFile('good2.md')
|
|
]),
|
|
read: jest.fn()
|
|
.mockResolvedValueOnce('test content')
|
|
.mockRejectedValueOnce(new Error('Permission denied'))
|
|
.mockResolvedValueOnce('test content')
|
|
});
|
|
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.filesSearched).toBe(3);
|
|
expect(result.stats.filesWithMatches).toBe(2); // Only good files
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed to search file bad.md'),
|
|
expect.any(Error)
|
|
);
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('statistics', () => {
|
|
it('should track files searched', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note1.md'),
|
|
createMockTFile('note2.md'),
|
|
createMockTFile('note3.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('some content')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'nonexistent'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.filesSearched).toBe(3);
|
|
});
|
|
|
|
it('should track files with matches', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('match1.md'),
|
|
createMockTFile('nomatch.md'),
|
|
createMockTFile('match2.md')
|
|
]),
|
|
read: jest.fn()
|
|
.mockResolvedValueOnce('test test')
|
|
.mockResolvedValueOnce('nothing here')
|
|
.mockResolvedValueOnce('test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.filesWithMatches).toBe(2);
|
|
});
|
|
|
|
it('should track total matches', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('test test test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
expect(result.stats.totalMatches).toBe(3);
|
|
});
|
|
|
|
it('should count file only once even with multiple matches', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('test-file.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue('test test test')
|
|
});
|
|
|
|
const options: SearchOptions = {
|
|
query: 'test'
|
|
};
|
|
|
|
const result = await SearchUtils.search(mockVault, options);
|
|
|
|
// File has matches in filename AND content
|
|
expect(result.stats.totalMatches).toBeGreaterThan(1);
|
|
// But only counted once in filesWithMatches
|
|
expect(result.stats.filesWithMatches).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('searchWaypoints()', () => {
|
|
describe('finding waypoint blocks', () => {
|
|
it('should find waypoint markers', async () => {
|
|
const content = `# Folder
|
|
%% Begin Waypoint %%
|
|
- [[Note 1]]
|
|
- [[Note 2]]
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('folder/folder.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('folder/folder.md');
|
|
expect(result[0].line).toBe(2); // Line where waypoint starts (1-indexed)
|
|
});
|
|
|
|
it('should extract waypoint content', async () => {
|
|
const content = `# Folder
|
|
%% Begin Waypoint %%
|
|
- [[Note 1]]
|
|
- [[Note 2]]
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('folder.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result[0].content).toBe('- [[Note 1]]\n- [[Note 2]]');
|
|
});
|
|
|
|
it('should extract links from waypoint content', async () => {
|
|
const content = `%% Begin Waypoint %%
|
|
- [[Note 1]]
|
|
- [[Note 2]]
|
|
- [[Folder/Subfolder]]
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('folder.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result[0].links).toEqual([
|
|
'Note 1',
|
|
'Note 2',
|
|
'Folder/Subfolder'
|
|
]);
|
|
});
|
|
|
|
it('should track waypoint range', async () => {
|
|
const content = `Line 1
|
|
Line 2
|
|
%% Begin Waypoint %%
|
|
Content
|
|
More content
|
|
%% End Waypoint %%
|
|
Line 7`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result[0].waypointRange).toEqual({
|
|
start: 3, // Line after "Begin Waypoint" (1-indexed)
|
|
end: 6 // Line of "End Waypoint" (1-indexed)
|
|
});
|
|
});
|
|
|
|
it('should find multiple waypoints in same file', async () => {
|
|
const content = `%% Begin Waypoint %%
|
|
- [[A]]
|
|
%% End Waypoint %%
|
|
|
|
Other content
|
|
|
|
%% Begin Waypoint %%
|
|
- [[B]]
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].links).toEqual(['A']);
|
|
expect(result[1].links).toEqual(['B']);
|
|
});
|
|
|
|
it('should handle empty waypoints', async () => {
|
|
const content = `%% Begin Waypoint %%
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].content).toBe('');
|
|
expect(result[0].links).toEqual([]);
|
|
});
|
|
|
|
it('should ignore unclosed waypoints', async () => {
|
|
const content = `%% Begin Waypoint %%
|
|
- [[Note]]
|
|
No closing marker`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it('should handle waypoint without begin marker', async () => {
|
|
const content = `Some content
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('folder filtering', () => {
|
|
it('should filter by folder path', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('folder1/note.md'),
|
|
createMockTFile('folder2/note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(`%% Begin Waypoint %%
|
|
- [[Test]]
|
|
%% End Waypoint %%`)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault, 'folder1');
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('folder1/note.md');
|
|
});
|
|
|
|
it('should handle folder path with trailing slash', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('folder/note.md'),
|
|
createMockTFile('other/note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(`%% Begin Waypoint %%
|
|
- [[Test]]
|
|
%% End Waypoint %%`)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault, 'folder/');
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('folder/note.md');
|
|
});
|
|
|
|
it('should search all files when no folder specified', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('a/note.md'),
|
|
createMockTFile('b/note.md'),
|
|
createMockTFile('c/note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(`%% Begin Waypoint %%
|
|
[[Test]]
|
|
%% End Waypoint %%`)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe('link extraction', () => {
|
|
it('should extract multiple links', async () => {
|
|
const content = `%% Begin Waypoint %%
|
|
- [[Link 1]]
|
|
- [[Link 2]]
|
|
- [[Link 3]]
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result[0].links).toEqual(['Link 1', 'Link 2', 'Link 3']);
|
|
});
|
|
|
|
it('should handle links without list markers', async () => {
|
|
const content = `%% Begin Waypoint %%
|
|
[[Link 1]] [[Link 2]]
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result[0].links).toEqual(['Link 1', 'Link 2']);
|
|
});
|
|
|
|
it('should handle links with aliases', async () => {
|
|
const content = `%% Begin Waypoint %%
|
|
- [[Note|Alias]]
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
// Should extract the full link text including alias
|
|
expect(result[0].links).toEqual(['Note|Alias']);
|
|
});
|
|
|
|
it('should handle waypoints with no links', async () => {
|
|
const content = `%% Begin Waypoint %%
|
|
Just some text
|
|
%% End Waypoint %%`;
|
|
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note.md')
|
|
]),
|
|
read: jest.fn().mockResolvedValue(content)
|
|
});
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result[0].links).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should skip files that cannot be read', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('good.md'),
|
|
createMockTFile('bad.md')
|
|
]),
|
|
read: jest.fn()
|
|
.mockResolvedValueOnce(`%% Begin Waypoint %%
|
|
[[Test]]
|
|
%% End Waypoint %%`)
|
|
.mockRejectedValueOnce(new Error('Read error'))
|
|
});
|
|
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('good.md');
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed to search waypoints in bad.md'),
|
|
expect.any(Error)
|
|
);
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it('should continue searching after encountering errors', async () => {
|
|
const mockVault = createMockVaultAdapter({
|
|
getMarkdownFiles: jest.fn().mockReturnValue([
|
|
createMockTFile('note1.md'),
|
|
createMockTFile('note2.md'),
|
|
createMockTFile('note3.md')
|
|
]),
|
|
read: jest.fn()
|
|
.mockResolvedValueOnce(`%% Begin Waypoint %%
|
|
[[A]]
|
|
%% End Waypoint %%`)
|
|
.mockRejectedValueOnce(new Error('Error'))
|
|
.mockResolvedValueOnce(`%% Begin Waypoint %%
|
|
[[B]]
|
|
%% End Waypoint %%`)
|
|
});
|
|
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
|
|
const result = await SearchUtils.searchWaypoints(mockVault);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].links).toEqual(['A']);
|
|
expect(result[1].links).toEqual(['B']);
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
});
|
|
});
|
|
});
|