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 = `
-
-
- `;
-
- 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 = `
+
+
+ `;
+
+ 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;