andy-stack_vaultkeeper-ai/__tests__/Services/VaultCacheService.test.ts
Andrew Beal 28772e7d0e feat: implement centralized abort controller with enhanced cancellation UX
Introduce a new AbortService to centralize cancellation logic across all async operations, replacing scattered AbortSignal parameters with a unified singleton service. This improves maintainability and provides consistent cancellation behavior throughout the application.

Key changes:
- Add AbortService for centralized abort signal management with automatic cleanup
- Refactor all AI providers (Claude, Gemini, OpenAI) to use AbortService instead of passing AbortSignal parameters
- Update streaming operations to use centralized abort handling
- Add CancellationIndicator component to show visual feedback during operation cancellation
- Rename ChatAreaThought to ThoughtIndicator for better semantic clarity
- Add Environment enum for consistent environment detection
- Enhance ChatService lifecycle with proper cancellation state management
- Remove scattered abort-related UI selectors and error messages in favor of dedicated indicator
- Add safeContinue() factory method to ConversationContent for internal continuations
- Update all tests to reflect new abort handling architecture

This change simplifies the API surface by removing AbortSignal parameters from method signatures while improving the user experience with clearer cancellation feedback.
2025-12-04 23:04:20 +00:00

997 lines
31 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VaultCacheService } from '../../Services/VaultCacheService';
import { VaultService } from '../../Services/VaultService';
import { TFile, TFolder } from 'obsidian';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { FileEvent } from '../../Enums/FileEvent';
// Mock getAllTags from obsidian
vi.mock('obsidian', async () => {
const actual = await vi.importActual('obsidian');
return {
...actual,
Component: class Component {
public load() {}
public onload() {}
public unload() {}
public onunload() {}
public addChild<T extends Component>(component: T): T {
return component;
}
public removeChild<T extends Component>(component: T): T {
return component;
}
public register(_cb: () => any): void {}
public registerEvent(_eventRef: any): void {}
public registerDomEvent(_el: any, _type: any, _callback: any, _options?: any): void {}
public registerInterval(id: number): number {
return id;
}
},
getAllTags: vi.fn((metadata: any) => {
if (!metadata || !metadata.tags) return null;
return metadata.tags.map((t: any) => t.tag);
})
};
});
// Mock fuzzysort
vi.mock('fuzzysort', () => {
const mockPrepare = vi.fn((str: string) => ({ target: str }));
const mockGo = vi.fn((input: string, targets: any[], options: any) => {
// Simple mock implementation that filters based on includes
const results = targets
.filter((t: any) => {
const searchIn = t.tag || t.file?.basename || t.folder?.path || '';
return searchIn.toLowerCase().includes(input.toLowerCase());
})
.slice(0, options.limit || 10)
.map((t: any) => ({
obj: t,
score: 0
}));
return results;
});
return {
default: {
go: mockGo,
prepare: mockPrepare
},
go: mockGo,
prepare: mockPrepare
};
});
/**
* INTEGRATION TESTS
*
* These tests use real dependencies where possible and mock only the Obsidian API.
*/
// Create mock instances
const mockMetadataCache = {
getCache: vi.fn(),
on: vi.fn()
};
const mockVault = {
getFiles: vi.fn(),
getAllFolders: vi.fn(),
on: vi.fn()
};
const mockPlugin = {
app: {
vault: mockVault,
metadataCache: mockMetadataCache
},
settings: {
exclusions: []
},
registerEvent: vi.fn()
};
// Helper to create mock TFile
function createMockFile(path: string): TFile {
const name = path.split('/').pop() || '';
const basename = name.split('.')[0];
const file = new TFile();
file.path = path;
file.name = name;
file.basename = basename;
file.extension = 'md';
file.stat = { ctime: Date.now(), mtime: Date.now(), size: 100 };
file.parent = null;
file.vault = mockVault as any;
return file;
}
// Helper to create mock TFolder
function createMockFolder(path: string): TFolder {
const name = path.split('/').pop() || '';
const folder = new TFolder();
folder.path = path;
folder.name = name;
folder.children = [];
folder.parent = null;
folder.vault = mockVault as any;
return folder;
}
describe('VaultCacheService - Integration Tests', () => {
let vaultCacheService: VaultCacheService;
let vaultService: VaultService;
let fileEventHandler: any;
beforeEach(() => {
// Clear singleton registry to prevent memory leaks
DeregisterAllServices();
// Reset all mocks
vi.clearAllMocks();
// Capture file event handler
fileEventHandler = null;
// Mock VaultService to capture the event handler
const mockVaultService = {
registerFileEvents: vi.fn((handler: any) => {
fileEventHandler = handler;
}),
listDirectoryContents: vi.fn(async () => []),
getAbstractFileByPath: vi.fn((path: string) => {
// By default, allow all folders (return a mock folder)
// This maintains existing test behavior
return createMockFolder(path);
}),
isExclusion: vi.fn((path: string, allowAccessToPluginRoot: boolean) => {
// By default, return false to allow all paths (not excluded)
// This maintains backward compatibility with existing tests
return false;
})
};
// Register dependencies
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin as any);
RegisterSingleton(Services.VaultService, mockVaultService as any);
// Create fresh instance
vaultCacheService = new VaultCacheService();
vaultService = mockVaultService as any;
});
describe('initialization', () => {
it('should register file event handlers on construction', () => {
expect(vaultService.registerFileEvents).toHaveBeenCalledOnce();
expect(fileEventHandler).toBeDefined();
});
it('should initialize cache with existing vault contents', () => {
const files = [
createMockFile('note1.md'),
createMockFile('note2.md')
];
const folders = [createMockFolder('folder')];
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag1' }, { tag: '#tag2' }]
});
const mockListVaultContents = vi.fn(() => [...files, ...folders]);
const mockRegisterEvents = vi.fn((handler: any) => {
fileEventHandler = handler;
});
let resolvedHandler: any = null;
const mockMetadataCacheWithHandler = {
getCache: mockMetadataCache.getCache,
on: vi.fn((event: string, handler: any) => {
if (event === 'resolved') {
resolvedHandler = handler;
}
})
};
const mockPluginWithMetadata = {
app: {
vault: mockVault,
metadataCache: mockMetadataCacheWithHandler
},
settings: {
exclusions: []
},
registerEvent: vi.fn()
};
const mockVaultServiceWithContent = {
registerFileEvents: mockRegisterEvents,
listDirectoryContents: mockListVaultContents
};
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPluginWithMetadata as any);
RegisterSingleton(Services.VaultService, mockVaultServiceWithContent as any);
// Create new instance to trigger initialization
new VaultCacheService();
// Trigger the resolved event
expect(resolvedHandler).toBeDefined();
resolvedHandler();
expect(mockListVaultContents).toHaveBeenCalled();
});
});
describe('file event handling - create', () => {
it('should cache file on create event', () => {
const mockFile = createMockFile('new.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Verify by triggering delete and checking orphaned tags
const orphanedTags = fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
// If the file was cached, deleting it should not cause issues
expect(orphanedTags).not.toThrow;
});
it('should extract and cache tags on file create', () => {
const mockFile = createMockFile('tagged.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#important' }, { tag: '#project' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Verify tags are cached by creating another file with same tags
const mockFile2 = createMockFile('tagged2.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#important' }]
});
fileEventHandler(FileEvent.Create, mockFile2, { oldPath: '' });
// Delete first file - #important should not be orphaned
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
// Both files should be tracked
expect(fileEventHandler).toBeDefined();
});
it('should handle files with no tags', () => {
const mockFile = createMockFile('untagged.md');
mockMetadataCache.getCache.mockReturnValue(null);
// Should not throw
expect(() => {
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
}).not.toThrow();
});
it('should handle files with empty tag array', () => {
const mockFile = createMockFile('notags.md');
mockMetadataCache.getCache.mockReturnValue({
tags: []
});
expect(() => {
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
}).not.toThrow();
});
});
describe('file event handling - modify', () => {
it('should update tags on file modify', () => {
const mockFile = createMockFile('modified.md');
// Create file with initial tags
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#old' }, { tag: '#keep' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Modify file with new tags
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#new' }, { tag: '#keep' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
// The implementation should handle tag updates correctly
expect(fileEventHandler).toBeDefined();
});
it('should remove orphaned tags on modify', () => {
const mockFile = createMockFile('file.md');
// Create with tags
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#unique' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Modify to remove all tags
mockMetadataCache.getCache.mockReturnValue({
tags: []
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
// #unique should be removed from cache
expect(fileEventHandler).toBeDefined();
});
it('should not remove shared tags on modify', () => {
const mockFile1 = createMockFile('file1.md');
const mockFile2 = createMockFile('file2.md');
// Create two files with shared tag
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#shared' }]
});
fileEventHandler(FileEvent.Create, mockFile1, { oldPath: '' });
fileEventHandler(FileEvent.Create, mockFile2, { oldPath: '' });
// Modify file1 to remove shared tag
mockMetadataCache.getCache.mockReturnValue({
tags: []
});
fileEventHandler(FileEvent.Modify, mockFile1, { oldPath: '' });
// #shared should still exist because file2 has it
expect(fileEventHandler).toBeDefined();
});
});
describe('file event handling - rename', () => {
it('should update file path on rename', () => {
const oldPath = 'old.md';
const mockFile = createMockFile('new.md');
// Create file
const oldFile = createMockFile(oldPath);
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
fileEventHandler(FileEvent.Create, oldFile, { oldPath: '' });
// Rename file
fileEventHandler(FileEvent.Rename, mockFile, { oldPath: oldPath });
// Verify rename worked by deleting with new path
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
expect(() => {
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
}).not.toThrow();
});
it('should preserve tags on rename', () => {
const oldPath = 'folder/old.md';
const newFile = createMockFile('folder/new.md');
// Create file with tags
const oldFile = createMockFile(oldPath);
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#important' }]
});
fileEventHandler(FileEvent.Create, oldFile, { oldPath: '' });
// Rename file
fileEventHandler(FileEvent.Rename, newFile, { oldPath: oldPath });
// Tags should still be associated with file
expect(fileEventHandler).toBeDefined();
});
});
describe('file event handling - delete', () => {
it('should remove file from cache on delete', () => {
const mockFile = createMockFile('deleted.md');
// Create file
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Delete file
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
// Deleting again should handle gracefully
expect(() => {
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
}).not.toThrow();
});
it('should remove orphaned tags on delete', () => {
const mockFile = createMockFile('file.md');
// Create file with unique tag
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#unique' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Delete file
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
// #unique should be removed from cache
expect(fileEventHandler).toBeDefined();
});
it('should not remove shared tags on delete', () => {
const mockFile1 = createMockFile('file1.md');
const mockFile2 = createMockFile('file2.md');
// Create two files with shared tag
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#shared' }]
});
fileEventHandler(FileEvent.Create, mockFile1, { oldPath: '' });
fileEventHandler(FileEvent.Create, mockFile2, { oldPath: '' });
// Delete one file
fileEventHandler(FileEvent.Delete, mockFile1, { oldPath: mockFile1.path });
// #shared should still exist because file2 has it
expect(fileEventHandler).toBeDefined();
});
});
describe('complex scenarios', () => {
it('should handle multiple files with overlapping tags', () => {
const file1 = createMockFile('file1.md');
const file2 = createMockFile('file2.md');
const file3 = createMockFile('file3.md');
// file1: tag1, tag2
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag1' }, { tag: '#tag2' }]
});
fileEventHandler(FileEvent.Create, file1, { oldPath: '' });
// file2: tag2, tag3
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag2' }, { tag: '#tag3' }]
});
fileEventHandler(FileEvent.Create, file2, { oldPath: '' });
// file3: tag3, tag4
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag3' }, { tag: '#tag4' }]
});
fileEventHandler(FileEvent.Create, file3, { oldPath: '' });
// Delete file2
fileEventHandler(FileEvent.Delete, file2, { oldPath: file2.path });
// tag2 should still exist (file1), tag3 should still exist (file3)
expect(fileEventHandler).toBeDefined();
});
it('should handle file lifecycle: create, modify, rename, delete', () => {
let mockFile = createMockFile('initial.md');
// Create
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#v1' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Modify
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#v2' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
// Rename
const renamedFile = createMockFile('renamed.md');
fileEventHandler(FileEvent.Rename, renamedFile, { oldPath: mockFile.path });
// Delete
fileEventHandler(FileEvent.Delete, renamedFile, { oldPath: renamedFile.path });
// Should complete without errors
expect(fileEventHandler).toBeDefined();
});
it('should handle files in different folders', () => {
const file1 = createMockFile('folder1/note.md');
const file2 = createMockFile('folder2/note.md');
const file3 = createMockFile('note.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
fileEventHandler(FileEvent.Create, file1, { oldPath: '' });
fileEventHandler(FileEvent.Create, file2, { oldPath: '' });
fileEventHandler(FileEvent.Create, file3, { oldPath: '' });
// All three files should be tracked separately
expect(fileEventHandler).toBeDefined();
});
it('should handle rapid tag changes', () => {
const mockFile = createMockFile('volatile.md');
// Create with tags
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#a' }, { tag: '#b' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Modify multiple times
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#b' }, { tag: '#c' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#c' }, { tag: '#d' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#d' }, { tag: '#e' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
// Should handle gracefully
expect(fileEventHandler).toBeDefined();
});
});
describe('edge cases', () => {
it('should handle folder events - create', () => {
const mockFolder = createMockFolder('new-folder');
expect(() => {
fileEventHandler(FileEvent.Create, mockFolder, { oldPath: '' });
}).not.toThrow();
});
it('should handle folder events - rename', () => {
const mockFolder = createMockFolder('folder');
fileEventHandler(FileEvent.Create, mockFolder, { oldPath: '' });
const renamedFolder = createMockFolder('renamed-folder');
expect(() => {
fileEventHandler(FileEvent.Rename, renamedFolder, { oldPath: mockFolder.path });
}).not.toThrow();
});
it('should handle folder events - delete', () => {
const mockFolder = createMockFolder('folder-to-delete');
fileEventHandler(FileEvent.Create, mockFolder, { oldPath: '' });
expect(() => {
fileEventHandler(FileEvent.Delete, mockFolder, { oldPath: mockFolder.path });
}).not.toThrow();
});
it('should handle files with special characters in path', () => {
const mockFile = createMockFile('folder/file with spaces & (special).md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
expect(() => {
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
}).not.toThrow();
});
it('should handle tags with special characters', () => {
const mockFile = createMockFile('file.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag-with-dash' }, { tag: '#tag_with_underscore' }, { tag: '#tag123' }]
});
expect(() => {
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
}).not.toThrow();
});
it('should handle deleting non-existent file', () => {
const mockFile = createMockFile('never-created.md');
expect(() => {
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
}).not.toThrow();
});
it('should handle modifying non-existent file', () => {
const mockFile = createMockFile('never-created.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
expect(() => {
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
}).not.toThrow();
});
it('should handle renaming non-existent file', () => {
const mockFile = createMockFile('new.md');
expect(() => {
fileEventHandler(FileEvent.Rename, mockFile, { oldPath: 'old.md' });
}).not.toThrow();
});
});
describe('folder event handling - exclusions', () => {
it('should not cache excluded folders on create (Vaultkeeper AI directory)', () => {
// Mock VaultService to return null for excluded paths
const mockVaultServiceWithExclusions = {
registerFileEvents: vi.fn((handler: any) => {
fileEventHandler = handler;
}),
listDirectoryContents: vi.fn(async () => []),
getAbstractFileByPath: vi.fn((path: string) => {
// Return null for excluded paths (simulating VaultService exclusion logic)
if (path.startsWith('Vaultkeeper AI')) {
return null;
}
// Return a folder for allowed paths
return createMockFolder(path);
}),
isExclusion: vi.fn((path: string) => {
// Return true for Vaultkeeper AI directory paths
return path.startsWith('Vaultkeeper AI');
})
};
RegisterSingleton(Services.VaultService, mockVaultServiceWithExclusions as any);
vaultCacheService = new VaultCacheService();
const excludedFolder = createMockFolder('Vaultkeeper AI/subfolder');
fileEventHandler(FileEvent.Create, excludedFolder, { oldPath: '' });
const results = vaultCacheService.matchFolder('subfolder');
expect(results).toHaveLength(0);
});
it('should not cache user-excluded folders on create', () => {
// Create plugin with user exclusions
const mockPluginWithExclusions = {
app: {
vault: mockVault,
metadataCache: mockMetadataCache
},
settings: {
exclusions: ['private/**']
},
registerEvent: vi.fn()
};
// Mock VaultService to return null for excluded paths
const mockVaultServiceWithExclusions = {
registerFileEvents: vi.fn((handler: any) => {
fileEventHandler = handler;
}),
listDirectoryContents: vi.fn(async () => []),
getAbstractFileByPath: vi.fn((path: string) => {
// Return null for excluded paths
if (path.startsWith('private/')) {
return null;
}
return createMockFolder(path);
}),
isExclusion: vi.fn((path: string) => {
// Return true for user-excluded paths
return path.startsWith('private/');
})
};
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPluginWithExclusions as any);
RegisterSingleton(Services.VaultService, mockVaultServiceWithExclusions as any);
vaultCacheService = new VaultCacheService();
const excludedFolder = createMockFolder('private/secrets');
fileEventHandler(FileEvent.Create, excludedFolder, { oldPath: '' });
const results = vaultCacheService.matchFolder('secrets');
expect(results).toHaveLength(0);
});
it('should remove folder from cache when renamed to excluded path', () => {
// Mock VaultService for rename scenario
const mockVaultServiceWithExclusions = {
registerFileEvents: vi.fn((handler: any) => {
fileEventHandler = handler;
}),
listDirectoryContents: vi.fn(async () => []),
getAbstractFileByPath: vi.fn((path: string, allowAccessToPluginRoot: boolean) => {
// First call (public-folder) should succeed
if (path === 'public-folder') {
return createMockFolder(path);
}
// Second call (Vaultkeeper AI/renamed) should fail
if (path.startsWith('Vaultkeeper AI')) {
return null;
}
return createMockFolder(path);
}),
isExclusion: vi.fn((path: string) => {
// Return true for Vaultkeeper AI paths
return path.startsWith('Vaultkeeper AI');
})
};
RegisterSingleton(Services.VaultService, mockVaultServiceWithExclusions as any);
vaultCacheService = new VaultCacheService();
// Create a folder first
const folder = createMockFolder('public-folder');
fileEventHandler(FileEvent.Create, folder, { oldPath: '' });
// Verify it's in the cache
let results = vaultCacheService.matchFolder('public-folder');
expect(results.length).toBeGreaterThan(0);
// Rename to excluded path
const renamedFolder = createMockFolder('Vaultkeeper AI/renamed');
fileEventHandler(FileEvent.Rename, renamedFolder, { oldPath: folder.path });
// Should not be in cache under new name
results = vaultCacheService.matchFolder('renamed');
expect(results).toHaveLength(0);
// Old name should also be removed
results = vaultCacheService.matchFolder('public-folder');
expect(results).toHaveLength(0);
});
it('should cache allowed folders even when exclusions are configured', () => {
// Mock VaultService with exclusions
const mockVaultServiceWithExclusions = {
registerFileEvents: vi.fn((handler: any) => {
fileEventHandler = handler;
}),
listDirectoryContents: vi.fn(async () => []),
getAbstractFileByPath: vi.fn((path: string) => {
if (path.startsWith('Vaultkeeper AI')) {
return null;
}
return createMockFolder(path);
}),
isExclusion: vi.fn((path: string) => {
// Return true for Vaultkeeper AI paths, false for others
return path.startsWith('Vaultkeeper AI');
})
};
RegisterSingleton(Services.VaultService, mockVaultServiceWithExclusions as any);
vaultCacheService = new VaultCacheService();
// Create an allowed folder
const allowedFolder = createMockFolder('allowed-folder');
fileEventHandler(FileEvent.Create, allowedFolder, { oldPath: '' });
// Should be in cache
const results = vaultCacheService.matchFolder('allowed-folder');
expect(results.length).toBeGreaterThan(0);
});
});
describe('fuzzy search - matchTag', () => {
beforeEach(() => {
// Create files with various tags
const file1 = createMockFile('file1.md');
const file2 = createMockFile('file2.md');
const file3 = createMockFile('file3.md');
mockMetadataCache.getCache.mockReturnValueOnce({
tags: [{ tag: '#project' }, { tag: '#important' }]
});
fileEventHandler(FileEvent.Create, file1, { oldPath: '' });
mockMetadataCache.getCache.mockReturnValueOnce({
tags: [{ tag: '#personal' }, { tag: '#todo' }]
});
fileEventHandler(FileEvent.Create, file2, { oldPath: '' });
mockMetadataCache.getCache.mockReturnValueOnce({
tags: [{ tag: '#work' }, { tag: '#meeting' }]
});
fileEventHandler(FileEvent.Create, file3, { oldPath: '' });
});
it('should find matching tags', () => {
const results = vaultCacheService.matchTag('project');
expect(results).toBeDefined();
expect(results.length).toBeGreaterThan(0);
});
it('should return empty results for non-matching input', () => {
const results = vaultCacheService.matchTag('nonexistent');
expect(results).toBeDefined();
expect(results.length).toBe(0);
});
it('should be case insensitive', () => {
const results = vaultCacheService.matchTag('PROJECT');
expect(results).toBeDefined();
expect(results.length).toBeGreaterThan(0);
});
it('should handle partial matches', () => {
const results = vaultCacheService.matchTag('proj');
expect(results).toBeDefined();
});
it('should limit results to configured limit', () => {
// Create many tags to test limit
for (let i = 0; i < 20; i++) {
const file = createMockFile(`file${i}.md`);
mockMetadataCache.getCache.mockReturnValueOnce({
tags: [{ tag: `#test${i}` }]
});
fileEventHandler(FileEvent.Create, file, { oldPath: '' });
}
const results = vaultCacheService.matchTag('test');
expect(results.length).toBeLessThanOrEqual(10);
});
});
describe('fuzzy search - matchFile', () => {
beforeEach(() => {
// Create files with various names
mockMetadataCache.getCache.mockReturnValue(null);
fileEventHandler(FileEvent.Create, createMockFile('project-notes.md'), { oldPath: '' });
fileEventHandler(FileEvent.Create, createMockFile('meeting-2024.md'), { oldPath: '' });
fileEventHandler(FileEvent.Create, createMockFile('todo-list.md'), { oldPath: '' });
fileEventHandler(FileEvent.Create, createMockFile('important-doc.md'), { oldPath: '' });
});
it('should find matching files by basename', () => {
const results = vaultCacheService.matchFile('project');
expect(results).toBeDefined();
expect(results.length).toBeGreaterThan(0);
});
it('should return empty results for non-matching input', () => {
const results = vaultCacheService.matchFile('zzznomatch');
expect(results).toBeDefined();
expect(results.length).toBe(0);
});
it('should be case insensitive', () => {
const results = vaultCacheService.matchFile('PROJECT');
expect(results).toBeDefined();
expect(results.length).toBeGreaterThan(0);
});
it('should handle partial matches', () => {
const results = vaultCacheService.matchFile('meet');
expect(results).toBeDefined();
});
it('should limit results to configured limit', () => {
// Create many files to test limit
for (let i = 0; i < 20; i++) {
const file = createMockFile(`document${i}.md`);
fileEventHandler(FileEvent.Create, file, { oldPath: '' });
}
const results = vaultCacheService.matchFile('document');
expect(results.length).toBeLessThanOrEqual(10);
});
it('should update results after file is added', () => {
const resultsBefore = vaultCacheService.matchFile('newfile');
expect(resultsBefore.length).toBe(0);
const newFile = createMockFile('newfile-test.md');
fileEventHandler(FileEvent.Create, newFile, { oldPath: '' });
const resultsAfter = vaultCacheService.matchFile('newfile');
expect(resultsAfter.length).toBeGreaterThan(0);
});
it('should update results after file is deleted', () => {
const file = createMockFile('todelete.md');
fileEventHandler(FileEvent.Create, file, { oldPath: '' });
const resultsBefore = vaultCacheService.matchFile('todelete');
expect(resultsBefore.length).toBeGreaterThan(0);
fileEventHandler(FileEvent.Delete, file, { oldPath: file.path });
const resultsAfter = vaultCacheService.matchFile('todelete');
expect(resultsAfter.length).toBe(0);
});
});
describe('fuzzy search - matchFolder', () => {
beforeEach(() => {
// Create folders with various paths
fileEventHandler(FileEvent.Create, createMockFolder('projects'), { oldPath: '' });
fileEventHandler(FileEvent.Create, createMockFolder('projects/frontend'), { oldPath: '' });
fileEventHandler(FileEvent.Create, createMockFolder('projects/backend'), { oldPath: '' });
fileEventHandler(FileEvent.Create, createMockFolder('archive'), { oldPath: '' });
});
it('should find matching folders', () => {
const results = vaultCacheService.matchFolder('projects');
expect(results).toBeDefined();
expect(results.length).toBeGreaterThan(0);
});
it('should return empty results for non-matching input', () => {
const results = vaultCacheService.matchFolder('nonexistent');
expect(results).toBeDefined();
expect(results.length).toBe(0);
});
it('should be case insensitive', () => {
const results = vaultCacheService.matchFolder('PROJECTS');
expect(results).toBeDefined();
expect(results.length).toBeGreaterThan(0);
});
it('should handle partial matches', () => {
const results = vaultCacheService.matchFolder('proj');
expect(results).toBeDefined();
});
it('should limit results to configured limit', () => {
// Create many folders to test limit
for (let i = 0; i < 20; i++) {
const folder = createMockFolder(`folder${i}`);
fileEventHandler(FileEvent.Create, folder, { oldPath: '' });
}
const results = vaultCacheService.matchFolder('folder');
expect(results.length).toBeLessThanOrEqual(10);
});
it('should update results after folder is added', () => {
const resultsBefore = vaultCacheService.matchFolder('newfolder');
expect(resultsBefore.length).toBe(0);
const newFolder = createMockFolder('newfolder');
fileEventHandler(FileEvent.Create, newFolder, { oldPath: '' });
const resultsAfter = vaultCacheService.matchFolder('newfolder');
expect(resultsAfter.length).toBeGreaterThan(0);
});
it('should update results after folder is renamed', () => {
const folder = createMockFolder('oldname');
fileEventHandler(FileEvent.Create, folder, { oldPath: '' });
const resultsBefore = vaultCacheService.matchFolder('oldname');
expect(resultsBefore.length).toBeGreaterThan(0);
const renamedFolder = createMockFolder('newname');
fileEventHandler(FileEvent.Rename, renamedFolder, { oldPath: folder.path });
const resultsOldName = vaultCacheService.matchFolder('oldname');
expect(resultsOldName.length).toBe(0);
const resultsNewName = vaultCacheService.matchFolder('newname');
expect(resultsNewName.length).toBeGreaterThan(0);
});
it('should update results after folder is deleted', () => {
const folder = createMockFolder('todelete');
fileEventHandler(FileEvent.Create, folder, { oldPath: '' });
const resultsBefore = vaultCacheService.matchFolder('todelete');
expect(resultsBefore.length).toBeGreaterThan(0);
fileEventHandler(FileEvent.Delete, folder, { oldPath: folder.path });
const resultsAfter = vaultCacheService.matchFolder('todelete');
expect(resultsAfter.length).toBe(0);
});
});
});