philomena/assets/js/utils/__tests__/dom.spec.ts
2022-01-31 14:28:38 -05:00

434 lines
15 KiB
TypeScript

import {
$,
$$,
clearEl,
escapeCss,
escapeHtml,
hideEl,
insertBefore,
makeEl,
onLeftClick,
removeEl,
showEl,
toggleEl,
whenReady,
findFirstTextNode,
} from '../dom';
import { getRandomArrayItem, getRandomIntBetween } from '../../../test/randomness';
import { fireEvent } from '@testing-library/dom';
describe('DOM Utilities', () => {
const mockSelectors = ['#id', '.class', 'div', '#a .complex--selector:not(:hover)'];
const hiddenClass = 'hidden';
const createHiddenElement: Document['createElement'] = (...params: Parameters<Document['createElement']>) => {
const el = document.createElement(...params);
el.classList.add(hiddenClass);
return el;
};
describe('$', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should call the native querySelector method on document by default', () => {
const spy = jest.spyOn(document, 'querySelector');
mockSelectors.forEach((selector, nthCall) => {
$(selector);
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
});
});
it('should call the native querySelector method on the passed element', () => {
const mockElement = document.createElement('br');
const spy = jest.spyOn(mockElement, 'querySelector');
mockSelectors.forEach((selector, nthCall) => {
// FIXME This will not be necessary once the file is properly typed
$(selector, mockElement as unknown as Document);
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
});
});
});
describe('$$', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should call the native querySelectorAll method on document by default', () => {
const spy = jest.spyOn(document, 'querySelectorAll');
mockSelectors.forEach((selector, nthCall) => {
$$(selector);
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
});
});
it('should call the native querySelectorAll method on the passed element', () => {
const mockElement = document.createElement('br');
const spy = jest.spyOn(mockElement, 'querySelectorAll');
mockSelectors.forEach((selector, nthCall) => {
// FIXME This will not be necessary once the file is properly typed
$$(selector, mockElement as unknown as Document);
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
});
});
});
describe('showEl', () => {
it(`should remove the ${hiddenClass} class from the provided element`, () => {
const mockElement = createHiddenElement('div');
showEl(mockElement);
expect(mockElement).not.toHaveClass(hiddenClass);
});
it(`should remove the ${hiddenClass} class from all provided elements`, () => {
const mockElements = [
createHiddenElement('div'),
createHiddenElement('a'),
createHiddenElement('strong'),
];
showEl(mockElements);
expect(mockElements[0]).not.toHaveClass(hiddenClass);
expect(mockElements[1]).not.toHaveClass(hiddenClass);
expect(mockElements[2]).not.toHaveClass(hiddenClass);
});
it(`should remove the ${hiddenClass} class from elements provided in multiple arrays`, () => {
const mockElements1 = [
createHiddenElement('div'),
createHiddenElement('a'),
];
const mockElements2 = [
createHiddenElement('strong'),
createHiddenElement('em'),
];
showEl(mockElements1, mockElements2);
expect(mockElements1[0]).not.toHaveClass(hiddenClass);
expect(mockElements1[1]).not.toHaveClass(hiddenClass);
expect(mockElements2[0]).not.toHaveClass(hiddenClass);
expect(mockElements2[1]).not.toHaveClass(hiddenClass);
});
});
describe('hideEl', () => {
it(`should add the ${hiddenClass} class to the provided element`, () => {
const mockElement = document.createElement('div');
hideEl(mockElement);
expect(mockElement).toHaveClass(hiddenClass);
});
it(`should add the ${hiddenClass} class to all provided elements`, () => {
const mockElements = [
document.createElement('div'),
document.createElement('a'),
document.createElement('strong'),
];
hideEl(mockElements);
expect(mockElements[0]).toHaveClass(hiddenClass);
expect(mockElements[1]).toHaveClass(hiddenClass);
expect(mockElements[2]).toHaveClass(hiddenClass);
});
it(`should add the ${hiddenClass} class to elements provided in multiple arrays`, () => {
const mockElements1 = [
document.createElement('div'),
document.createElement('a'),
];
const mockElements2 = [
document.createElement('strong'),
document.createElement('em'),
];
hideEl(mockElements1, mockElements2);
expect(mockElements1[0]).toHaveClass(hiddenClass);
expect(mockElements1[1]).toHaveClass(hiddenClass);
expect(mockElements2[0]).toHaveClass(hiddenClass);
expect(mockElements2[1]).toHaveClass(hiddenClass);
});
});
describe('toggleEl', () => {
it(`should toggle the ${hiddenClass} class on the provided element`, () => {
const mockVisibleElement = document.createElement('div');
toggleEl(mockVisibleElement);
expect(mockVisibleElement).toHaveClass(hiddenClass);
const mockHiddenElement = createHiddenElement('div');
toggleEl(mockHiddenElement);
expect(mockHiddenElement).not.toHaveClass(hiddenClass);
});
it(`should toggle the ${hiddenClass} class on all provided elements`, () => {
const mockElements = [
document.createElement('div'),
createHiddenElement('a'),
document.createElement('strong'),
createHiddenElement('em'),
];
toggleEl(mockElements);
expect(mockElements[0]).toHaveClass(hiddenClass);
expect(mockElements[1]).not.toHaveClass(hiddenClass);
expect(mockElements[2]).toHaveClass(hiddenClass);
expect(mockElements[3]).not.toHaveClass(hiddenClass);
});
it(`should toggle the ${hiddenClass} class on elements provided in multiple arrays`, () => {
const mockElements1 = [
createHiddenElement('div'),
document.createElement('a'),
];
const mockElements2 = [
createHiddenElement('strong'),
document.createElement('em'),
];
toggleEl(mockElements1, mockElements2);
expect(mockElements1[0]).not.toHaveClass(hiddenClass);
expect(mockElements1[1]).toHaveClass(hiddenClass);
expect(mockElements2[0]).not.toHaveClass(hiddenClass);
expect(mockElements2[1]).toHaveClass(hiddenClass);
});
});
describe('clearEl', () => {
it('should not throw an exception for empty element', () => {
const emptyElement = document.createElement('br');
expect(emptyElement.children).toHaveLength(0);
expect(() => clearEl(emptyElement)).not.toThrow();
expect(emptyElement.children).toHaveLength(0);
});
it('should remove a single child node', () => {
const baseElement = document.createElement('p');
baseElement.appendChild(document.createElement('br'));
expect(baseElement.children).toHaveLength(1);
clearEl(baseElement);
expect(baseElement.children).toHaveLength(0);
});
it('should remove a multiple child nodes', () => {
const baseElement = document.createElement('p');
const elementsToAdd = getRandomIntBetween(5, 10);
for (let i = 0; i < elementsToAdd; ++i) {
baseElement.appendChild(document.createElement('br'));
}
expect(baseElement.children).toHaveLength(elementsToAdd);
clearEl(baseElement);
expect(baseElement.children).toHaveLength(0);
});
it('should remove child nodes of elements provided in multiple arrays', () => {
const baseElement1 = document.createElement('p');
const elementsToAdd1 = getRandomIntBetween(5, 10);
for (let i = 0; i < elementsToAdd1; ++i) {
baseElement1.appendChild(document.createElement('br'));
}
expect(baseElement1.children).toHaveLength(elementsToAdd1);
const baseElement2 = document.createElement('p');
const elementsToAdd2 = getRandomIntBetween(5, 10);
for (let i = 0; i < elementsToAdd2; ++i) {
baseElement2.appendChild(document.createElement('br'));
}
expect(baseElement2.children).toHaveLength(elementsToAdd2);
clearEl([baseElement1], [baseElement2]);
expect(baseElement1.children).toHaveLength(0);
expect(baseElement2.children).toHaveLength(0);
});
});
describe('removeEl', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should throw error if element has no parent', () => {
const detachedElement = document.createElement('div');
expect(() => removeEl(detachedElement)).toThrow(/propert(y|ies).*null/);
});
it('should call the native removeElement method on parent', () => {
const parentNode = document.createElement('div');
const childNode = document.createElement('p');
parentNode.appendChild(childNode);
const spy = jest.spyOn(parentNode, 'removeChild');
removeEl(childNode);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenNthCalledWith(1, childNode);
});
});
describe('makeEl', () => {
it('should create br tag', () => {
const el = makeEl('br');
expect(el.nodeName).toEqual('BR');
});
it('should create a script tag', () => {
const mockSource = 'https://example.com/';
const el = makeEl('script', { src: mockSource, async: true, defer: true });
expect(el.nodeName).toEqual('SCRIPT');
expect(el.src).toEqual(mockSource);
expect(el.async).toEqual(true);
expect(el.defer).toEqual(true);
});
it('should create a link tag', () => {
const mockHref = 'https://example.com/';
const mockTarget = '_blank';
const el = makeEl('a', { href: mockHref, target: mockTarget });
expect(el.nodeName).toEqual('A');
expect(el.href).toEqual(mockHref);
expect(el.target).toEqual(mockTarget);
});
it('should create paragraph tag', () => {
const mockClassOne = 'class-one';
const mockClassTwo = 'class-two';
const el = makeEl('p', { className: `${mockClassOne} ${mockClassTwo}` });
expect(el.nodeName).toEqual('P');
expect(el).toHaveClass(mockClassOne);
expect(el).toHaveClass(mockClassTwo);
});
});
describe('insertBefore', () => {
it('should insert the new element before the existing element', () => {
const mockParent = document.createElement('p');
const mockExisingElement = document.createElement('span');
mockParent.appendChild(mockExisingElement);
const mockNewElement = document.createElement('strong');
insertBefore(mockExisingElement, mockNewElement);
expect(mockParent.children).toHaveLength(2);
expect(mockParent.children[0].tagName).toBe('STRONG');
expect(mockParent.children[1].tagName).toBe('SPAN');
});
it('should insert between two elements', () => {
const mockParent = document.createElement('p');
const mockFirstExisingElement = document.createElement('span');
const mockSecondExisingElement = document.createElement('em');
mockParent.appendChild(mockFirstExisingElement);
mockParent.appendChild(mockSecondExisingElement);
const mockNewElement = document.createElement('strong');
insertBefore(mockSecondExisingElement, mockNewElement);
expect(mockParent.children).toHaveLength(3);
expect(mockParent.children[0].tagName).toBe('SPAN');
expect(mockParent.children[1].tagName).toBe('STRONG');
expect(mockParent.children[2].tagName).toBe('EM');
});
it('should fail if there is no parent', () => {
const mockParent = document.createElement('p');
const mockNewElement = document.createElement('em');
expect(() => {
insertBefore(mockParent, mockNewElement);
}).toThrow(/propert(y|ies).*null/);
});
});
describe('onLeftClick', () => {
it('should call callback on left click', () => {
const mockCallback = jest.fn();
const element = document.createElement('div');
onLeftClick(mockCallback, element as unknown as Document);
fireEvent.click(element, { button: 0 });
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should NOT call callback on non-left click', () => {
const mockCallback = jest.fn();
const element = document.createElement('div');
onLeftClick(mockCallback, element as unknown as Document);
const mockButton = getRandomArrayItem([1, 2, 3, 4, 5]);
fireEvent.click(element, { button: mockButton });
expect(mockCallback).not.toHaveBeenCalled();
});
it('should add click event listener to the document by default', () => {
const mockCallback = jest.fn();
onLeftClick(mockCallback);
fireEvent.click(document.body);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
});
describe('whenReady', () => {
it('should call callback immediately if document ready state is not loading', () => {
const mockReadyStateValue = getRandomArrayItem<DocumentReadyState>(['complete', 'interactive']);
const readyStateSpy = jest.spyOn(document, 'readyState', 'get').mockReturnValue(mockReadyStateValue);
const mockCallback = jest.fn();
try {
whenReady(mockCallback);
expect(mockCallback).toHaveBeenCalledTimes(1);
}
finally {
readyStateSpy.mockRestore();
}
});
it('should add event listener with callback if document ready state is loading', () => {
const readyStateSpy = jest.spyOn(document, 'readyState', 'get').mockReturnValue('loading');
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
const mockCallback = jest.fn();
try {
whenReady(mockCallback);
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'DOMContentLoaded', mockCallback);
expect(mockCallback).not.toHaveBeenCalled();
}
finally {
readyStateSpy.mockRestore();
addEventListenerSpy.mockRestore();
}
});
});
describe('escapeHtml', () => {
it('should replace only the expected characters with their HTML entity equivalents', () => {
expect(escapeHtml('<script src="http://example.com/?a=1&b=2"></script>')).toBe('&lt;script src=&quot;http://example.com/?a=1&amp;b=2&quot;&gt;&lt;/script&gt;');
});
});
describe('escapeCss', () => {
it('should replace only the expected characters with their escaped equivalents', () => {
expect(escapeCss('url("https://example.com")')).toBe('url(\\"https://example.com\\")');
});
});
describe('findFirstTextNode', () => {
it('should return the first text node child', () => {
const mockText = `expected text ${Math.random()}`;
const mockNode = document.createElement('div');
mockNode.innerHTML = `<strong>bold</strong>${mockText}<em>italic</em>`;
const result: Node = findFirstTextNode(mockNode);
expect(result.nodeValue).toBe(mockText);
});
it('should return undefined if there is no text node child', () => {
const mockNode = document.createElement('div');
mockNode.innerHTML = '<strong>bold</strong><em>italic</em>';
const result: Node = findFirstTextNode(mockNode);
expect(result).toBe(undefined);
});
});
});