mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 16:30:27 +00:00
370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
||
|
|
import { Services } from '../../Services/Services';
|
||
|
|
import { writable } from 'svelte/store';
|
||
|
|
import type { ISearchState } from '../../Stores/SearchStateStore';
|
||
|
|
import { SearchTrigger } from '../../Enums/SearchTrigger';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* UNIT TESTS FOR CHATINPUT CURSOR-AWARE QUERY EXTRACTION
|
||
|
|
*
|
||
|
|
* These tests verify that the handleInput() function in ChatInput.svelte
|
||
|
|
* correctly extracts the search query from the trigger position to the
|
||
|
|
* current cursor position, rather than to the end of the text.
|
||
|
|
*
|
||
|
|
* This allows users to edit mentions in the middle of their text without
|
||
|
|
* breaking the autocomplete functionality.
|
||
|
|
*/
|
||
|
|
|
||
|
|
describe('ChatInput - Cursor-Aware Query Extraction', () => {
|
||
|
|
let mockInputService: any;
|
||
|
|
let mockUserInputService: any;
|
||
|
|
let mockSearchStateStore: any;
|
||
|
|
let mockSearchState: any;
|
||
|
|
let mockTextareaElement: HTMLDivElement;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
// Create a mock contenteditable element
|
||
|
|
mockTextareaElement = document.createElement('div');
|
||
|
|
mockTextareaElement.contentEditable = 'true';
|
||
|
|
document.body.appendChild(mockTextareaElement);
|
||
|
|
|
||
|
|
// Create mock search state store
|
||
|
|
mockSearchState = writable<ISearchState>({
|
||
|
|
active: false,
|
||
|
|
trigger: null,
|
||
|
|
position: null,
|
||
|
|
query: "",
|
||
|
|
results: [],
|
||
|
|
selectedResult: ""
|
||
|
|
});
|
||
|
|
|
||
|
|
mockSearchStateStore = {
|
||
|
|
searchState: mockSearchState,
|
||
|
|
setQuery: vi.fn((query: string) => {
|
||
|
|
mockSearchState.update((state: any) => ({ ...state, query }));
|
||
|
|
}),
|
||
|
|
setResults: vi.fn((results: string[]) => {
|
||
|
|
mockSearchState.update((state: any) => ({ ...state, results }));
|
||
|
|
}),
|
||
|
|
initializeSearch: vi.fn((trigger: SearchTrigger, position: number) => {
|
||
|
|
mockSearchState.update((state: any) => ({
|
||
|
|
...state,
|
||
|
|
active: true,
|
||
|
|
trigger,
|
||
|
|
position
|
||
|
|
}));
|
||
|
|
}),
|
||
|
|
resetSearch: vi.fn(() => {
|
||
|
|
mockSearchState.update((state: any) => ({
|
||
|
|
...state,
|
||
|
|
active: false,
|
||
|
|
trigger: null,
|
||
|
|
position: null,
|
||
|
|
query: "",
|
||
|
|
results: [],
|
||
|
|
selectedResult: ""
|
||
|
|
}));
|
||
|
|
})
|
||
|
|
};
|
||
|
|
|
||
|
|
// Create mock input service
|
||
|
|
mockInputService = {
|
||
|
|
getCursorPosition: vi.fn(),
|
||
|
|
setCursorPosition: vi.fn(),
|
||
|
|
hasUnauthorizedHTML: vi.fn(() => false),
|
||
|
|
sanitizeToPlainText: vi.fn()
|
||
|
|
};
|
||
|
|
|
||
|
|
// Create mock user input service
|
||
|
|
mockUserInputService = {
|
||
|
|
performSearch: vi.fn()
|
||
|
|
};
|
||
|
|
|
||
|
|
// Register mock dependencies
|
||
|
|
RegisterSingleton(Services.InputService, mockInputService);
|
||
|
|
RegisterSingleton(Services.UserInputService, mockUserInputService);
|
||
|
|
RegisterSingleton(Services.SearchStateStore, mockSearchStateStore);
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
// Clean up
|
||
|
|
document.body.removeChild(mockTextareaElement);
|
||
|
|
DeregisterAllServices();
|
||
|
|
vi.restoreAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Helper function to simulate the handleInput() logic from ChatInput.svelte
|
||
|
|
* This directly tests the query extraction logic
|
||
|
|
*/
|
||
|
|
function simulateHandleInput(
|
||
|
|
textContent: string,
|
||
|
|
cursorPosition: number,
|
||
|
|
searchActive: boolean,
|
||
|
|
triggerPosition: number | null
|
||
|
|
): string | null {
|
||
|
|
mockTextareaElement.textContent = textContent;
|
||
|
|
mockInputService.getCursorPosition.mockReturnValue(cursorPosition);
|
||
|
|
|
||
|
|
if (searchActive && triggerPosition !== null) {
|
||
|
|
const fullText = mockTextareaElement.textContent || "";
|
||
|
|
const triggerPos = triggerPosition;
|
||
|
|
|
||
|
|
// This is the logic from ChatInput.svelte line 211-212
|
||
|
|
const currentCursorPos = mockInputService.getCursorPosition(mockTextareaElement);
|
||
|
|
const actualQuery = fullText.substring(triggerPos + 1, currentCursorPos);
|
||
|
|
|
||
|
|
return actualQuery;
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('Cursor-Aware Query Extraction', () => {
|
||
|
|
it('should extract query from trigger to cursor position, not end of text', () => {
|
||
|
|
// Scenario: "@myFile and give feedback"
|
||
|
|
// Cursor at position 7 (after "myFile")
|
||
|
|
const text = "@myFile and give feedback";
|
||
|
|
const cursorPos = 7; // After "@myFile"
|
||
|
|
const triggerPos = 0; // Position of "@"
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("myFile");
|
||
|
|
expect(query).not.toBe("myFile and give feedback");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should update query when cursor is mid-edit', () => {
|
||
|
|
// Scenario: User edits "@myFile" to "@myFile2" in the middle of text
|
||
|
|
// "@myFile2 and give feedback"
|
||
|
|
// Cursor at position 8 (after "myFile2")
|
||
|
|
const text = "@myFile2 and give feedback";
|
||
|
|
const cursorPos = 8;
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("myFile2");
|
||
|
|
expect(query).not.toContain("and give feedback");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should extract full query when cursor is at the end', () => {
|
||
|
|
// Scenario: "@myFile" with cursor at end
|
||
|
|
const text = "@myFile";
|
||
|
|
const cursorPos = 7; // At the end
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("myFile");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle whitespace in filenames correctly', () => {
|
||
|
|
// Scenario: "@my file name" with spaces
|
||
|
|
const text = "@my file name";
|
||
|
|
const cursorPos = 13; // At the end
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("my file name");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle empty query when cursor is right after trigger', () => {
|
||
|
|
// Scenario: "@" with cursor right after
|
||
|
|
const text = "@some text";
|
||
|
|
const cursorPos = 1; // Right after "@"
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should not include text after cursor in query', () => {
|
||
|
|
// Scenario: "@file.md more text" with cursor after "fi"
|
||
|
|
const text = "@file.md more text";
|
||
|
|
const cursorPos = 3; // After "@fi"
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("fi");
|
||
|
|
expect(query).not.toContain("le.md");
|
||
|
|
expect(query).not.toContain("more text");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Edge Cases', () => {
|
||
|
|
it('should handle trigger in the middle of text', () => {
|
||
|
|
// Scenario: "Please review @myFile and let me know"
|
||
|
|
// Trigger at position 14, cursor at position 21
|
||
|
|
const text = "Please review @myFile and let me know";
|
||
|
|
const cursorPos = 21; // After "@myFile"
|
||
|
|
const triggerPos = 14; // Position of "@"
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("myFile");
|
||
|
|
expect(query).not.toContain("and let me know");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle special characters in query', () => {
|
||
|
|
// Scenario: "@file-name_123.md"
|
||
|
|
const text = "@file-name_123.md";
|
||
|
|
const cursorPos = 17; // At the end
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("file-name_123.md");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle cursor position at trigger position', () => {
|
||
|
|
// Scenario: User has text but cursor moved to trigger position
|
||
|
|
// Note: In actual usage, handleCursorPositionChange() would reset search
|
||
|
|
// when cursor moves to or before trigger, but we test the substring behavior
|
||
|
|
const text = "@myFile";
|
||
|
|
const cursorPos = 1; // Right after the trigger "@"
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
// When cursor is right after trigger, query should be empty
|
||
|
|
expect(query).toBe("");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle tag trigger (#)', () => {
|
||
|
|
// Scenario: "#my-tag"
|
||
|
|
const text = "#my-tag";
|
||
|
|
const cursorPos = 7;
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("my-tag");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle folder trigger (/)', () => {
|
||
|
|
// Scenario: "/my-folder"
|
||
|
|
const text = "/my-folder";
|
||
|
|
const cursorPos = 10;
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("my-folder");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle very long text with cursor in middle', () => {
|
||
|
|
// Scenario: Long text with trigger in middle
|
||
|
|
const beforeText = "This is a very long piece of text ";
|
||
|
|
const query = "@myFile";
|
||
|
|
const afterText = " and this is more text after the mention";
|
||
|
|
const text = beforeText + query + afterText;
|
||
|
|
const triggerPos = beforeText.length;
|
||
|
|
const cursorPos = beforeText.length + query.length;
|
||
|
|
|
||
|
|
const extractedQuery = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(extractedQuery).toBe("myFile");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Integration with Services', () => {
|
||
|
|
it('should call getCursorPosition when extracting query', () => {
|
||
|
|
const text = "@myFile and text";
|
||
|
|
const cursorPos = 7;
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(mockInputService.getCursorPosition).toHaveBeenCalledWith(mockTextareaElement);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should work correctly when search is not active', () => {
|
||
|
|
const text = "@myFile";
|
||
|
|
const cursorPos = 7;
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, false, triggerPos);
|
||
|
|
|
||
|
|
// Should return null when search is not active
|
||
|
|
expect(query).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should work correctly when trigger position is null', () => {
|
||
|
|
const text = "@myFile";
|
||
|
|
const cursorPos = 7;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, null);
|
||
|
|
|
||
|
|
// Should return null when trigger position is null
|
||
|
|
expect(query).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle negative cursor position gracefully', () => {
|
||
|
|
// Edge case: getCursorPosition returns -1 (error state)
|
||
|
|
const text = "@myFile";
|
||
|
|
const cursorPos = -1;
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
// substring with negative end position should handle this
|
||
|
|
expect(query).toBeDefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Real-World Scenarios', () => {
|
||
|
|
it('should handle editing mention at start of text', () => {
|
||
|
|
// User types: "@myFile"
|
||
|
|
// User edits to: "@myFile2"
|
||
|
|
const text = "@myFile2 some other text";
|
||
|
|
const cursorPos = 8; // After "@myFile2"
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("myFile2");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle editing mention in middle of sentence', () => {
|
||
|
|
// User types: "Please check @oldFile and review"
|
||
|
|
// User edits to: "Please check @newFile and review"
|
||
|
|
const text = "Please check @newFile and review";
|
||
|
|
const triggerPos = 13;
|
||
|
|
const cursorPos = 21; // After "@newFile"
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("newFile");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle partial deletion', () => {
|
||
|
|
// User has: "@myFile"
|
||
|
|
// User deletes to: "@myFi"
|
||
|
|
const text = "@myFi";
|
||
|
|
const cursorPos = 5;
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("myFi");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle insertion in middle of mention', () => {
|
||
|
|
// User has: "@myFile"
|
||
|
|
// User inserts "New" to make: "@myNewFile"
|
||
|
|
const text = "@myNewFile some text";
|
||
|
|
const cursorPos = 10; // After "@myNewFile"
|
||
|
|
const triggerPos = 0;
|
||
|
|
|
||
|
|
const query = simulateHandleInput(text, cursorPos, true, triggerPos);
|
||
|
|
|
||
|
|
expect(query).toBe("myNewFile");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|