andy-stack_vaultkeeper-ai/__tests__/Components/ChatInput.test.ts

370 lines
11 KiB
TypeScript
Raw Normal View History

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