/** * DomPatcher Tests * * Tests for lightweight DOM patching functionality used by LiveComponent * for efficient fragment-based partial rendering. */ import { DomPatcher, domPatcher } from '../../../../resources/js/modules/livecomponent/DomPatcher.js'; describe('DomPatcher', () => { let patcher; beforeEach(() => { patcher = new DomPatcher(); document.body.innerHTML = ''; }); describe('patchFragment', () => { it('patches a single fragment successfully', () => { // Setup container with fragment const container = document.createElement('div'); container.innerHTML = `

Statistics

Count: 5

`; document.body.appendChild(container); // Patch fragment with new content const newHtml = `

Statistics

Count: 10

`; const result = patcher.patchFragment(container, 'user-stats', newHtml); expect(result).toBe(true); expect(container.querySelector('[data-lc-fragment="user-stats"] p').textContent).toBe('Count: 10'); }); it('returns false when fragment not found', () => { const container = document.createElement('div'); container.innerHTML = '
No fragments here
'; const result = patcher.patchFragment(container, 'nonexistent', '
Test
'); expect(result).toBe(false); }); it('returns false for invalid HTML', () => { const container = document.createElement('div'); container.innerHTML = '
Test
'; const result = patcher.patchFragment(container, 'test', ''); expect(result).toBe(false); }); it('returns false when fragment name mismatch', () => { const container = document.createElement('div'); container.innerHTML = '
Test
'; const newHtml = '
Updated
'; const result = patcher.patchFragment(container, 'fragment-a', newHtml); expect(result).toBe(false); }); }); describe('patchFragments', () => { it('patches multiple fragments at once', () => { const container = document.createElement('div'); container.innerHTML = `

Old Header

Old Content

Old Footer

`; const fragments = { 'header': '

New Header

', 'content': '

New Content

' }; const results = patcher.patchFragments(container, fragments); expect(results.header).toBe(true); expect(results.content).toBe(true); expect(container.querySelector('[data-lc-fragment="header"] h1').textContent).toBe('New Header'); expect(container.querySelector('[data-lc-fragment="content"] p').textContent).toBe('New Content'); expect(container.querySelector('[data-lc-fragment="footer"] p').textContent).toBe('Old Footer'); }); it('returns success status for each fragment', () => { const container = document.createElement('div'); container.innerHTML = '
Test
'; const fragments = { 'exists': '
Updated
', 'missing': '
Not found
' }; const results = patcher.patchFragments(container, fragments); expect(results.exists).toBe(true); expect(results.missing).toBe(false); }); }); describe('patchAttributes', () => { it('updates changed attributes', () => { const oldElement = document.createElement('div'); oldElement.setAttribute('class', 'old-class'); oldElement.setAttribute('data-value', '5'); const newElement = document.createElement('div'); newElement.setAttribute('class', 'new-class'); newElement.setAttribute('data-value', '10'); patcher.patchAttributes(oldElement, newElement); expect(oldElement.getAttribute('class')).toBe('new-class'); expect(oldElement.getAttribute('data-value')).toBe('10'); }); it('adds new attributes', () => { const oldElement = document.createElement('div'); oldElement.setAttribute('class', 'test'); const newElement = document.createElement('div'); newElement.setAttribute('class', 'test'); newElement.setAttribute('data-new', 'value'); patcher.patchAttributes(oldElement, newElement); expect(oldElement.getAttribute('data-new')).toBe('value'); }); it('removes attributes that no longer exist', () => { const oldElement = document.createElement('div'); oldElement.setAttribute('class', 'test'); oldElement.setAttribute('data-old', 'value'); const newElement = document.createElement('div'); newElement.setAttribute('class', 'test'); patcher.patchAttributes(oldElement, newElement); expect(oldElement.hasAttribute('data-old')).toBe(false); expect(oldElement.getAttribute('class')).toBe('test'); }); it('does not update unchanged attributes', () => { const oldElement = document.createElement('div'); oldElement.setAttribute('class', 'unchanged'); const setAttributeSpy = jest.spyOn(oldElement, 'setAttribute'); const newElement = document.createElement('div'); newElement.setAttribute('class', 'unchanged'); patcher.patchAttributes(oldElement, newElement); expect(setAttributeSpy).not.toHaveBeenCalledWith('class', 'unchanged'); }); }); describe('patchChildren', () => { it('appends new child nodes', () => { const oldElement = document.createElement('div'); oldElement.innerHTML = '

First

'; const newElement = document.createElement('div'); newElement.innerHTML = '

First

Second

'; patcher.patchChildren(oldElement, newElement); expect(oldElement.children.length).toBe(2); expect(oldElement.children[1].textContent).toBe('Second'); }); it('removes deleted child nodes', () => { const oldElement = document.createElement('div'); oldElement.innerHTML = '

First

Second

'; const newElement = document.createElement('div'); newElement.innerHTML = '

First

'; patcher.patchChildren(oldElement, newElement); expect(oldElement.children.length).toBe(1); expect(oldElement.children[0].textContent).toBe('First'); }); it('updates text node content', () => { const oldElement = document.createElement('div'); oldElement.textContent = 'Old text'; const newElement = document.createElement('div'); newElement.textContent = 'New text'; patcher.patchChildren(oldElement, newElement); expect(oldElement.textContent).toBe('New text'); }); it('recursively patches nested elements', () => { const oldElement = document.createElement('div'); oldElement.innerHTML = '

Old

'; const newElement = document.createElement('div'); newElement.innerHTML = '

New

'; patcher.patchChildren(oldElement, newElement); expect(oldElement.querySelector('p').textContent).toBe('New'); }); it('replaces elements with different tags', () => { const oldElement = document.createElement('div'); oldElement.innerHTML = '

Content

'; const newElement = document.createElement('div'); newElement.innerHTML = 'Content'; patcher.patchChildren(oldElement, newElement); expect(oldElement.children[0].tagName).toBe('SPAN'); }); }); describe('shouldPatch', () => { it('returns false for different node types', () => { const textNode = document.createTextNode('text'); const elementNode = document.createElement('div'); expect(patcher.shouldPatch(textNode, elementNode)).toBe(false); }); it('returns true for text nodes', () => { const oldText = document.createTextNode('old'); const newText = document.createTextNode('new'); expect(patcher.shouldPatch(oldText, newText)).toBe(true); }); it('returns false for different element tags', () => { const divElement = document.createElement('div'); const spanElement = document.createElement('span'); expect(patcher.shouldPatch(divElement, spanElement)).toBe(false); }); it('returns true for same element tags', () => { const div1 = document.createElement('div'); const div2 = document.createElement('div'); expect(patcher.shouldPatch(div1, div2)).toBe(true); }); it('respects data-lc-key for element identity', () => { const element1 = document.createElement('div'); element1.setAttribute('data-lc-key', 'key-1'); const element2 = document.createElement('div'); element2.setAttribute('data-lc-key', 'key-2'); expect(patcher.shouldPatch(element1, element2)).toBe(false); }); it('patches elements with matching data-lc-key', () => { const element1 = document.createElement('div'); element1.setAttribute('data-lc-key', 'same-key'); const element2 = document.createElement('div'); element2.setAttribute('data-lc-key', 'same-key'); expect(patcher.shouldPatch(element1, element2)).toBe(true); }); it('uses id as fallback key', () => { const element1 = document.createElement('div'); element1.id = 'element-1'; const element2 = document.createElement('div'); element2.id = 'element-2'; expect(patcher.shouldPatch(element1, element2)).toBe(false); }); }); describe('preserveFocus', () => { it('preserves focused input element', () => { const container = document.createElement('div'); const input = document.createElement('input'); input.id = 'test-input'; input.value = 'Test'; container.appendChild(input); document.body.appendChild(container); input.focus(); input.setSelectionRange(2, 4); const restoreFocus = patcher.preserveFocus(container); // Simulate patching that might lose focus document.body.focus(); restoreFocus(); expect(document.activeElement).toBe(input); expect(input.selectionStart).toBe(2); expect(input.selectionEnd).toBe(4); }); it('returns no-op when no element is focused', () => { const container = document.createElement('div'); document.body.appendChild(container); const restoreFocus = patcher.preserveFocus(container); expect(typeof restoreFocus).toBe('function'); expect(() => restoreFocus()).not.toThrow(); }); it('returns no-op when focused element is outside container', () => { const container = document.createElement('div'); const outsideInput = document.createElement('input'); document.body.appendChild(container); document.body.appendChild(outsideInput); outsideInput.focus(); const restoreFocus = patcher.preserveFocus(container); expect(typeof restoreFocus).toBe('function'); expect(() => restoreFocus()).not.toThrow(); }); it('handles focus restoration failure gracefully', () => { const container = document.createElement('div'); const input = document.createElement('input'); input.name = 'test'; container.appendChild(input); document.body.appendChild(container); input.focus(); const restoreFocus = patcher.preserveFocus(container); // Remove the input before restoration container.removeChild(input); expect(() => restoreFocus()).not.toThrow(); }); }); describe('getElementSelector', () => { it('uses id as primary selector', () => { const element = document.createElement('div'); element.id = 'unique-id'; element.name = 'test-name'; expect(patcher.getElementSelector(element)).toBe('#unique-id'); }); it('uses name as fallback selector', () => { const element = document.createElement('input'); element.name = 'field-name'; expect(patcher.getElementSelector(element)).toBe('[name="field-name"]'); }); it('uses data-lc-key as selector', () => { const element = document.createElement('div'); element.setAttribute('data-lc-key', 'item-123'); expect(patcher.getElementSelector(element)).toBe('[data-lc-key="item-123"]'); }); it('falls back to tag name', () => { const element = document.createElement('span'); expect(patcher.getElementSelector(element)).toBe('span'); }); }); describe('singleton instance', () => { it('exports singleton domPatcher instance', () => { expect(domPatcher).toBeInstanceOf(DomPatcher); }); it('singleton is the same instance', () => { const instance1 = domPatcher; const instance2 = domPatcher; expect(instance1).toBe(instance2); }); }); describe('complex patching scenarios', () => { it('patches nested fragments with multiple changes', () => { const container = document.createElement('div'); container.innerHTML = `

Old Title

Old paragraph 1

Old paragraph 2

`; const newHtml = `

New Title

New paragraph 1

New paragraph 2

New paragraph 3

`; patcher.patchFragment(container, 'card', newHtml); const fragment = container.querySelector('[data-lc-fragment="card"]'); expect(fragment.querySelector('h2').textContent).toBe('New Title'); expect(fragment.querySelector('h2').className).toBe('new-title'); expect(fragment.querySelectorAll('.content p').length).toBe(3); expect(fragment.querySelector('button').getAttribute('data-action')).toBe('new'); }); it('handles whitespace and formatting changes', () => { const container = document.createElement('div'); container.innerHTML = '

Content

'; const newHtml = `

Content

`; const result = patcher.patchFragment(container, 'test', newHtml); expect(result).toBe(true); expect(container.querySelector('p').textContent).toBe('Content'); }); it('preserves event listeners on patched elements', () => { const container = document.createElement('div'); container.innerHTML = '
'; document.body.appendChild(container); const button = container.querySelector('#btn'); let clicked = false; button.addEventListener('click', () => { clicked = true; }); const newHtml = '
'; patcher.patchFragment(container, 'test', newHtml); // Event listener is preserved because element is patched, not replaced container.querySelector('#btn').click(); expect(clicked).toBe(true); }); }); describe('performance characteristics', () => { it('patches large fragment efficiently', () => { const container = document.createElement('div'); const itemCount = 100; // Build initial list const items = []; for (let i = 0; i < itemCount; i++) { items.push(`
  • Item ${i}
  • `); } container.innerHTML = ``; // Update half the items const updatedItems = []; for (let i = 0; i < itemCount; i++) { if (i % 2 === 0) { updatedItems.push(`
  • Updated Item ${i}
  • `); } else { updatedItems.push(`
  • Item ${i}
  • `); } } const newHtml = ``; const startTime = performance.now(); patcher.patchFragment(container, 'list', newHtml); const duration = performance.now() - startTime; // Should complete in reasonable time (< 100ms for 100 items) expect(duration).toBeLessThan(100); // Verify updates applied const firstItem = container.querySelector('[data-lc-key="item-0"]'); expect(firstItem.textContent).toBe('Updated Item 0'); }); }); });