diff --git a/__tests__/Services/InputService.test.ts b/__tests__/Services/InputService.test.ts
index 0f8158d..5d8f4f2 100644
--- a/__tests__/Services/InputService.test.ts
+++ b/__tests__/Services/InputService.test.ts
@@ -1,12 +1,12 @@
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
import { InputService } from '../../Services/InputService';
+import { SearchTrigger } from '../../Enums/SearchTrigger';
/**
* UNIT TESTS
*
- * These tests cover the straightforward methods of InputService that don't
- * require complex DOM/Selection API mocking. Methods involving cursor manipulation
- * and Selection/Range APIs are not covered here due to testing complexity.
+ * These tests cover both simple methods and DOM/Selection API methods using JSDOM.
+ * JSDOM provides the DOM environment needed for cursor manipulation and Range/Selection APIs.
*/
describe('InputService', () => {
@@ -330,4 +330,711 @@ describe('InputService', () => {
expect(service.stripHtml(html)).toBe('Text beforeInside tagText after');
});
});
+
+ describe('getPlainTextFromClipboard', () => {
+ it('should extract text/plain from DataTransfer', () => {
+ const mockClipboardData = {
+ getData: vi.fn((type: string) => {
+ if (type === 'text/plain') return 'Plain text content';
+ return '';
+ })
+ } as unknown as DataTransfer;
+
+ const result = service.getPlainTextFromClipboard(mockClipboardData);
+ expect(result).toBe('Plain text content');
+ expect(mockClipboardData.getData).toHaveBeenCalledWith('text/plain');
+ });
+
+ it('should return empty string for null clipboard', () => {
+ const result = service.getPlainTextFromClipboard(null);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string when getData returns empty', () => {
+ const mockClipboardData = {
+ getData: vi.fn(() => '')
+ } as unknown as DataTransfer;
+
+ const result = service.getPlainTextFromClipboard(mockClipboardData);
+ expect(result).toBe('');
+ });
+
+ it('should handle clipboard with no text/plain data', () => {
+ const mockClipboardData = {
+ getData: vi.fn(() => null)
+ } as unknown as DataTransfer;
+
+ const result = service.getPlainTextFromClipboard(mockClipboardData);
+ expect(result).toBe('');
+ });
+ });
+
+ describe('hasUnauthorizedHTML', () => {
+ let element: HTMLDivElement;
+
+ beforeEach(() => {
+ element = document.createElement('div');
+ element.contentEditable = 'true';
+ });
+
+ it('should return false for text-only content', () => {
+ element.textContent = 'Plain text content';
+ expect(service.hasUnauthorizedHTML(element)).toBe(false);
+ });
+
+ it('should return false for BR tags (browser auto-insert)', () => {
+ const br = document.createElement('br');
+ element.appendChild(br);
+ expect(service.hasUnauthorizedHTML(element)).toBe(false);
+ });
+
+ it('should return false for DIV with only text content', () => {
+ const div = document.createElement('div');
+ div.textContent = 'Text in div';
+ element.appendChild(div);
+ expect(service.hasUnauthorizedHTML(element)).toBe(false);
+ });
+
+ it('should return false for DIV with BR tags', () => {
+ const div = document.createElement('div');
+ div.textContent = 'Line 1';
+ const br = document.createElement('br');
+ div.appendChild(br);
+ div.appendChild(document.createTextNode('Line 2'));
+ element.appendChild(div);
+ expect(service.hasUnauthorizedHTML(element)).toBe(false);
+ });
+
+ it('should return false for search trigger elements', () => {
+ const trigger = document.createElement('span');
+ trigger.className = 'search-trigger';
+ trigger.textContent = '#tag';
+ element.appendChild(trigger);
+ expect(service.hasUnauthorizedHTML(element)).toBe(false);
+ });
+
+ it('should return false for mixed text, BR, DIV, and search triggers', () => {
+ element.appendChild(document.createTextNode('Text '));
+ const trigger = document.createElement('span');
+ trigger.className = 'search-trigger';
+ element.appendChild(trigger);
+ element.appendChild(document.createTextNode(' more text'));
+ const br = document.createElement('br');
+ element.appendChild(br);
+ const div = document.createElement('div');
+ div.textContent = 'Div content';
+ element.appendChild(div);
+
+ expect(service.hasUnauthorizedHTML(element)).toBe(false);
+ });
+
+ it('should return true for SPAN tags (non-search-trigger)', () => {
+ const span = document.createElement('span');
+ span.textContent = 'Regular span';
+ element.appendChild(span);
+ expect(service.hasUnauthorizedHTML(element)).toBe(true);
+ });
+
+ it('should return true for STRONG tags', () => {
+ const strong = document.createElement('strong');
+ strong.textContent = 'Bold text';
+ element.appendChild(strong);
+ expect(service.hasUnauthorizedHTML(element)).toBe(true);
+ });
+
+ it('should return true for IMG tags', () => {
+ const img = document.createElement('img');
+ img.src = 'test.png';
+ element.appendChild(img);
+ expect(service.hasUnauthorizedHTML(element)).toBe(true);
+ });
+
+ it('should return true for SCRIPT tags', () => {
+ const script = document.createElement('script');
+ script.textContent = 'alert("test")';
+ element.appendChild(script);
+ expect(service.hasUnauthorizedHTML(element)).toBe(true);
+ });
+
+ it('should return true for A (anchor) tags', () => {
+ const a = document.createElement('a');
+ a.href = 'http://example.com';
+ a.textContent = 'Link';
+ element.appendChild(a);
+ expect(service.hasUnauthorizedHTML(element)).toBe(true);
+ });
+
+ it('should detect unauthorized HTML in nested DIVs', () => {
+ const div = document.createElement('div');
+ const span = document.createElement('span');
+ span.textContent = 'Nested span';
+ div.appendChild(span);
+ element.appendChild(div);
+ expect(service.hasUnauthorizedHTML(element)).toBe(true);
+ });
+
+ it('should return true for deeply nested unauthorized elements', () => {
+ const div1 = document.createElement('div');
+ const div2 = document.createElement('div');
+ const unauthorized = document.createElement('em');
+ unauthorized.textContent = 'Italic';
+ div2.appendChild(unauthorized);
+ div1.appendChild(div2);
+ element.appendChild(div1);
+ expect(service.hasUnauthorizedHTML(element)).toBe(true);
+ });
+
+ it('should handle empty element', () => {
+ expect(service.hasUnauthorizedHTML(element)).toBe(false);
+ });
+
+ it('should handle element with only whitespace', () => {
+ element.textContent = ' \n ';
+ expect(service.hasUnauthorizedHTML(element)).toBe(false);
+ });
+ });
+
+ describe('DOM Manipulation Methods', () => {
+ let element: HTMLDivElement;
+
+ beforeEach(() => {
+ // Create a contentEditable element
+ element = document.createElement('div');
+ element.contentEditable = 'true';
+ element.textContent = 'Hello World';
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ describe('getCursorPosition', () => {
+ it('should return -1 when no selection exists', () => {
+ // Clear any existing selection
+ const selection = window.getSelection();
+ if (selection) {
+ selection.removeAllRanges();
+ }
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(-1);
+ });
+
+ it('should return cursor position at start of text', () => {
+ const range = document.createRange();
+ const selection = window.getSelection();
+ const textNode = element.firstChild as Text;
+
+ range.setStart(textNode, 0);
+ range.setEnd(textNode, 0);
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(0);
+ });
+
+ it('should return cursor position in middle of text', () => {
+ const range = document.createRange();
+ const selection = window.getSelection();
+ const textNode = element.firstChild as Text;
+
+ range.setStart(textNode, 6); // After "Hello "
+ range.setEnd(textNode, 6);
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(6);
+ });
+
+ it('should return cursor position at end of text', () => {
+ const range = document.createRange();
+ const selection = window.getSelection();
+ const textNode = element.firstChild as Text;
+
+ range.setStart(textNode, 11); // After "Hello World"
+ range.setEnd(textNode, 11);
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(11);
+ });
+
+ it('should handle nested elements', () => {
+ element.innerHTML = '';
+ element.appendChild(document.createTextNode('Hello '));
+ const span = document.createElement('span');
+ span.textContent = 'World';
+ element.appendChild(span);
+
+ const range = document.createRange();
+ const selection = window.getSelection();
+ const spanTextNode = span.firstChild as Text;
+
+ range.setStart(spanTextNode, 2); // "Wo" - position 8 overall
+ range.setEnd(spanTextNode, 2);
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(8);
+ });
+ });
+
+ describe('setCursorPosition', () => {
+ it('should return false for non-contentEditable element', () => {
+ const nonEditable = document.createElement('div');
+ nonEditable.textContent = 'Not editable';
+ document.body.appendChild(nonEditable);
+
+ const result = service.setCursorPosition(nonEditable, 5);
+ expect(result).toBe(false);
+
+ document.body.removeChild(nonEditable);
+ });
+
+ it('should set cursor at start of text', () => {
+ const result = service.setCursorPosition(element, 0);
+ expect(result).toBe(true);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(0);
+ });
+
+ it('should set cursor in middle of text', () => {
+ const result = service.setCursorPosition(element, 6);
+ expect(result).toBe(true);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(6);
+ });
+
+ it('should set cursor at end of text', () => {
+ const result = service.setCursorPosition(element, 11);
+ expect(result).toBe(true);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(11);
+ });
+
+ it('should clamp position beyond text length', () => {
+ const result = service.setCursorPosition(element, 999);
+ expect(result).toBe(true);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(11); // Max length
+ });
+
+ it('should set cursor from end with fromEnd=true', () => {
+ const result = service.setCursorPosition(element, 5, true);
+ expect(result).toBe(true);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(6); // 11 - 5 = 6
+ });
+
+ it('should set cursor at start when fromEnd position exceeds length', () => {
+ const result = service.setCursorPosition(element, 999, true);
+ expect(result).toBe(true);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(0);
+ });
+
+ it('should handle nested elements', () => {
+ element.innerHTML = '';
+ element.appendChild(document.createTextNode('Hello '));
+ const span = document.createElement('span');
+ span.textContent = 'World';
+ element.appendChild(span);
+
+ const result = service.setCursorPosition(element, 8);
+ expect(result).toBe(true);
+
+ const position = service.getCursorPosition(element);
+ expect(position).toBe(8);
+ });
+
+ it('should focus the element when setting cursor', () => {
+ const focusSpy = vi.spyOn(element, 'focus');
+ service.setCursorPosition(element, 5);
+ expect(focusSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('insertTextAtCursor', () => {
+ it('should insert text at cursor position', () => {
+ service.setCursorPosition(element, 6); // After "Hello "
+ service.insertTextAtCursor('Beautiful ', element);
+
+ expect(element.textContent).toBe('Hello Beautiful World');
+ });
+
+ it('should replace selected text', () => {
+ const range = document.createRange();
+ const selection = window.getSelection();
+ const textNode = element.firstChild as Text;
+
+ range.setStart(textNode, 6);
+ range.setEnd(textNode, 11); // Select "World"
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ service.insertTextAtCursor('Universe', element);
+ expect(element.textContent).toBe('Hello Universe');
+ });
+
+ it('should not insert when no selection exists', () => {
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+
+ const originalText = element.textContent;
+ service.insertTextAtCursor('Test', element);
+ expect(element.textContent).toBe(originalText);
+ });
+
+ it('should warn and not insert for non-contentEditable element', () => {
+ const nonEditable = document.createElement('div');
+ nonEditable.textContent = 'Not editable';
+ document.body.appendChild(nonEditable);
+
+ const consoleSpy = vi.spyOn(console, 'warn');
+ service.insertTextAtCursor('Test', nonEditable);
+
+ expect(consoleSpy).toHaveBeenCalledWith('Element must be contenteditable');
+ document.body.removeChild(nonEditable);
+ });
+ });
+
+ describe('insertElementAtCursor', () => {
+ it('should insert element at cursor position', () => {
+ service.setCursorPosition(element, 6); // After "Hello "
+
+ const span = document.createElement('span');
+ span.textContent = 'Beautiful ';
+ service.insertElementAtCursor(span, element);
+
+ expect(element.textContent).toBe('Hello Beautiful World');
+ expect(element.querySelector('span')).toBeTruthy();
+ });
+
+ it('should insert search trigger element', () => {
+ service.setCursorPosition(element, 6);
+
+ const trigger = document.createElement('span');
+ trigger.className = 'search-trigger';
+ trigger.textContent = '#tag';
+ service.insertElementAtCursor(trigger, element);
+
+ expect(element.textContent).toBe('Hello #tagWorld');
+ expect(element.querySelector('.search-trigger')).toBeTruthy();
+ });
+
+ it('should replace selected text with element', () => {
+ const range = document.createRange();
+ const selection = window.getSelection();
+ const textNode = element.firstChild as Text;
+
+ range.setStart(textNode, 6);
+ range.setEnd(textNode, 11); // Select "World"
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ const span = document.createElement('span');
+ span.textContent = 'Universe';
+ service.insertElementAtCursor(span, element);
+
+ expect(element.textContent).toBe('Hello Universe');
+ });
+
+ it('should not insert when no selection exists', () => {
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+
+ const childrenBefore = element.childNodes.length;
+ const span = document.createElement('span');
+ service.insertElementAtCursor(span, element);
+ expect(element.childNodes.length).toBe(childrenBefore);
+ });
+
+ it('should warn and not insert for non-contentEditable element', () => {
+ const nonEditable = document.createElement('div');
+ nonEditable.textContent = 'Not editable';
+ document.body.appendChild(nonEditable);
+
+ const consoleSpy = vi.spyOn(console, 'warn');
+ const span = document.createElement('span');
+ service.insertElementAtCursor(span, nonEditable);
+
+ expect(consoleSpy).toHaveBeenCalledWith('Element must be contenteditable');
+ document.body.removeChild(nonEditable);
+ });
+ });
+
+ describe('deleteTextRange', () => {
+ it('should delete text range in middle', () => {
+ service.deleteTextRange(6, 11, element); // Delete "World"
+ expect(element.textContent).toBe('Hello ');
+ });
+
+ it('should delete from start', () => {
+ service.deleteTextRange(0, 6, element); // Delete "Hello "
+ expect(element.textContent).toBe('World');
+ });
+
+ it('should delete entire content', () => {
+ service.deleteTextRange(0, 11, element);
+ expect(element.textContent).toBe('');
+ });
+
+ it('should delete single character', () => {
+ service.deleteTextRange(5, 6, element); // Delete space
+ expect(element.textContent).toBe('HelloWorld');
+ });
+
+ it('should set cursor position after deletion', () => {
+ service.deleteTextRange(6, 11, element); // Delete "World"
+ const cursorPos = service.getCursorPosition(element);
+ expect(cursorPos).toBe(6);
+ });
+
+ it('should handle nested elements', () => {
+ element.innerHTML = '';
+ element.appendChild(document.createTextNode('Hello '));
+ const span = document.createElement('span');
+ span.textContent = 'World';
+ element.appendChild(span);
+
+ service.deleteTextRange(6, 9, element); // Delete "Wor"
+ expect(element.textContent).toBe('Hello ld');
+ });
+
+ it('should warn for non-contentEditable element', () => {
+ const nonEditable = document.createElement('div');
+ nonEditable.textContent = 'Not editable';
+ document.body.appendChild(nonEditable);
+
+ const consoleSpy = vi.spyOn(console, 'warn');
+ service.deleteTextRange(0, 3, nonEditable);
+
+ expect(consoleSpy).toHaveBeenCalledWith('Element must be contenteditable');
+ expect(nonEditable.textContent).toBe('Not editable'); // Unchanged
+ document.body.removeChild(nonEditable);
+ });
+
+ it('should handle invalid range gracefully', () => {
+ const originalText = element.textContent;
+ const consoleErrorSpy = vi.spyOn(console, 'error');
+
+ // Try to delete with reversed positions (though implementation may handle this)
+ service.deleteTextRange(999, 1000, element);
+
+ // Text should either be unchanged or empty depending on implementation
+ // We just verify it doesn't crash
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('getPreviousNode', () => {
+ it('should return null when no selection exists', () => {
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+
+ const result = service.getPreviousNode();
+ expect(result).toBeNull();
+ });
+
+ it('should return null when cursor is at start', () => {
+ service.setCursorPosition(element, 0);
+ const result = service.getPreviousNode();
+ expect(result).toBeNull();
+ });
+
+ it('should return null when cursor is in text node', () => {
+ service.setCursorPosition(element, 5);
+ const result = service.getPreviousNode();
+ expect(result).toBeNull();
+ });
+
+ it('should return search trigger element when cursor is after it', () => {
+ element.innerHTML = 'Text ';
+ const trigger = document.createElement('span');
+ trigger.className = 'search-trigger';
+ trigger.textContent = '#tag';
+ element.appendChild(trigger);
+ element.appendChild(document.createTextNode(' more'));
+
+ // Position cursor right after the trigger element
+ const range = document.createRange();
+ const selection = window.getSelection();
+ range.setStart(element, 2); // After text node and trigger
+ range.setEnd(element, 2);
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ const result = service.getPreviousNode();
+ expect(result).toBe(trigger);
+ });
+
+ it('should return null when previous element is not a search trigger', () => {
+ element.innerHTML = 'Text ';
+ const span = document.createElement('span');
+ span.textContent = 'regular span';
+ element.appendChild(span);
+ element.appendChild(document.createTextNode(' more'));
+
+ const range = document.createRange();
+ const selection = window.getSelection();
+ range.setStart(element, 2); // After text node and span
+ range.setEnd(element, 2);
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ const result = service.getPreviousNode();
+ expect(result).toBeNull();
+ });
+
+ it('should return null when selection is not collapsed', () => {
+ const range = document.createRange();
+ const selection = window.getSelection();
+ const textNode = element.firstChild as Text;
+
+ range.setStart(textNode, 0);
+ range.setEnd(textNode, 5); // Select "Hello"
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ const result = service.getPreviousNode();
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getNodeAtCursorPosition', () => {
+ it('should return node at cursor position', () => {
+ service.setCursorPosition(element, 5);
+ const node = service.getNodeAtCursorPosition();
+
+ // Should return the parent element or text node
+ expect(node).toBeTruthy();
+ });
+
+ it('should return null when no selection exists', () => {
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+
+ const node = service.getNodeAtCursorPosition();
+ // JSDOM may return an empty Selection, so node might be null or a node
+ // We just verify it doesn't crash
+ expect(node === null || node instanceof Node).toBe(true);
+ });
+
+ it('should return parent node for text nodes', () => {
+ service.setCursorPosition(element, 5);
+ const node = service.getNodeAtCursorPosition();
+
+ // Since cursor is in text, it should return the parent (element)
+ expect(node?.nodeType).toBe(Node.ELEMENT_NODE);
+ });
+
+ it('should handle nested elements', () => {
+ element.innerHTML = '';
+ element.appendChild(document.createTextNode('Hello '));
+ const span = document.createElement('span');
+ span.textContent = 'World';
+ element.appendChild(span);
+
+ service.setCursorPosition(element, 8); // Inside span
+ const node = service.getNodeAtCursorPosition();
+
+ expect(node).toBeTruthy();
+ });
+ });
+
+ describe('sanitizeToPlainText', () => {
+ it('should remove all HTML and keep plain text', () => {
+ element.innerHTML = 'Hello World';
+ service.setCursorPosition(element, 5);
+
+ service.sanitizeToPlainText(element);
+
+ expect(element.textContent).toBe('Hello World');
+ expect(element.innerHTML).toBe('Hello World');
+ });
+
+ it('should preserve cursor position after sanitization', () => {
+ element.innerHTML = 'Hello Bold World';
+ service.setCursorPosition(element, 11); // After "Hello Bold "
+
+ service.sanitizeToPlainText(element);
+
+ const cursorPos = service.getCursorPosition(element);
+ expect(cursorPos).toBe(11);
+ });
+
+ it('should handle cursor at start', () => {
+ element.innerHTML = 'Italic text';
+ service.setCursorPosition(element, 0);
+
+ service.sanitizeToPlainText(element);
+
+ expect(element.textContent).toBe('Italic text');
+ const cursorPos = service.getCursorPosition(element);
+ expect(cursorPos).toBe(0);
+ });
+
+ it('should handle cursor at end', () => {
+ element.innerHTML = 'Text with span';
+ const textLength = element.textContent?.length || 0;
+ service.setCursorPosition(element, textLength);
+
+ service.sanitizeToPlainText(element);
+
+ expect(element.textContent).toBe('Text with span');
+ const cursorPos = service.getCursorPosition(element);
+ expect(cursorPos).toBe(14);
+ });
+
+ it('should handle empty element', () => {
+ element.innerHTML = '';
+ service.sanitizeToPlainText(element);
+
+ expect(element.textContent).toBe('');
+ });
+
+ it('should handle element with only HTML tags', () => {
+ element.innerHTML = '
';
+ service.sanitizeToPlainText(element);
+
+ expect(element.innerHTML).toBe('');
+ });
+
+ it('should remove search trigger elements but keep text', () => {
+ element.innerHTML = 'Query ';
+ const trigger = document.createElement('span');
+ trigger.className = 'search-trigger';
+ trigger.textContent = '#tag';
+ element.appendChild(trigger);
+ element.appendChild(document.createTextNode(' text'));
+
+ service.sanitizeToPlainText(element);
+
+ expect(element.textContent).toBe('Query #tag text');
+ expect(element.querySelector('.search-trigger')).toBeNull();
+ });
+
+ it('should handle nested HTML structures', () => {
+ element.innerHTML = '
Nested content