andy-stack_vaultkeeper-ai/__tests__/Services/UserInputService.test.ts
Andrew Beal f2d5c01970 test: add cleanup to prevent memory leaks in test suites
Add DeregisterAllServices() calls in afterEach hooks across all test files to clear singleton registry between tests. Export DeregisterAllServices from DependencyService. Fix StatusBarService test timing issues with animation frames.
2025-10-30 12:15:35 +00:00

701 lines
19 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { UserInputService } from '../../Services/UserInputService';
import { SearchTrigger } from '../../Enums/SearchTrigger';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { writable, get } from 'svelte/store';
import type { ISearchState } from '../../Stores/SearchStateStore';
/**
* UNIT TESTS
*
* These tests mock the VaultCacheService and SearchStateStore dependencies
* to isolate and test UserInputService behavior.
*/
describe('UserInputService', () => {
let userInputService: UserInputService;
let mockVaultCacheService: any;
let mockSearchStateStore: any;
let mockSearchState: any;
beforeEach(() => {
// Create mock search state store
mockSearchState = writable<ISearchState>({
active: false,
trigger: null,
position: null,
query: "",
results: [],
selectedResult: ""
});
mockSearchStateStore = {
searchState: mockSearchState,
setResults: vi.fn((results: string[]) => {
mockSearchState.update((state: any) => ({ ...state, results }));
})
};
// Create mock vault cache service
mockVaultCacheService = {
matchTag: vi.fn(),
matchFile: vi.fn(),
matchFolder: vi.fn()
};
// Register mock dependencies
RegisterSingleton(Services.VaultCacheService, mockVaultCacheService);
RegisterSingleton(Services.SearchStateStore, mockSearchStateStore);
// Create service instance
userInputService = new UserInputService();
});
afterEach(() => {
// Clear singleton registry to prevent memory leaks
DeregisterAllServices();
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should resolve VaultCacheService dependency', () => {
expect(userInputService).toBeDefined();
// Dependency resolution is validated by the service not throwing during construction
});
it('should resolve SearchStateStore dependency', () => {
expect(userInputService).toBeDefined();
// Dependency resolution is validated by the service not throwing during construction
});
});
describe('searchState getter', () => {
it('should return the search state store', () => {
const searchState = userInputService.searchState;
expect(searchState).toBeDefined();
expect(searchState).toBe(mockSearchState);
});
it('should return a writable store', () => {
const searchState = userInputService.searchState;
const currentState = get(searchState) as ISearchState;
expect(currentState).toHaveProperty('active');
expect(currentState).toHaveProperty('trigger');
expect(currentState).toHaveProperty('query');
expect(currentState).toHaveProperty('results');
});
});
describe('performSearch - validation checks', () => {
it('should set empty results when search is not active', () => {
mockSearchState.set({
active: false,
trigger: SearchTrigger.Tag,
position: 0,
query: "test query",
results: ["old result"],
selectedResult: ""
});
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
expect(mockVaultCacheService.matchTag).not.toHaveBeenCalled();
expect(mockVaultCacheService.matchFile).not.toHaveBeenCalled();
expect(mockVaultCacheService.matchFolder).not.toHaveBeenCalled();
});
it('should set empty results when trigger is null', () => {
mockSearchState.set({
active: true,
trigger: null,
position: 0,
query: "test query",
results: ["old result"],
selectedResult: ""
});
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
expect(mockVaultCacheService.matchTag).not.toHaveBeenCalled();
expect(mockVaultCacheService.matchFile).not.toHaveBeenCalled();
expect(mockVaultCacheService.matchFolder).not.toHaveBeenCalled();
});
it('should set empty results when query length is less than 3', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "ab",
results: ["old result"],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '#ab' }, score: 0 }
]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith("ab");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['#ab']);
});
it('should set empty results when query is empty', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "",
results: ["old result"],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith("");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
});
it('should set empty results when trimmed query length is less than 3', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: " a ",
results: ["old result"],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith(" a ");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
});
it('should proceed with search when query length is exactly 3', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "abc",
results: [],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '#test' }, score: 0 }
]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith("abc");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['#test']);
});
it('should proceed with search when trimmed query length is 3 or more', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: " abc ",
results: [],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '#test' }, score: 0 }
]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith(" abc ");
});
});
describe('performSearch - tag search', () => {
beforeEach(() => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "project",
results: [],
selectedResult: ""
});
});
it('should call matchTag with the query', () => {
mockVaultCacheService.matchTag.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith("project");
expect(mockVaultCacheService.matchTag).toHaveBeenCalledOnce();
});
it('should map tag results correctly', () => {
const mockResults = [
{ obj: { tag: '#project' }, score: 0 },
{ obj: { tag: '#project-work' }, score: 0.5 },
{ obj: { tag: '#project-personal' }, score: 1 }
];
mockVaultCacheService.matchTag.mockReturnValue(mockResults);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([
'#project',
'#project-work',
'#project-personal'
]);
});
it('should handle empty tag results', () => {
mockVaultCacheService.matchTag.mockReturnValue([]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
});
it('should handle single tag result', () => {
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '#unique-tag' }, score: 0 }
]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['#unique-tag']);
});
it('should not call other match methods', () => {
mockVaultCacheService.matchTag.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchFile).not.toHaveBeenCalled();
expect(mockVaultCacheService.matchFolder).not.toHaveBeenCalled();
});
});
describe('performSearch - file search', () => {
beforeEach(() => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.File,
position: 0,
query: "note",
results: [],
selectedResult: ""
});
});
it('should call matchFile with the query', () => {
mockVaultCacheService.matchFile.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchFile).toHaveBeenCalledWith("note");
expect(mockVaultCacheService.matchFile).toHaveBeenCalledOnce();
});
it('should map file results correctly', () => {
const mockResults = [
{ obj: { file: { path: 'notes/meeting.md' } }, score: 0 },
{ obj: { file: { path: 'notes/daily.md' } }, score: 0.5 },
{ obj: { file: { path: 'archive/notes.md' } }, score: 1 }
];
mockVaultCacheService.matchFile.mockReturnValue(mockResults);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([
'notes/meeting.md',
'notes/daily.md',
'archive/notes.md'
]);
});
it('should handle empty file results', () => {
mockVaultCacheService.matchFile.mockReturnValue([]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
});
it('should handle single file result', () => {
mockVaultCacheService.matchFile.mockReturnValue([
{ obj: { file: { path: 'unique/file.md' } }, score: 0 }
]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['unique/file.md']);
});
it('should not call other match methods', () => {
mockVaultCacheService.matchFile.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).not.toHaveBeenCalled();
expect(mockVaultCacheService.matchFolder).not.toHaveBeenCalled();
});
});
describe('performSearch - folder search', () => {
beforeEach(() => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Folder,
position: 0,
query: "proj",
results: [],
selectedResult: ""
});
});
it('should call matchFolder with the query', () => {
mockVaultCacheService.matchFolder.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchFolder).toHaveBeenCalledWith("proj");
expect(mockVaultCacheService.matchFolder).toHaveBeenCalledOnce();
});
it('should map folder results correctly', () => {
const mockResults = [
{ obj: { folder: { path: 'projects' } }, score: 0 },
{ obj: { folder: { path: 'projects/frontend' } }, score: 0.5 },
{ obj: { folder: { path: 'projects/backend' } }, score: 1 }
];
mockVaultCacheService.matchFolder.mockReturnValue(mockResults);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([
'projects',
'projects/frontend',
'projects/backend'
]);
});
it('should handle empty folder results', () => {
mockVaultCacheService.matchFolder.mockReturnValue([]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
});
it('should handle single folder result', () => {
mockVaultCacheService.matchFolder.mockReturnValue([
{ obj: { folder: { path: 'unique-folder' } }, score: 0 }
]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['unique-folder']);
});
it('should not call other match methods', () => {
mockVaultCacheService.matchFolder.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).not.toHaveBeenCalled();
expect(mockVaultCacheService.matchFile).not.toHaveBeenCalled();
});
});
describe('performSearch - edge cases', () => {
it('should handle multiple consecutive searches', () => {
// First search - tags
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "test",
results: [],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '#test' }, score: 0 }
]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['#test']);
// Second search - files
mockSearchState.set({
active: true,
trigger: SearchTrigger.File,
position: 0,
query: "note",
results: ['#test'],
selectedResult: ""
});
mockVaultCacheService.matchFile.mockReturnValue([
{ obj: { file: { path: 'note.md' } }, score: 0 }
]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['note.md']);
expect(mockSearchStateStore.setResults).toHaveBeenCalledTimes(2);
});
it('should handle search with special characters in query', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "test-tag_123",
results: [],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '#test-tag_123' }, score: 0 }
]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith("test-tag_123");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['#test-tag_123']);
});
it('should handle search with unicode characters', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.File,
position: 0,
query: "café",
results: [],
selectedResult: ""
});
mockVaultCacheService.matchFile.mockReturnValue([
{ obj: { file: { path: 'café-notes.md' } }, score: 0 }
]);
userInputService.performSearch();
expect(mockVaultCacheService.matchFile).toHaveBeenCalledWith("café");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['café-notes.md']);
});
it('should handle large result sets', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "test",
results: [],
selectedResult: ""
});
const largeResults = Array.from({ length: 100 }, (_, i) => ({
obj: { tag: `#test${i}` },
score: i
}));
mockVaultCacheService.matchTag.mockReturnValue(largeResults);
userInputService.performSearch();
const expectedTags = largeResults.map(r => r.obj.tag);
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(expectedTags);
});
it('should handle switching between different search triggers', () => {
// Tag search
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "test",
results: [],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '#test' }, score: 0 }
]);
userInputService.performSearch();
// File search
mockSearchState.set({
active: true,
trigger: SearchTrigger.File,
position: 0,
query: "test",
results: ['#test'],
selectedResult: ""
});
mockVaultCacheService.matchFile.mockReturnValue([
{ obj: { file: { path: 'test.md' } }, score: 0 }
]);
userInputService.performSearch();
// Folder search
mockSearchState.set({
active: true,
trigger: SearchTrigger.Folder,
position: 0,
query: "test",
results: ['test.md'],
selectedResult: ""
});
mockVaultCacheService.matchFolder.mockReturnValue([
{ obj: { folder: { path: 'test-folder' } }, score: 0 }
]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledOnce();
expect(mockVaultCacheService.matchFile).toHaveBeenCalledOnce();
expect(mockVaultCacheService.matchFolder).toHaveBeenCalledOnce();
});
it('should handle query that becomes invalid after trim', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: " ",
results: ["old result"],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([]);
userInputService.performSearch();
expect(mockVaultCacheService.matchTag).toHaveBeenCalledWith(" ");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([]);
});
it('should preserve query with internal spaces', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.File,
position: 0,
query: "my note",
results: [],
selectedResult: ""
});
mockVaultCacheService.matchFile.mockReturnValue([
{ obj: { file: { path: 'my note.md' } }, score: 0 }
]);
userInputService.performSearch();
expect(mockVaultCacheService.matchFile).toHaveBeenCalledWith("my note");
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['my note.md']);
});
});
describe('performSearch - integration scenarios', () => {
it('should handle search state changes during execution', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "test",
results: [],
selectedResult: ""
});
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '#test1' }, score: 0 },
{ obj: { tag: '#test2' }, score: 0.5 }
]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['#test1', '#test2']);
// Verify state can be updated
const currentState = get(mockSearchState) as ISearchState;
expect(currentState.results).toEqual(['#test1', '#test2']);
});
it('should handle empty string results gracefully', () => {
mockSearchState.set({
active: true,
trigger: SearchTrigger.Tag,
position: 0,
query: "test",
results: [],
selectedResult: ""
});
// Mock results with empty tag (edge case)
mockVaultCacheService.matchTag.mockReturnValue([
{ obj: { tag: '' }, score: 0 },
{ obj: { tag: '#valid' }, score: 0.5 }
]);
userInputService.performSearch();
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith(['', '#valid']);
});
it('should complete search workflow from start to finish', () => {
// Initial state - inactive
const initialState = get(mockSearchState) as ISearchState;
expect(initialState.active).toBe(false);
// Activate search
mockSearchState.set({
active: true,
trigger: SearchTrigger.File,
position: 10,
query: "important",
results: [],
selectedResult: ""
});
// Mock successful search
mockVaultCacheService.matchFile.mockReturnValue([
{ obj: { file: { path: 'important-doc.md' } }, score: 0 },
{ obj: { file: { path: 'very-important.md' } }, score: 0.5 }
]);
// Perform search
userInputService.performSearch();
// Verify results
expect(mockSearchStateStore.setResults).toHaveBeenCalledWith([
'important-doc.md',
'very-important.md'
]);
// Verify state updated
const finalState = get(mockSearchState) as ISearchState;
expect(finalState.results).toEqual(['important-doc.md', 'very-important.md']);
});
});
});