From baf8c1fddb94f5a25fac762954b1c2cb14336336 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Tue, 27 Aug 2019 15:36:06 +0100 Subject: [PATCH] Split helpers into separate files --- tests/javascripts/support/helpers.js | 565 +----------------- .../support/helpers/dom_interfaces.js | 49 ++ tests/javascripts/support/helpers/elements.js | 76 +++ tests/javascripts/support/helpers/events.js | 111 ++++ tests/javascripts/support/helpers/html.js | 37 ++ .../javascripts/support/helpers/rendering.js | 296 +++++++++ 6 files changed, 586 insertions(+), 548 deletions(-) create mode 100644 tests/javascripts/support/helpers/dom_interfaces.js create mode 100644 tests/javascripts/support/helpers/elements.js create mode 100644 tests/javascripts/support/helpers/events.js create mode 100644 tests/javascripts/support/helpers/html.js create mode 100644 tests/javascripts/support/helpers/rendering.js diff --git a/tests/javascripts/support/helpers.js b/tests/javascripts/support/helpers.js index 5c1408fff..620bba003 100644 --- a/tests/javascripts/support/helpers.js +++ b/tests/javascripts/support/helpers.js @@ -1,548 +1,17 @@ -function getDescriptorForProperty (prop, obj) { - const descriptors = Object.getOwnPropertyDescriptors(obj); - const prototype = Object.getPrototypeOf(obj); - - if ((descriptors !== {}) && (prop in descriptors)) { - return descriptors[prop]; - } - - // if not in this object's descriptors, check the prototype chain - if (prototype !== null) { - return getDescriptorForProperty(prop, prototype); - } - - // no descriptor for this prop and no prototypes left in the chain - return null; -}; - -const triggerEvent = (el, evtType, options) => { - const eventInit = { - bubbles: true, - cancelable: true - }; - let setPositionData = () => { - const browserUI = { - leftFrameBorder: 0, - topHeight: 100 - }; - const cursorOffset = { - x: 5, - y: 5 - }; - const elBoundingBox = el.getBoundingClientRect(); - - if (!eventInit.clientX) { eventInit.clientX = elBoundingBox.left + cursorOffset.x; } - if (!eventInit.clientY) { eventInit.clientY = elBoundingBox.top + cursorOffset.y; } - if (!eventInit.pageX) { eventInit.pageX = elBoundingBox.left + cursorOffset.x; } - if (!eventInit.pageY) { eventInit.pageY = elBoundingBox.top + cursorOffset.y; } - if (!eventInit.screenX) { eventInit.screenX = eventInit.clientX + browserUI.leftFrameBorder; } - if (!eventInit.screenY) { eventInit.screenY = eventInit.clientY + browserUI.topHeight; } - if (!eventInit.offsetX) { eventInit.offsetX = cursorOffset.x; } - if (!eventInit.offsetY) { eventInit.offsetY = cursorOffset.y; } - }; - let Instance; - - // mixin any specified event properties with the defaults - if (options && ('eventInit' in options)) { - Object.assign(eventInit, options.eventInit); - } - - // use event interface if specified - if (options && ('interface' in options)) { - Instance = options.interface; - } else { - - // otherwise, derive from the event type - switch (evtType) { - case 'click': - // click events are part of the MouseEvent interface - Instance = window.MouseEvent; - break; - case 'mousedown': - Instance = window.MouseEvent; - break; - case 'mouseup': - Instance = window.MouseEvent; - break; - case 'keydown': - Instance = window.KeyboardEvent; - break; - case 'keyup': - Instance = window.KeyboardEvent; - break; - default: - Instance = Event; - } - - } - - if (evtType === 'click') { - // hack for click events to simulate details of pointer interaction - setPositionData(); - } - - const evt = new Instance(evtType, eventInit); - - el.dispatchEvent(evt); -}; - -function getRadios (fields) { - const result = ''; - - return fields.map((field, idx) => { - const count = idx + 1; - - return ` -
- - -
`; - }).join("\n"); -}; - -function getRadioGroup (data) { - let radioGroup = document.createElement('div'); - - data.cssClasses.forEach(cssClass => radioGroup.classList.add(cssClass)); - radioGroup.innerHTML = ` -
-
- - Choose ${data.label} - - ${getRadios(data.fields)} -
-
`; - - return radioGroup; -}; - -function clickElementWithMouse (el) { - triggerEvent(el, 'mousedown'); - triggerEvent(el, 'mouseup'); - triggerEvent(el, 'click'); -}; - -function moveSelectionToRadio (el, options) { - // movement within a radio group with arrow keys fires no keyboard events - - // click event fired from option radio being activated - triggerEvent(el, 'click', { - eventInit: { pageX: 0 } - }); - -}; - -function activateRadioWithSpace (el) { - - // simulate events for space key press to confirm selection - // event for space key press - triggerEvent(el, 'keydown', { - eventInit: { which: 32 } - }); - // click event fired from option radio being activated - triggerEvent(el, 'click', { - eventInit: { pageX: 0 } - }); - -}; - -class ElementQuery { - constructor (el) { - this.el = el; - } - - get nodeName () { - return this.el.nodeName.toLowerCase(); - } - - get firstTextNodeValue () { - const textNodes = Array.from(this.el.childNodes).filter(el => el.nodeType === 3); - - return textNodes.length ? textNodes[0].nodeValue : undefined; - }; - // returns the elements attributes as an object - hasAttributesSetTo (mappings) { - if (!this.el.hasAttributes()) { return false; } - - const keys = Object.keys(mappings); - let matches = 0; - - keys.forEach(key => { - if (this.el.hasAttribute(key) && (this.el.attributes[key].value === mappings[key])) { - matches++; - } - }); - - return matches === keys.length; - } - - hasClass (classToken) { - return Array.from(this.el.classList).includes(classToken); - } - - is (state) { - const test = `_is${state.charAt(0).toUpperCase()}${state.slice(1)}`; - - if (ElementQuery.prototype.hasOwnProperty(test)) { - return this[test](); - } - } - - // looks for a sibling before the el that matches the supplied test function - // the test function gets sent each sibling, wrapped in an Element instance - getPreviousSibling (test) { - let node = this.el.previousElementSibling; - let el; - - while(node) { - el = element(node); - - if (test(el)) { - return node; - } - - node = node.previousElementSibling; - } - - return null; - } - - _isHidden () { - const display = window.getComputedStyle(this.el).getPropertyValue('display'); - - return display === 'none'; - } -}; - -class WindowMock { - constructor (jest) { - this._defaults = { - height: window.innerHeight, - width: window.innerWidth - }; - this.spies = { - document: {}, - window: {} - }; - this._jest = jest; - this._setSpies(); - this._plugJSDOM(); - } - - get top () { - return window.scrollY; - } - - get bottom () { - return window.scrollY + window.innerHeight; - } - - get height () { - return window.innerHeight; - } - - get width () { - return window.innerWidth - } - - get scrollPosition () { - return window.scrollY; - } - - _setSpies () { - - // remove calls to document.documentElement.clientHeight when jQuery is gone. It's called to support older browsers like IE8 - this.spies.document.clientHeight = this._jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => window.innerHeight); - - // remove calls to document.documentElement.clientWidth when jQuery is gone. It's called to support older browsers like IE8 - this.spies.document.clientWidth = this._jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => window.innerWidth); - - } - - _plugJSDOM () { - - const self = this; - - // JSDOM doesn't support .scrollTo - this.spies.window.scrollTo = this._jest.fn(function () { - let y; - - // data sent as props in an object - if (arguments.length === 1) { - y = arguments[0].y; - } else { - y = arguments[1]; - } - - self.scrollTo(y); - - }); - - window.scrollTo = this.spies.window.scrollTo; - - } - - setHeightTo (height) { - - window.innerHeight = height; - - } - - setWidthTo (width) { - - window.innerWidth = width; - - } - - resizeTo (dimensions) { - - this.setHeightTo(dimensions.height); - this.setWidthTo(dimensions.width); - triggerEvent(window, 'resize'); - - } - - scrollTo (scrollPosition) { - - document.documentElement.scrollTop = scrollPosition; - window.scrollY = scrollPosition; - window.pageYOffset = scrollPosition; - - triggerEvent(window, 'scroll'); - - } - - reset () { - - window.innerHeight = this._defaults.height; - window.innerWidth = this._defaults.width; - this.scrollTo(0); - - // reset all spies - Object.keys(this.spies).forEach(key => { - const objectSpies = this.spies[key]; - Object.keys(objectSpies).forEach(key => objectSpies[key].mockClear()); - }); - - } -} - -// Base class for mocking an DOM APi interfaces not in JSDOM -class DOMInterfaceMock { - - constructor (jest, spec) { - - // set up methods so their calls can be tracked - // leave implementation/return values to the test - spec.methods.forEach(method => this[method] = jest.fn(() => {})); - - // set up props - // any spies should be relative to the test so not set here - spec.props.forEach(prop => { - - Object.defineProperty(this, prop, { - get: () => this[prop], - set: value => this[prop] = value - }); - - }); - - } - -} - -// Very basic class for stubbing the Range interface -// Only contains methods required for current tests -class RangeMock extends DOMInterfaceMock { - - constructor (jest) { - super(jest, { props: [], methods: ['selectNodeContents'] }); - } - -} - -// Very basic class for stubbing the Selection interface -// Only contains methods required for current tests -class SelectionMock extends DOMInterfaceMock { - - constructor (jest) { - super(jest, { props: [], methods: ['removeAllRanges', 'addRange'] }); - } - -} -class ScreenRenderItem { - constructor (jest, node) { - - this._jest = jest; - this._node = node; - this._storeProps(); - this._mockAPICalls(); - - } - - setData (itemData) { - - // check all the item data is present - const itemProps = Object.keys(itemData); - const missingKeys = ScreenRenderItem.REQUIRED_PROPS.filter(prop => !itemProps.includes(prop)); - - this._data = {}; - - if (missingKeys.length) { - throw Error(`${itemData.name ? itemData.name : itemProps.join(', ')} is missing these properties: ${missingKeys.join(', ')}`); - } - - // default left if not set - if (!('offsetLeft' in itemData)) { itemData.offsetLeft = 0; } - - // copy onto internal store - Object.assign(this._data, itemData); - - } - - _getBoundingClientRect () { - const {offsetHeight, offsetWidth, offsetTop, offsetLeft} = this._data; - const x = offsetLeft - window.scrollX; - const y = offsetTop - window.scrollY; - - return { - 'x': x, - 'y': y, - 'top': (offsetHeight < 0) ? y + offsetHeight : y, - 'left': (offsetWidth < 0) ? x + offsetWidth : x, - 'bottom': (offsetTop + offsetHeight) - window.scrollY, - 'right': (offsetLeft + offsetWidth) - window.scrollX, - }; - - } - - reset () { - - // reset DOMRect mock - this._node.getBoundingClientRect.mockClear(); - - ScreenRenderItem.OFFSET_PROPS.forEach(prop => { - - if (prop in this._propStore) { - // replace property implementation - Object.defineProperty(this._node, prop, this._propStore[prop]); - } - - }); - - } - - _storeProps () { - - this._propStore = {}; - - ScreenRenderItem.OFFSET_PROPS.forEach(prop => { - const descriptor = getDescriptorForProperty(prop, this._node); - - if (descriptor !== null) { - this._propStore[prop] = descriptor; - } - }); - - } - - // mock any calls to the node's DOM API for position/dimension - _mockAPICalls () { - - // proxy boundingClientRect property calls to item data - this._jest.spyOn(this._node, 'getBoundingClientRect').mockImplementation(() => this._getBoundingClientRect()); - - // handle calls to offset properties - ScreenRenderItem.OFFSET_PROPS.forEach(prop => { - - this._jest.spyOn(this._node, prop, 'get').mockImplementation(() => this._data[prop]); - - // proxy DOM API sets for offsetValues (not possible to mock directly) - Object.defineProperty(this._node, prop, { - configurable: true, - set: jest.fn(value => { - this._data[prop] = value; - return true; - }) - }); - - }); - - } - -} -ScreenRenderItem.OFFSET_PROPS = ['offsetHeight', 'offsetWidth', 'offsetTop', 'offsetLeft']; -ScreenRenderItem.REQUIRED_PROPS = ['name', 'offsetHeight', 'offsetHeight', 'offsetWidth', 'offsetTop']; - -class ScreenMock { - constructor (jest) { - - this._jest = jest - this._items = {}; - - } - - mockPositionAndDimension (itemName, node, itemData) { - - if (itemName in this._items) { throw new Error(`${itemName} already has its position and dimension mocked`); } - - const data = Object.assign({ 'name': itemName }, itemData); - const item = new ScreenRenderItem(this._jest, node); - - item.setData(data); - - this._items[itemName] = item; - - } - - setWindow (windowData) { - - this.window = new WindowMock(this._jest); - - // check all the window data is present - const missingKeys = Object.keys(windowData).filter(key => !ScreenMock.REQUIRED_WINDOW_PROPS.includes(key)); - - if (missingKeys.length) { - throw Error(`Window definition is missing these properties: ${missingKeys.join(', ')}`); - } - - this.window.setHeightTo(windowData.height); - this.window.setWidthTo(windowData.width); - this.window.scrollTo(windowData.scrollTop); - - } - - scrollTo (scrollTop) { - - this.window.scrollTo(scrollTop); - - } - - reset () { - - Object.keys(this._items).forEach(itemName => this._items[itemName].reset()); - - } - -} -ScreenMock.REQUIRED_WINDOW_PROPS = ['height', 'width', 'scrollTop']; - -// function to ask certain questions of a DOM Element -const element = function (el) { - return new ElementQuery(el); -}; - -exports.triggerEvent = triggerEvent; -exports.clickElementWithMouse = clickElementWithMouse; -exports.moveSelectionToRadio = moveSelectionToRadio; -exports.activateRadioWithSpace = activateRadioWithSpace; -exports.RangeMock = RangeMock; -exports.SelectionMock = SelectionMock; -exports.getRadioGroup = getRadioGroup; -exports.getRadios = getRadios; -exports.element = element; -exports.WindowMock = WindowMock; -exports.ScreenMock = ScreenMock; +const events = require('./helpers/events.js'); +const domInterfaces = require('./helpers/dom_interfaces.js'); +const html = require('./helpers/html.js'); +const elements = require('./helpers/elements.js'); +const rendering = require('./helpers/rendering.js'); + +exports.triggerEvent = events.triggerEvent; +exports.clickElementWithMouse = events.clickElementWithMouse; +exports.moveSelectionToRadio = events.moveSelectionToRadio; +exports.activateRadioWithSpace = events.activateRadioWithSpace; +exports.RangeMock = domInterfaces.RangeMock; +exports.SelectionMock = domInterfaces.SelectionMock; +exports.getRadioGroup = html.getRadioGroup; +exports.getRadios = html.getRadios; +exports.element = elements.element; +exports.WindowMock = rendering.WindowMock; +exports.ScreenMock = rendering.ScreenMock; diff --git a/tests/javascripts/support/helpers/dom_interfaces.js b/tests/javascripts/support/helpers/dom_interfaces.js new file mode 100644 index 000000000..903a1da42 --- /dev/null +++ b/tests/javascripts/support/helpers/dom_interfaces.js @@ -0,0 +1,49 @@ +// helpers for mocking DOM interfaces +// see https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model#DOM_interfaces + +// Base class for mocking an DOM APi interfaces not in JSDOM +class DOMInterfaceMock { + + constructor (jest, spec) { + + // set up methods so their calls can be tracked + // leave implementation/return values to the test + spec.methods.forEach(method => this[method] = jest.fn(() => {})); + + // set up props + // any spies should be relative to the test so not set here + spec.props.forEach(prop => { + + Object.defineProperty(this, prop, { + get: () => this[prop], + set: value => this[prop] = value + }); + + }); + + } + +} + +// Very basic class for stubbing the Range interface +// Only contains methods required for current tests +class RangeMock extends DOMInterfaceMock { + + constructor (jest) { + super(jest, { props: [], methods: ['selectNodeContents'] }); + } + +} + +// Very basic class for stubbing the Selection interface +// Only contains methods required for current tests +class SelectionMock extends DOMInterfaceMock { + + constructor (jest) { + super(jest, { props: [], methods: ['removeAllRanges', 'addRange'] }); + } + +} + +exports.RangeMock = RangeMock; +exports.SelectionMock = SelectionMock; diff --git a/tests/javascripts/support/helpers/elements.js b/tests/javascripts/support/helpers/elements.js new file mode 100644 index 000000000..4ca14359e --- /dev/null +++ b/tests/javascripts/support/helpers/elements.js @@ -0,0 +1,76 @@ +// helpers for getting information about DOM nodes + +class ElementQuery { + constructor (el) { + this.el = el; + } + + get nodeName () { + return this.el.nodeName.toLowerCase(); + } + + get firstTextNodeValue () { + const textNodes = Array.from(this.el.childNodes).filter(el => el.nodeType === 3); + + return textNodes.length ? textNodes[0].nodeValue : undefined; + }; + // returns the elements attributes as an object + hasAttributesSetTo (mappings) { + if (!this.el.hasAttributes()) { return false; } + + const keys = Object.keys(mappings); + let matches = 0; + + keys.forEach(key => { + if (this.el.hasAttribute(key) && (this.el.attributes[key].value === mappings[key])) { + matches++; + } + }); + + return matches === keys.length; + } + + hasClass (classToken) { + return Array.from(this.el.classList).includes(classToken); + } + + is (state) { + const test = `_is${state.charAt(0).toUpperCase()}${state.slice(1)}`; + + if (ElementQuery.prototype.hasOwnProperty(test)) { + return this[test](); + } + } + + // looks for a sibling before the el that matches the supplied test function + // the test function gets sent each sibling, wrapped in an Element instance + getPreviousSibling (test) { + let node = this.el.previousElementSibling; + let el; + + while(node) { + el = element(node); + + if (test(el)) { + return node; + } + + node = node.previousElementSibling; + } + + return null; + } + + _isHidden () { + const display = window.getComputedStyle(this.el).getPropertyValue('display'); + + return display === 'none'; + } +} + +// function to ask certain questions of a DOM Element +function element (el) { + return new ElementQuery(el); +} + +exports.element = element; diff --git a/tests/javascripts/support/helpers/events.js b/tests/javascripts/support/helpers/events.js new file mode 100644 index 000000000..1ca061124 --- /dev/null +++ b/tests/javascripts/support/helpers/events.js @@ -0,0 +1,111 @@ +// helper to simulate events fired by interactions +// adds positional data for 'click' events to match behaviour of browsers + +const triggerEvent = (el, evtType, options) => { + const eventInit = { + bubbles: true, + cancelable: true + }; + let setPositionData = () => { + const browserUI = { + leftFrameBorder: 0, + topHeight: 100 + }; + const cursorOffset = { + x: 5, + y: 5 + }; + const elBoundingBox = el.getBoundingClientRect(); + + if (!eventInit.clientX) { eventInit.clientX = elBoundingBox.left + cursorOffset.x; } + if (!eventInit.clientY) { eventInit.clientY = elBoundingBox.top + cursorOffset.y; } + if (!eventInit.pageX) { eventInit.pageX = elBoundingBox.left + cursorOffset.x; } + if (!eventInit.pageY) { eventInit.pageY = elBoundingBox.top + cursorOffset.y; } + if (!eventInit.screenX) { eventInit.screenX = eventInit.clientX + browserUI.leftFrameBorder; } + if (!eventInit.screenY) { eventInit.screenY = eventInit.clientY + browserUI.topHeight; } + if (!eventInit.offsetX) { eventInit.offsetX = cursorOffset.x; } + if (!eventInit.offsetY) { eventInit.offsetY = cursorOffset.y; } + }; + let Instance; + + // mixin any specified event properties with the defaults + if (options && ('eventInit' in options)) { + Object.assign(eventInit, options.eventInit); + } + + // use event interface if specified + if (options && ('interface' in options)) { + Instance = options.interface; + } else { + + // otherwise, derive from the event type + switch (evtType) { + case 'click': + // click events are part of the MouseEvent interface + Instance = window.MouseEvent; + break; + case 'mousedown': + Instance = window.MouseEvent; + break; + case 'mouseup': + Instance = window.MouseEvent; + break; + case 'keydown': + Instance = window.KeyboardEvent; + break; + case 'keyup': + Instance = window.KeyboardEvent; + break; + default: + Instance = Event; + } + + } + + if (evtType === 'click') { + // hack for click events to simulate details of pointer interaction + setPositionData(); + } + + const evt = new Instance(evtType, eventInit); + + el.dispatchEvent(evt); +}; + +// helpers for simulating events fired by certain interactions +// simulates those fired in Chrome, other browsers vary the events fired + +function clickElementWithMouse (el) { + triggerEvent(el, 'mousedown'); + triggerEvent(el, 'mouseup'); + triggerEvent(el, 'click'); +}; + +function moveSelectionToRadio (el, options) { + // movement within a radio group with arrow keys fires no keyboard events + + // click event fired from option radio being activated + triggerEvent(el, 'click', { + eventInit: { pageX: 0 } + }); + +}; + +function activateRadioWithSpace (el) { + + // simulate events for space key press to confirm selection + // event for space key press + triggerEvent(el, 'keydown', { + eventInit: { which: 32 } + }); + // click event fired from option radio being activated + triggerEvent(el, 'click', { + eventInit: { pageX: 0 } + }); + +} + +exports.triggerEvent = triggerEvent; +exports.clickElementWithMouse = clickElementWithMouse; +exports.moveSelectionToRadio = moveSelectionToRadio; +exports.activateRadioWithSpace = activateRadioWithSpace; diff --git a/tests/javascripts/support/helpers/html.js b/tests/javascripts/support/helpers/html.js new file mode 100644 index 000000000..06dfa758d --- /dev/null +++ b/tests/javascripts/support/helpers/html.js @@ -0,0 +1,37 @@ +// helpers for generating patterns of HTML + +function getRadios (fields) { + const result = ''; + + return fields.map((field, idx) => { + const count = idx + 1; + + return ` +
+ + +
`; + }).join("\n"); +}; + +function getRadioGroup (data) { + let radioGroup = document.createElement('div'); + + data.cssClasses.forEach(cssClass => radioGroup.classList.add(cssClass)); + radioGroup.innerHTML = ` +
+
+ + Choose ${data.label} + + ${getRadios(data.fields)} +
+
`; + + return radioGroup; +}; + +exports.getRadios = getRadios; +exports.getRadioGroup = getRadioGroup; diff --git a/tests/javascripts/support/helpers/rendering.js b/tests/javascripts/support/helpers/rendering.js new file mode 100644 index 000000000..5d3fdd2d2 --- /dev/null +++ b/tests/javascripts/support/helpers/rendering.js @@ -0,0 +1,296 @@ +// helpers for mocking the getting and setting of: position, dimension and scroll position for: +// - elements on the page +// - the page +// - the window + +const triggerEvent = require('./events.js').triggerEvent; + +function getDescriptorForProperty (prop, obj) { + const descriptors = Object.getOwnPropertyDescriptors(obj); + const prototype = Object.getPrototypeOf(obj); + + if ((descriptors !== {}) && (prop in descriptors)) { + return descriptors[prop]; + } + + // if not in this object's descriptors, check the prototype chain + if (prototype !== null) { + return getDescriptorForProperty(prop, prototype); + } + + // no descriptor for this prop and no prototypes left in the chain + return null; +}; + +class WindowMock { + constructor (jest) { + this._defaults = { + height: window.innerHeight, + width: window.innerWidth + }; + this.spies = { + document: {}, + window: {} + }; + this._jest = jest; + this._setSpies(); + this._plugJSDOM(); + } + + get top () { + return window.scrollY; + } + + get bottom () { + return window.scrollY + window.innerHeight; + } + + get height () { + return window.innerHeight; + } + + get width () { + return window.innerWidth + } + + get scrollPosition () { + return window.scrollY; + } + + _setSpies () { + + // remove calls to document.documentElement.clientHeight when jQuery is gone. It's called to support older browsers like IE8 + this.spies.document.clientHeight = this._jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => window.innerHeight); + + // remove calls to document.documentElement.clientWidth when jQuery is gone. It's called to support older browsers like IE8 + this.spies.document.clientWidth = this._jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => window.innerWidth); + + } + + _plugJSDOM () { + + const self = this; + + // JSDOM doesn't support .scrollTo + this.spies.window.scrollTo = this._jest.fn(function () { + let y; + + // data sent as props in an object + if (arguments.length === 1) { + y = arguments[0].y; + } else { + y = arguments[1]; + } + + self.scrollTo(y); + + }); + + window.scrollTo = this.spies.window.scrollTo; + + } + + setHeightTo (height) { + + window.innerHeight = height; + + } + + setWidthTo (width) { + + window.innerWidth = width; + + } + + resizeTo (dimensions) { + + this.setHeightTo(dimensions.height); + this.setWidthTo(dimensions.width); + triggerEvent(window, 'resize'); + + } + + scrollTo (scrollPosition) { + + document.documentElement.scrollTop = scrollPosition; + window.scrollY = scrollPosition; + window.pageYOffset = scrollPosition; + + triggerEvent(window, 'scroll'); + + } + + reset () { + + window.innerHeight = this._defaults.height; + window.innerWidth = this._defaults.width; + this.scrollTo(0); + + // reset all spies + Object.keys(this.spies).forEach(key => { + const objectSpies = this.spies[key]; + Object.keys(objectSpies).forEach(key => objectSpies[key].mockClear()); + }); + + } +} + +class ScreenRenderItem { + constructor (jest, node) { + + this._jest = jest; + this._node = node; + this._storeProps(); + this._mockAPICalls(); + + } + + setData (itemData) { + + // check all the item data is present + const itemProps = Object.keys(itemData); + const missingKeys = ScreenRenderItem.REQUIRED_PROPS.filter(prop => !itemProps.includes(prop)); + + this._data = {}; + + if (missingKeys.length) { + throw Error(`${itemData.name ? itemData.name : itemProps.join(', ')} is missing these properties: ${missingKeys.join(', ')}`); + } + + // default left if not set + if (!('offsetLeft' in itemData)) { itemData.offsetLeft = 0; } + + // copy onto internal store + Object.assign(this._data, itemData); + + } + + _getBoundingClientRect () { + const {offsetHeight, offsetWidth, offsetTop, offsetLeft} = this._data; + const x = offsetLeft - window.scrollX; + const y = offsetTop - window.scrollY; + + return { + 'x': x, + 'y': y, + 'top': (offsetHeight < 0) ? y + offsetHeight : y, + 'left': (offsetWidth < 0) ? x + offsetWidth : x, + 'bottom': (offsetTop + offsetHeight) - window.scrollY, + 'right': (offsetLeft + offsetWidth) - window.scrollX, + }; + + } + + reset () { + + // reset DOMRect mock + this._node.getBoundingClientRect.mockClear(); + + ScreenRenderItem.OFFSET_PROPS.forEach(prop => { + + if (prop in this._propStore) { + // replace property implementation + Object.defineProperty(this._node, prop, this._propStore[prop]); + } + + }); + + } + + _storeProps () { + + this._propStore = {}; + + ScreenRenderItem.OFFSET_PROPS.forEach(prop => { + const descriptor = getDescriptorForProperty(prop, this._node); + + if (descriptor !== null) { + this._propStore[prop] = descriptor; + } + }); + + } + + // mock any calls to the node's DOM API for position/dimension + _mockAPICalls () { + + // proxy boundingClientRect property calls to item data + this._jest.spyOn(this._node, 'getBoundingClientRect').mockImplementation(() => this._getBoundingClientRect()); + + // handle calls to offset properties + ScreenRenderItem.OFFSET_PROPS.forEach(prop => { + + this._jest.spyOn(this._node, prop, 'get').mockImplementation(() => this._data[prop]); + + // proxy DOM API sets for offsetValues (not possible to mock directly) + Object.defineProperty(this._node, prop, { + configurable: true, + set: jest.fn(value => { + this._data[prop] = value; + return true; + }) + }); + + }); + + } + +} +ScreenRenderItem.OFFSET_PROPS = ['offsetHeight', 'offsetWidth', 'offsetTop', 'offsetLeft']; +ScreenRenderItem.REQUIRED_PROPS = ['name', 'offsetHeight', 'offsetHeight', 'offsetWidth', 'offsetTop']; + +class ScreenMock { + constructor (jest) { + + this._jest = jest + this._items = {}; + + } + + mockPositionAndDimension (itemName, node, itemData) { + + if (itemName in this._items) { throw new Error(`${itemName} already has its position and dimension mocked`); } + + const data = Object.assign({ 'name': itemName }, itemData); + const item = new ScreenRenderItem(this._jest, node); + + item.setData(data); + + this._items[itemName] = item; + + } + + setWindow (windowData) { + + this.window = new WindowMock(this._jest); + + // check all the window data is present + const missingKeys = Object.keys(windowData).filter(key => !ScreenMock.REQUIRED_WINDOW_PROPS.includes(key)); + + if (missingKeys.length) { + throw Error(`Window definition is missing these properties: ${missingKeys.join(', ')}`); + } + + this.window.setHeightTo(windowData.height); + this.window.setWidthTo(windowData.width); + this.window.scrollTo(windowData.scrollTop); + + } + + scrollTo (scrollTop) { + + this.window.scrollTo(scrollTop); + + } + + reset () { + + Object.keys(this._items).forEach(itemName => this._items[itemName].reset()); + + } + +} +ScreenMock.REQUIRED_WINDOW_PROPS = ['height', 'width', 'scrollTop']; + +exports.WindowMock = WindowMock; +exports.ScreenMock = ScreenMock;