diff --git a/src/components/monitor-list/monitor-list.jsx b/src/components/monitor-list/monitor-list.jsx index d05ac6a3e8c159aa21ad4b385df1bb01e56833ca..7d63a66d161ed653518b1b7cb2f889e1cbd2853d 100644 --- a/src/components/monitor-list/monitor-list.jsx +++ b/src/components/monitor-list/monitor-list.jsx @@ -11,10 +11,9 @@ const MonitorList = props => ( <Box className={styles.monitorList} > - {props.monitors.valueSeq().map((monitorData, index) => ( + {props.monitors.valueSeq().map(monitorData => ( <Monitor id={monitorData.id} - index={index} key={monitorData.id} opcode={monitorData.opcode} params={monitorData.params} diff --git a/src/components/monitor/monitor.jsx b/src/components/monitor/monitor.jsx index 5d03b1ee495b928e02b5f00bb812b97e1d398c44..35a2cac17429ff04052ceffbcfd8ea2d406b3aa2 100644 --- a/src/components/monitor/monitor.jsx +++ b/src/components/monitor/monitor.jsx @@ -16,13 +16,12 @@ const MonitorComponent = props => ( <Draggable bounds="parent" defaultClassNameDragging={styles.dragging} - defaultPosition={{ - x: props.x, - y: props.y - }} onStop={props.onDragEnd} > - <Box className={styles.monitor}> + <Box + className={styles.monitor} + componentRef={props.componentRef} + > <Box className={styles.label}> {props.label} </Box> @@ -40,17 +39,14 @@ MonitorComponent.categories = categories; MonitorComponent.propTypes = { category: PropTypes.oneOf(Object.keys(categories)), + componentRef: PropTypes.func.isRequired, label: PropTypes.string.isRequired, onDragEnd: PropTypes.func.isRequired, - value: PropTypes.string.isRequired, - x: PropTypes.number, - y: PropTypes.number + value: PropTypes.string.isRequired }; MonitorComponent.defaultProps = { - category: 'data', - x: 0, - y: 0 + category: 'data' }; export default MonitorComponent; diff --git a/src/containers/monitor-list.jsx b/src/containers/monitor-list.jsx index d8becdc668f2110388071ee230e83325715cba7c..85a3b20e3ec840808e27b99f7c81b6a338022cf4 100644 --- a/src/containers/monitor-list.jsx +++ b/src/containers/monitor-list.jsx @@ -1,7 +1,9 @@ import bindAll from 'lodash.bindall'; import React from 'react'; +import PropTypes from 'prop-types'; import {connect} from 'react-redux'; +import {moveMonitorRect} from '../reducers/monitor-layout'; import MonitorListComponent from '../components/monitor-list/monitor-list.jsx'; @@ -13,7 +15,7 @@ class MonitorList extends React.Component { ]); } handleMonitorChange (id, x, y) { // eslint-disable-line no-unused-vars - // @todo send this event to the VM + this.props.moveMonitorRect(id, x, y); } render () { return ( @@ -25,10 +27,15 @@ class MonitorList extends React.Component { } } +MonitorList.propTypes = { + moveMonitorRect: PropTypes.func.isRequired +}; const mapStateToProps = state => ({ monitors: state.monitors }); -const mapDispatchToProps = () => ({}); +const mapDispatchToProps = dispatch => ({ + moveMonitorRect: (id, x, y) => dispatch(moveMonitorRect(id, x, y)) +}); export default connect( mapStateToProps, diff --git a/src/containers/monitor.jsx b/src/containers/monitor.jsx index 92a4304ad883febe241479ff1b9f3fa239032e0c..8f454a0e87e39c9aa1858682d9cc8571051957b0 100644 --- a/src/containers/monitor.jsx +++ b/src/containers/monitor.jsx @@ -4,25 +4,69 @@ import PropTypes from 'prop-types'; import monitorAdapter from '../lib/monitor-adapter.js'; import MonitorComponent from '../components/monitor/monitor.jsx'; +import {addMonitorRect, getInitialPosition, resizeMonitorRect, removeMonitorRect} from '../reducers/monitor-layout'; + +import {connect} from 'react-redux'; class Monitor extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleDragEnd' + 'handleDragEnd', + 'setElement' ]); } + componentDidMount () { + let rect; + // Load the VM provided position if not loaded already + if (this.props.x && this.props.y && !this.props.monitorLayout.savedMonitorPositions[this.props.id]) { + rect = { + upperStart: {x: this.props.x, y: this.props.y}, + lowerEnd: {x: this.props.x + this.element.offsetWidth, y: this.props.y + this.element.offsetHeight} + }; + this.props.addMonitorRect(this.props.id, rect, true /* savePosition */); + } else { // Newly created user monitor + rect = getInitialPosition( + this.props.monitorLayout, this.props.id, this.element.offsetWidth, this.element.offsetHeight); + this.props.addMonitorRect(this.props.id, rect); + } + this.element.style.top = `${rect.upperStart.y}px`; + this.element.style.left = `${rect.upperStart.x}px`; + } + shouldComponentUpdate (nextProps, nextState) { + if (nextState !== this.state) { + return true; + } + for (const key of Object.getOwnPropertyNames(nextProps)) { + // Don't need to rerender when other monitors are moved. + // monitorLayout is only used during initial layout. + if (key !== 'monitorLayout' && nextProps[key] !== this.props[key]) { + return true; + } + } + return false; + } + componentDidUpdate () { + this.props.resizeMonitorRect(this.props.id, this.element.offsetWidth, this.element.offsetHeight); + } + componentWillUnmount () { + this.props.removeMonitorRect(this.props.id); + } handleDragEnd (e, {x, y}) { this.props.onDragEnd( this.props.id, - x, - y + parseInt(this.element.style.left, 10) + x, + parseInt(this.element.style.top, 10) + y ); } + setElement (monitorElt) { + this.element = monitorElt; + } render () { const monitorProps = monitorAdapter(this.props); return ( <MonitorComponent + componentRef={this.setElement} {...monitorProps} onDragEnd={this.handleDragEnd} /> @@ -31,13 +75,32 @@ class Monitor extends React.Component { } Monitor.propTypes = { + addMonitorRect: PropTypes.func.isRequired, id: PropTypes.string.isRequired, - index: PropTypes.number.isRequired, // eslint-disable-line react/no-unused-prop-types + monitorLayout: PropTypes.shape({ + monitors: PropTypes.object, + savedMonitorPositions: PropTypes.object + }).isRequired, onDragEnd: PropTypes.func.isRequired, opcode: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types params: PropTypes.object, // eslint-disable-line react/no-unused-prop-types, react/forbid-prop-types + removeMonitorRect: PropTypes.func.isRequired, + resizeMonitorRect: PropTypes.func.isRequired, spriteName: PropTypes.string, // eslint-disable-line react/no-unused-prop-types - value: PropTypes.string.isRequired // eslint-disable-line react/no-unused-prop-types + value: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types + x: PropTypes.number, + y: PropTypes.number }; - -export default Monitor; +const mapStateToProps = state => ({ + monitorLayout: state.monitorLayout +}); +const mapDispatchToProps = dispatch => ({ + addMonitorRect: (id, rect, savePosition) => + dispatch(addMonitorRect(id, rect.upperStart, rect.lowerEnd, savePosition)), + resizeMonitorRect: (id, newWidth, newHeight) => dispatch(resizeMonitorRect(id, newWidth, newHeight)), + removeMonitorRect: id => dispatch(removeMonitorRect(id)) +}); +export default connect( + mapStateToProps, + mapDispatchToProps +)(Monitor); diff --git a/src/lib/monitor-adapter.js b/src/lib/monitor-adapter.js index 7637a00fe81347361f6f3afd79208775d796e1cc..95244b17539ec83e78b01e8dd8439523010e042f 100644 --- a/src/lib/monitor-adapter.js +++ b/src/lib/monitor-adapter.js @@ -1,45 +1,33 @@ import OpcodeLabels from './opcode-labels.js'; -const PADDING = 5; -const MONITOR_HEIGHT = 23; - const isUndefined = a => typeof a === 'undefined'; /** * Convert monitors from VM format to what the GUI needs to render. * - Convert opcode to a label and a category - * - Add missing XY position data if needed - * @param {object} block - The monitor block * @param {string} block.id - The id of the monitor block * @param {string} block.spriteName - Present only if the monitor applies only to the sprite * with given target ID. The name of the target sprite when the monitor was created - * @param {number} block.index - The index of the monitor * @param {string} block.opcode - The opcode of the monitor * @param {object} block.params - Extra params to the monitor block * @param {string} block.value - The monitor value - * @param {number} x - The monitor x position - * @param {number} y - The monitor y position * @return {object} The adapted monitor with label and category */ -export default function ({id, spriteName, index, opcode, params, value, x, y}) { +export default function ({id, spriteName, opcode, params, value}) { let {label, category, labelFn} = OpcodeLabels(opcode); // Use labelFn if provided for dynamic labelling (e.g. variables) if (!isUndefined(labelFn)) label = labelFn(params); + // Append sprite name for sprite-specific monitors if (spriteName) { label = `${spriteName}: ${label}`; } - // Simple layout if x or y are undefined - // @todo scratch2 has a more complex layout behavior we may want to adopt - // @todo e.g. this does not work well when monitors have already been moved - if (isUndefined(x)) x = PADDING; - if (isUndefined(y)) y = PADDING + (index * (PADDING + MONITOR_HEIGHT)); - + // If value is a number, round it to six decimal places if (typeof value === 'number' || (typeof value === 'string' && String(parseFloat(value)) === value)) { value = Number(Number(value).toFixed(6)); } - return {id, label, category, value, x, y}; + return {id, label, category, value}; } diff --git a/src/reducers/gui.js b/src/reducers/gui.js index 1dfa4ea76c3ddb04b5d3fb00ddbe2b81b14e6c2a..97e24dfe8d8ef1a02ebe8791e1889193a76bef0c 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -4,6 +4,7 @@ import customProceduresReducer from './custom-procedures'; import intlReducer from './intl'; import modalReducer from './modals'; import monitorReducer from './monitors'; +import monitorLayoutReducer from './monitor-layout'; import targetReducer from './targets'; import toolboxReducer from './toolbox'; import vmReducer from './vm'; @@ -15,6 +16,7 @@ export default combineReducers({ intl: intlReducer, modals: modalReducer, monitors: monitorReducer, + monitorLayout: monitorLayoutReducer, targets: targetReducer, toolbox: toolboxReducer, vm: vmReducer, diff --git a/src/reducers/monitor-layout.js b/src/reducers/monitor-layout.js new file mode 100644 index 0000000000000000000000000000000000000000..55cb52ee52be04b42c30ba81f5b6c604eca282e2 --- /dev/null +++ b/src/reducers/monitor-layout.js @@ -0,0 +1,318 @@ +import log from '../lib/log'; + +const ADD_MONITOR_RECT = 'scratch-gui/monitors/ADD_MONITOR_RECT'; +const MOVE_MONITOR_RECT = 'scratch-gui/monitors/MOVE_MONITOR_RECT'; +const RESIZE_MONITOR_RECT = 'scratch-gui/monitors/RESIZE_MONITOR_RECT'; +const REMOVE_MONITOR_RECT = 'scratch-gui/monitors/REMOVE_MONITOR_RECT'; + +const initialState = { + monitors: {}, + savedMonitorPositions: {} +}; + +// Verify that the rectangle formed by the 2 points is well-formed +const _verifyRect = function (upperStart, lowerEnd) { + if (isNaN(upperStart.x) || isNaN(upperStart.y) || isNaN(lowerEnd.x) || isNaN(lowerEnd.y)) { + return false; + } + if (!(upperStart.x < lowerEnd.x)) { + return false; + } + if (!(upperStart.y < lowerEnd.y)) { + return false; + } + return true; +}; + +const _addMonitorRect = function (state, action) { + if (state.monitors.hasOwnProperty(action.monitorId)) { + log.error(`Can't add monitor, monitor with id ${action.monitorId} already exists.`); + return state; + } + if (!_verifyRect(action.upperStart, action.lowerEnd)) { + log.error(`Monitor rectangle not formatted correctly`); + return state; + } + return { + monitors: Object.assign({}, state.monitors, { + [action.monitorId]: { + upperStart: action.upperStart, + lowerEnd: action.lowerEnd + } + }), + savedMonitorPositions: action.savePosition ? + Object.assign({}, state.savedMonitorPositions, { + [action.monitorId]: {x: action.upperStart.x, y: action.upperStart.y} + }) : + state.savedMonitorPositions + }; +}; + +const _moveMonitorRect = function (state, action) { + if (!state.monitors.hasOwnProperty(action.monitorId)) { + log.error(`Can't move monitor, monitor with id ${action.monitorId} does not exist.`); + return state; + } + if (isNaN(action.newX) || isNaN(action.newY)) { + log.error(`Monitor rectangle not formatted correctly`); + return state; + } + + const oldMonitor = state.monitors[action.monitorId]; + if (oldMonitor.upperStart.x === action.newX && + oldMonitor.upperStart.y === action.newY) { + // Hasn't moved + return state; + } + const monitorWidth = oldMonitor.lowerEnd.x - oldMonitor.upperStart.x; + const monitorHeight = oldMonitor.lowerEnd.y - oldMonitor.upperStart.y; + return { + monitors: Object.assign({}, state.monitors, { + [action.monitorId]: { + upperStart: {x: action.newX, y: action.newY}, + lowerEnd: {x: action.newX + monitorWidth, y: action.newY + monitorHeight} + } + }), + // User generated position is saved + savedMonitorPositions: Object.assign({}, state.savedMonitorPositions, { + [action.monitorId]: {x: action.newX, y: action.newY} + }) + }; +}; + +const _resizeMonitorRect = function (state, action) { + if (!state.monitors.hasOwnProperty(action.monitorId)) { + log.error(`Can't resize monitor, monitor with id ${action.monitorId} does not exist.`); + return state; + } + if (isNaN(action.newWidth) || isNaN(action.newHeight) || + action.newWidth <= 0 || action.newHeight <= 0) { + log.error(`Monitor rectangle not formatted correctly`); + return state; + } + + const oldMonitor = state.monitors[action.monitorId]; + const newMonitor = { + upperStart: oldMonitor.upperStart, + lowerEnd: { + x: oldMonitor.upperStart.x + action.newWidth, + y: oldMonitor.upperStart.y + action.newHeight + } + }; + if (newMonitor.lowerEnd.x === oldMonitor.lowerEnd.x && + newMonitor.lowerEnd.y === oldMonitor.lowerEnd.y) { + // no change + return state; + } + + return { + monitors: Object.assign({}, state.monitors, {[action.monitorId]: newMonitor}), + savedMonitorPositions: state.savedMonitorPositions + }; + +}; + +const _removeMonitorRect = function (state, action) { + if (!state.monitors.hasOwnProperty(action.monitorId)) { + log.error(`Can't remove monitor, monitor with id ${action.monitorId} does not exist.`); + return state; + } + + const newMonitors = Object.assign({}, state.monitors); + delete newMonitors[action.monitorId]; + return { + monitors: newMonitors, + savedMonitorPositions: state.savedMonitorPositions + }; +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case ADD_MONITOR_RECT: + return _addMonitorRect(state, action); + case MOVE_MONITOR_RECT: + return _moveMonitorRect(state, action); + case RESIZE_MONITOR_RECT: + return _resizeMonitorRect(state, action); + case REMOVE_MONITOR_RECT: + return _removeMonitorRect(state, action); + default: + return state; + } +}; + +// Init position -------------------------- +const PADDING = 5; +// @todo fix these numbers when we fix https://github.com/LLK/scratch-gui/issues/980 +const SCREEN_WIDTH = 400; +const SCREEN_HEIGHT = 300; +const SCREEN_EDGE_BUFFER = 40; + +const _rectsIntersect = function (rect1, rect2) { + // If one rectangle is on left side of other + if (rect1.upperStart.x >= rect2.lowerEnd.x || rect2.upperStart.x >= rect1.lowerEnd.x) return false; + // If one rectangle is above other + if (rect1.upperStart.y >= rect2.lowerEnd.y || rect2.upperStart.y >= rect1.lowerEnd.y) return false; + return true; +}; + +// We need to place a monitor with the given width and height. Return a rect defining where it should be placed. +const getInitialPosition = function (state, monitorId, eltWidth, eltHeight) { + // If this monitor was purposefully moved to a certain position before, put it back in that position + if (state.savedMonitorPositions.hasOwnProperty(monitorId)) { + const saved = state.savedMonitorPositions[monitorId]; + return { + upperStart: saved, + lowerEnd: {x: saved.x + eltWidth, y: saved.y + eltHeight} + }; + } + + // Try all starting positions for the new monitor to find one that doesn't intersect others + const endXs = [0]; + const endYs = [0]; + let lastX = null; + let lastY = null; + for (const monitor in state.monitors) { + let x = state.monitors[monitor].lowerEnd.x; + x = Math.ceil(x / 50) * 50; // Try to choose a sensible "tab width" so more monitors line up + endXs.push(x); + endYs.push(Math.ceil(state.monitors[monitor].lowerEnd.y)); + } + endXs.sort((a, b) => a - b); + endYs.sort((a, b) => a - b); + // We'll use plan B if the monitor doesn't fit anywhere (too long or tall) + let planB = null; + for (const x of endXs) { + if (x === lastX) { + continue; + } + lastX = x; + outer: + for (const y of endYs) { + if (y === lastY) { + continue; + } + lastY = y; + const monitorRect = { + upperStart: {x: x + PADDING, y: y + PADDING}, + lowerEnd: {x: x + PADDING + eltWidth, y: y + PADDING + eltHeight} + }; + // Intersection testing rect that includes padding + const rect = { + upperStart: {x, y}, + lowerEnd: {x: x + eltWidth + (2 * PADDING), y: y + eltHeight + (2 * PADDING)} + }; + for (const monitor in state.monitors) { + if (_rectsIntersect(state.monitors[monitor], rect)) { + continue outer; + } + } + // If the rect overlaps the ends of the screen + if (rect.lowerEnd.x > SCREEN_WIDTH || rect.lowerEnd.y > SCREEN_HEIGHT) { + // If rect is not too close to completely off screen, set it as plan B + if (!planB && + !(rect.upperStart.x + SCREEN_EDGE_BUFFER > SCREEN_WIDTH || + rect.upperStart.y + SCREEN_EDGE_BUFFER > SCREEN_HEIGHT)) { + planB = monitorRect; + } + continue; + } + return monitorRect; + } + } + // If the monitor is too long to fit anywhere, put it in the leftmost spot available + // that intersects the right or bottom edge and isn't too close to the edge. + if (planB) { + return planB; + } + + // If plan B fails and there's nowhere reasonable to put it, plan C is to place the monitor randomly + const randX = Math.ceil(Math.random() * (SCREEN_WIDTH / 2)); + const randY = Math.ceil(Math.random() * (SCREEN_HEIGHT - SCREEN_EDGE_BUFFER)); + return { + upperStart: { + x: randX, + y: randY + }, + lowerEnd: { + x: randX + eltWidth, + y: randY + eltHeight + } + }; +}; + +// Action creators ------------------------ +/** + * @param {!string} monitorId Id to add + * @param {!object} upperStart upper point defining the rectangle + * @param {!number} upperStart.x X of top point that defines the monitor location + * @param {!number} upperStart.y Y of top point that defines the monitor location + * @param {!object} lowerEnd lower point defining the rectangle + * @param {!number} lowerEnd.x X of bottom point that defines the monitor location + * @param {!number} lowerEnd.y Y of bottom point that defines the monitor location + * @param {?boolean} savePosition True if the placement should be saved when adding the monitor + * @returns {object} action to add a new monitor at the location + */ +const addMonitorRect = function (monitorId, upperStart, lowerEnd, savePosition) { + return { + type: ADD_MONITOR_RECT, + monitorId: monitorId, + upperStart: upperStart, + lowerEnd: lowerEnd, + savePosition: savePosition + }; +}; + +/** + * @param {!string} monitorId Id for monitor to move + * @param {!number} newX X of top point that defines the monitor location + * @param {!number} newY Y of top point that defines the monitor location + * @returns {object} action to move an existing monitor to the location + */ +const moveMonitorRect = function (monitorId, newX, newY) { + return { + type: MOVE_MONITOR_RECT, + monitorId: monitorId, + newX: newX, + newY: newY + }; +}; + +/** + * @param {!string} monitorId Id for monitor to resize + * @param {!number} newWidth Width to set monitor to + * @param {!number} newHeight Height to set monitor to + * @returns {object} action to resize an existing monitor to the given dimensions + */ +const resizeMonitorRect = function (monitorId, newWidth, newHeight) { + return { + type: RESIZE_MONITOR_RECT, + monitorId: monitorId, + newWidth: newWidth, + newHeight: newHeight + }; +}; + +/** + * @param {!string} monitorId Id for monitor to remove + * @returns {object} action to remove an existing monitor + */ +const removeMonitorRect = function (monitorId) { + return { + type: REMOVE_MONITOR_RECT, + monitorId: monitorId + }; +}; + +export { + reducer as default, + addMonitorRect, + getInitialPosition, + moveMonitorRect, + resizeMonitorRect, + removeMonitorRect, + PADDING, + SCREEN_HEIGHT, + SCREEN_WIDTH +}; diff --git a/test/unit/reducers/monitor-layout-reducer.test.js b/test/unit/reducers/monitor-layout-reducer.test.js new file mode 100644 index 0000000000000000000000000000000000000000..ea45b20e47eceb7a1c4969fbfe1061cdb5d80d66 --- /dev/null +++ b/test/unit/reducers/monitor-layout-reducer.test.js @@ -0,0 +1,301 @@ +/* eslint-env jest */ +import monitorLayoutReducer from '../../../src/reducers/monitor-layout'; +import {addMonitorRect, moveMonitorRect} from '../../../src/reducers/monitor-layout'; +import {resizeMonitorRect, removeMonitorRect} from '../../../src/reducers/monitor-layout'; +import {getInitialPosition, PADDING, SCREEN_WIDTH, SCREEN_HEIGHT} from '../../../src/reducers/monitor-layout'; + +test('initialState', () => { + let defaultState; + + expect(monitorLayoutReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined(); + expect(monitorLayoutReducer(defaultState /* state */, {type: 'anything'} /* action */).monitors).toBeDefined(); + expect(monitorLayoutReducer(defaultState /* state */, {type: 'anything'} /* action */).savedMonitorPositions) + .toBeDefined(); +}); + +test('addMonitorRect', () => { + let defaultState; + const monitorId = 1; + const monitorId2 = 2; + const upperStart = {x: 100, y: 100}; + const lowerEnd = {x: 200, y: 200}; + + // Add a monitor rect + const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, lowerEnd)); + expect(reduxState.monitors[monitorId]).toBeDefined(); + expect(reduxState.monitors[monitorId].upperStart).toEqual(upperStart); + expect(reduxState.monitors[monitorId].lowerEnd).toEqual(lowerEnd); + // Add monitor rect doesn't save position + expect(reduxState.savedMonitorPositions[monitorId]).toBeUndefined(); + const reduxState2 = monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 0, 0)); + + // Add a second monitor rect + const reduxState3 = monitorLayoutReducer(reduxState2, addMonitorRect(monitorId2, upperStart, lowerEnd)); + expect(reduxState3.monitors[monitorId]).toBeDefined(); + expect(reduxState3.monitors[monitorId2]).toBeDefined(); + expect(reduxState3.monitors[monitorId2].upperStart).toEqual(upperStart); + expect(reduxState3.monitors[monitorId2].lowerEnd).toEqual(lowerEnd); + // Saved positions aren't changed by adding monitor + expect(reduxState3.savedMonitorPositions).toEqual(reduxState2.savedMonitorPositions); +}); + +test('addMonitorRectWithSavedPosition', () => { + let defaultState; + const monitorId = 1; + const upperStart = {x: 100, y: 100}; + const lowerEnd = {x: 200, y: 200}; + + // Add a monitor rect + const reduxState = monitorLayoutReducer(defaultState, + addMonitorRect(monitorId, upperStart, lowerEnd, true /* savePosition */)); + expect(reduxState.monitors[monitorId]).toBeDefined(); + expect(reduxState.monitors[monitorId].upperStart).toEqual(upperStart); + expect(reduxState.monitors[monitorId].lowerEnd).toEqual(lowerEnd); + // Save position + expect(reduxState.savedMonitorPositions[monitorId].x).toEqual(upperStart.x); + expect(reduxState.savedMonitorPositions[monitorId].y).toEqual(upperStart.y); +}); + +test('invalidRect', () => { + let defaultState; + const reduxState = monitorLayoutReducer(defaultState /* state */, {type: 'initialize'} /* action */); + + // Problem: x end is before x start + expect( + monitorLayoutReducer(reduxState, + addMonitorRect(1, {x: 100, y: 100}, {x: 10, y: 200}))) + .toEqual(reduxState); + + // Problem: y end is before y start + expect( + monitorLayoutReducer(reduxState, + addMonitorRect(1, {x: 100, y: 100}, {x: 200, y: 10}))) + .toEqual(reduxState); +}); + +test('invalidAddMonitorRect', () => { + let defaultState; + const monitorId = 1; + const upperStart = {x: 100, y: 100}; + const lowerEnd = {x: 200, y: 200}; + + // Add a monitor rect + const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, lowerEnd)); + // Try to add the same one + expect(monitorLayoutReducer(reduxState, addMonitorRect(monitorId, upperStart, lowerEnd))) + .toEqual(reduxState); +}); + +test('moveMonitorRect', () => { + let defaultState; + const monitorId = 1; + const monitorId2 = 2; + const width = 102; + const height = 101; + const upperStart = {x: 100, y: 100}; + const lowerEnd = {x: upperStart.x + width, y: upperStart.y + height}; + const movedToPosition = {x: 0, y: 0}; + const movedToPosition2 = {x: 543, y: 2}; + + // Add a monitor rect and move it. Expect it to be in monitors state and saved positions. + const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, lowerEnd)); + const reduxState2 = monitorLayoutReducer(reduxState, + moveMonitorRect(monitorId, movedToPosition.x, movedToPosition.y)); + expect(reduxState2.monitors[monitorId]).toBeDefined(); + expect(reduxState2.monitors[monitorId].upperStart).toEqual(movedToPosition); + expect(reduxState2.monitors[monitorId].lowerEnd.x).toEqual(movedToPosition.x + width); + expect(reduxState2.monitors[monitorId].lowerEnd.y).toEqual(movedToPosition.y + height); + expect(reduxState2.savedMonitorPositions[monitorId]).toBeDefined(); + expect(reduxState2.savedMonitorPositions[monitorId].x).toEqual(movedToPosition.x); + expect(reduxState2.savedMonitorPositions[monitorId].y).toEqual(movedToPosition.y); + + // Add a second monitor rect and move it. Expect there to now be 2 saved positions. + const reduxState3 = monitorLayoutReducer(reduxState2, addMonitorRect(monitorId2, upperStart, lowerEnd)); + const reduxState4 = monitorLayoutReducer(reduxState3, + moveMonitorRect(monitorId2, movedToPosition2.x, movedToPosition2.y)); + expect(reduxState4.savedMonitorPositions[monitorId]).toEqual(reduxState2.savedMonitorPositions[monitorId]); + expect(reduxState4.savedMonitorPositions[monitorId2].x).toEqual(movedToPosition2.x); + expect(reduxState4.savedMonitorPositions[monitorId2].y).toEqual(movedToPosition2.y); +}); + +test('invalidMoveMonitorRect', () => { + let defaultState; + let reduxState = monitorLayoutReducer(defaultState, {type: 'initialize'} /* action */); + const monitorId = 1; + + // Try to move a monitor rect that doesn't exist + expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 1 /* newX */, 1 /* newY */))) + .toEqual(reduxState); + + // Add the monitor to move + reduxState = monitorLayoutReducer(reduxState, addMonitorRect(monitorId, {x: 100, y: 100}, {x: 200, y: 200})); + + // Invalid newX + expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 'Oregon' /* newX */, 1 /* newY */))) + .toEqual(reduxState); + + // Invalid newY + expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 1 /* newX */))) + .toEqual(reduxState); +}); + +test('resizeMonitorRect', () => { + let defaultState; + const monitorId = 1; + const upperStart = {x: 100, y: 100}; + const newWidth = 10; + const newHeight = 20; + + // Add a monitor rect and resize it + const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, upperStart, {x: 200, y: 200})); + const reduxState2 = monitorLayoutReducer(reduxState, + resizeMonitorRect(monitorId, newWidth, newHeight)); + expect(reduxState2.monitors[monitorId]).toBeDefined(); + expect(reduxState2.monitors[monitorId].upperStart).toEqual(upperStart); + expect(reduxState2.monitors[monitorId].lowerEnd.x).toEqual(upperStart.x + newWidth); + expect(reduxState2.monitors[monitorId].lowerEnd.y).toEqual(upperStart.y + newHeight); + // Saved positions aren't changed by resizing monitor + expect(reduxState2.savedMonitorPositions).toEqual(reduxState.savedMonitorPositions); +}); + +test('invalidResizeMonitorRect', () => { + let defaultState; + let reduxState = monitorLayoutReducer(defaultState, {type: 'initialize'} /* action */); + const monitorId = 1; + + // Try to resize a monitor rect that doesn't exist + expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, 1 /* newWidth */, 1 /* newHeight */))) + .toEqual(reduxState); + + // Add the monitor to resize + reduxState = monitorLayoutReducer(reduxState, addMonitorRect(monitorId, {x: 100, y: 100}, {x: 200, y: 200})); + + // Invalid newWidth + expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, 'Oregon' /* newWidth */, 1 /* newHeight */))) + .toEqual(reduxState); + + // Invalid newHeight + expect(monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 1 /* newWidth */))) + .toEqual(reduxState); + + // newWidth < 0 + expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, -1 /* newWidth */, 1 /* newHeight */))) + .toEqual(reduxState); + + // newHeight < 0 + expect(monitorLayoutReducer(reduxState, resizeMonitorRect(monitorId, 1 /* newWidth */, -1 /* newHeight */))) + .toEqual(reduxState); +}); + +test('removeMonitorRect', () => { + let defaultState; + const monitorId = 1; + + // Add a monitor rect, move it, and remove it + const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(monitorId, + {x: 100, y: 100}, + {x: 200, y: 200} + )); + const reduxState2 = monitorLayoutReducer(reduxState, moveMonitorRect(monitorId, 0, 0)); + const reduxState3 = monitorLayoutReducer(reduxState2, removeMonitorRect(monitorId)); + expect(reduxState3.monitors[monitorId]).toBeUndefined(); + // Check that saved positions aren't changed by removing monitor + expect(reduxState3.savedMonitorPositions).toEqual(reduxState2.savedMonitorPositions); +}); + +test('invalidRemoveMonitorRect', () => { + let defaultState; + const reduxState = monitorLayoutReducer(defaultState, {type: 'initialize'} /* action */); + + // Try to remove a monitor rect that doesn't exist + expect(monitorLayoutReducer(reduxState, resizeMonitorRect(1))) + .toEqual(reduxState); +}); + +test('getInitialPosition_lineUpTopLeft', () => { + let defaultState; + const width = 100; + const height = 200; + // Add monitors to right and bottom, but there is a space in the top left + let reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1, + {x: width + PADDING, y: 0}, + {x: 100, y: height} + )); + reduxState = monitorLayoutReducer(defaultState, addMonitorRect(2, + {x: 0, y: height + PADDING}, + {x: width, y: 100} + )); + + // Check that the added monitor appears in the space + const rect = getInitialPosition(reduxState, 3, width, height); + expect(rect.upperStart).toBeDefined(); + expect(rect.lowerEnd).toBeDefined(); + expect(rect.lowerEnd.x - rect.upperStart.x).toEqual(width); + expect(rect.lowerEnd.y - rect.upperStart.y).toEqual(height); + expect(rect.upperStart.x).toEqual(PADDING); + expect(rect.upperStart.y).toEqual(PADDING); +}); + +test('getInitialPosition_savedPosition', () => { + const monitorId = 1; + const savedX = 100; + const savedY = 200; + const width = 7; + const height = 8; + const reduxState = { + monitors: {}, + savedMonitorPositions: {[monitorId]: {x: savedX, y: savedY}} + }; + + // Check that initial position uses saved state + const rect = getInitialPosition(reduxState, monitorId, width, height); + expect(rect.upperStart).toBeDefined(); + expect(rect.lowerEnd).toBeDefined(); + expect(rect.lowerEnd.x - rect.upperStart.x).toEqual(width); + expect(rect.lowerEnd.y - rect.upperStart.y).toEqual(height); + expect(rect.upperStart.x).toEqual(savedX); + expect(rect.upperStart.y).toEqual(savedY); +}); + +test('getInitialPosition_lineUpLeft', () => { + let defaultState; + const monitor1EndY = 60; + // Add a monitor that takes up the upper left corner + const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1, {x: 0, y: 0}, {x: 100, y: monitor1EndY})); + + // Check that added monitor is under it and lines up left + const rect = getInitialPosition(reduxState, 2, 20 /* width */, 20 /* height */); + expect(rect.upperStart.y >= monitor1EndY + PADDING).toBeTruthy(); +}); + +test('getInitialPosition_lineUpTop', () => { + let defaultState; + const monitor1EndX = 100; + // Add a monitor that takes up the whole left side + const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1, + {x: 0, y: 0}, + {x: monitor1EndX, y: SCREEN_HEIGHT} + )); + + // Check that added monitor is to the right of it and lines up top + const rect = getInitialPosition(reduxState, 2, 20 /* width */, 20 /* height */); + expect(rect.upperStart.y).toEqual(PADDING); + expect(rect.upperStart.x >= monitor1EndX + PADDING).toBeTruthy(); +}); + +test('getInitialPosition_noRoom', () => { + let defaultState; + const width = 7; + const height = 8; + // Add a monitor that takes up the whole screen + const reduxState = monitorLayoutReducer(defaultState, addMonitorRect(1, + {x: 0, y: 0}, + {x: SCREEN_WIDTH, y: SCREEN_HEIGHT} + )); + + // Check that added monitor exists somewhere (we don't care where) + const rect = getInitialPosition(reduxState, 2, width, height); + expect(rect.upperStart).toBeDefined(); + expect(rect.lowerEnd.x - rect.upperStart.x).toEqual(width); + expect(rect.lowerEnd.y - rect.upperStart.y).toEqual(height); +});