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({ 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"); }); }); });