Add test coverage for InputService.

This commit is contained in:
Andrew Beal 2025-10-31 15:12:31 +00:00
parent 9b22f61bbe
commit eab7e77303

View file

@ -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 <strong>World</strong>';
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 <strong>Bold</strong> 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 = '<em>Italic</em> 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 <span>with span</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 = '<br><br>';
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 = '<div><p>Nested <strong>content</strong></p></div>';
service.setCursorPosition(element, 7); // After "Nested "
service.sanitizeToPlainText(element);
expect(element.textContent).toBe('Nested content');
const cursorPos = service.getCursorPosition(element);
expect(cursorPos).toBe(7);
});
});
});
});