From eab7e77303c4467caada107f9ea94c5b55f7a43e Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Fri, 31 Oct 2025 15:12:31 +0000 Subject: [PATCH] Add test coverage for InputService. --- __tests__/Services/InputService.test.ts | 715 +++++++++++++++++++++++- 1 file changed, 711 insertions(+), 4 deletions(-) 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

'; + service.setCursorPosition(element, 7); // After "Nested " + + service.sanitizeToPlainText(element); + + expect(element.textContent).toBe('Nested content'); + const cursorPos = service.getCursorPosition(element); + expect(cursorPos).toBe(7); + }); + }); + }); });