diff --git a/app/css/components/service-list.css b/app/css/components/service-list.css index b42660a..5481288 100644 --- a/app/css/components/service-list.css +++ b/app/css/components/service-list.css @@ -4,11 +4,7 @@ .service-list__item { display: flex; - padding: 0; position: relative; -} - -.service-list__item[data-icon] { padding-left: 4rem; background-size: 2.5rem; diff --git a/app/css/views/services-list.css b/app/css/views/services-list.css index 7edbb98..006a5f2 100644 --- a/app/css/views/services-list.css +++ b/app/css/views/services-list.css @@ -6,10 +6,28 @@ background-image: url('services-list/ip-camera.svg'); } -[data-icon="motion-sensor"] { +[data-icon="door-lock"] { + background-image: url('services-list/door-lock.svg'); +} + +@keyframes motion-sensor-animation { + 0%, 90% { + background-size: 2.7rem; + } + + 5%, 95% { + background-size: 2.3rem; + } + + 10%, 85%, 100% { + background-size: 2.5rem; + } +} + +.motion-sensor-item { background-image: url('services-list/motion-sensor.svg'); } -[data-icon="door-lock"] { - background-image: url('services-list/door-lock.svg'); +.motion-sensor-item--motion-detected { + animation: motion-sensor-animation 1.5s linear infinite both; } diff --git a/app/js/lib/foxbox/api.js b/app/js/lib/foxbox/api.js index 7327347..2873d3f 100644 --- a/app/js/lib/foxbox/api.js +++ b/app/js/lib/foxbox/api.js @@ -1,14 +1,23 @@ 'use strict'; +import EventDispatcher from './common/event-dispatcher'; +import SequentialTimer from './common/sequential-timer'; + const p = Object.freeze({ settings: Symbol('settings'), net: Symbol('net'), + watchTimer: Symbol('watchTimer'), + watchEventBus: Symbol('watchEventBus'), + watchGetters: Symbol('getters'), + // Private methods. getURL: Symbol('getURL'), onceOnline: Symbol('onceOnline'), onceAuthenticated: Symbol('onceAuthenticated'), onceReady: Symbol('onceReady'), + fetchGetterValues: Symbol('fetchGetterValues'), + updateGetterValue: Symbol('updateGetterValue'), }); /** @@ -23,6 +32,14 @@ export default class API { constructor(net, settings) { this[p.net] = net; this[p.settings] = settings; + + this[p.watchTimer] = new SequentialTimer(this[p.settings].watchInterval); + this[p.watchEventBus] = new EventDispatcher(); + this[p.watchGetters] = new Map(); + + this[p.fetchGetterValues] = this[p.fetchGetterValues].bind(this); + + Object.freeze(this); } /** @@ -90,6 +107,63 @@ export default class API { }); } + /** + * Registers watcher for the getter with specified id. + * + * @todo We may need to accept getter kind in the future too, to validate + * getter value type. + * + * @param {string} getterId Id of the getter we'd like to watch. + * @param {function} handler Handler to be executed once watched value is + * changed. + */ + watch(getterId, handler) { + this[p.watchEventBus].on(getterId, handler); + + if (this[p.watchGetters].has(getterId)) { + return; + } + + this[p.watchGetters].set(getterId, { + id: getterId, + // Using null as initial value, some getters can return null when value + // is not yet available, so it perfectly fits our case. + value: null, + }); + + // We automatically start watching if at least one getter is requested to + // be watched. + if (!this[p.watchTimer].started) { + this[p.watchTimer].start(this[p.fetchGetterValues]); + } + } + + /** + * Unregisters watcher for the getter with specified id. + * + * @param {string} getterId Id of the getter we'd like to not watch anymore. + * @param {function} handler Handler function that has been used with "watch" + * previously. + */ + unwatch(getterId, handler) { + if (!this[p.watchGetters].has(getterId)) { + console.warn('Getter with id "%s" is not watched.', getterId); + return; + } + + this[p.watchEventBus].off(getterId, handler); + + // If there is no more listeners, we should not watch this getter anymore. + if (!this[p.watchEventBus].hasListeners(getterId)) { + this[p.watchGetters].delete(getterId); + } + + // If no more getters are watched let's stop watching. + if (this[p.watchGetters].size === 0) { + this[p.watchTimer].stop(); + } + } + /** * Creates a fully qualified API URL based on predefined base origin, API * version and specified resource path. @@ -148,4 +222,80 @@ export default class API { return new Promise((resolve) => settings.once('session', () => resolve())); } + + /** + * Fetches values for the set of getters. + * + * @return {Promise} + * @private + */ + [p.fetchGetterValues]() { + // It may happen that all watchers have been unregistered in the meantime, + // so let's return early in this case. + if (this[p.watchGetters].size === 0) { + return Promise.resolve(); + } + + const selectors = Array.from(this[p.watchGetters].values()).map( + ({ id }) => ({ id }) + ); + + return this.put('channels/get', selectors) + .then((response) => { + Object.keys(response).forEach((key) => { + const getter = this[p.watchGetters].get(key); + if (!getter) { + return; + } + + this[p.updateGetterValue](getter, response[key]); + }); + }); + } + + /** + * Updates getter value if needed. If value has changed, appropriate event is + * fired. + * + * @param {{ id: string, value: Object }} getter Getter to update value for. + * @param {Object} getterValue Getter value returned from the server. + * + * @private + */ + [p.updateGetterValue](getter, getterValue) { + let valueChanged = false; + + if (!getterValue || !getter.value) { + valueChanged = getterValue !== getter.value; + } else { + const [valueKind] = Object.keys(getterValue); + if (valueKind === 'Error') { + console.error( + 'Failed to retrieve value for getter (%s): %o', + getter.id, + getterValue[valueKind] + ); + + return; + } + + const newValue = getterValue[valueKind]; + const oldValue = getter.value[valueKind]; + + if (newValue && oldValue && typeof newValue === 'object') { + // @todo If value is a non-null object, we use their JSON representation + // to compare values. It's not performant and not reliable at all, but + // this OK until we have such values, once we support them we should + // have dedicated utility function for deep comparing objects. + valueChanged = JSON.stringify(newValue) !== JSON.stringify(oldValue); + } else { + valueChanged = newValue !== oldValue; + } + } + + if (valueChanged) { + getter.value = Object.freeze(getterValue); + this[p.watchEventBus].emit(getter.id, getter.value); + } + } } diff --git a/app/js/lib/foxbox/defer.js b/app/js/lib/foxbox/common/defer.js similarity index 100% rename from app/js/lib/foxbox/defer.js rename to app/js/lib/foxbox/common/defer.js diff --git a/app/js/lib/foxbox/event-dispatcher.js b/app/js/lib/foxbox/common/event-dispatcher.js similarity index 97% rename from app/js/lib/foxbox/event-dispatcher.js rename to app/js/lib/foxbox/common/event-dispatcher.js index 7cc03d1..fc82a4f 100644 --- a/app/js/lib/foxbox/event-dispatcher.js +++ b/app/js/lib/foxbox/common/event-dispatcher.js @@ -60,23 +60,23 @@ * obj.emit("somethinghappened", 123); */ -function assertValidEventName(eventName) { +const assertValidEventName = function(eventName) { if (!eventName || typeof eventName !== 'string') { throw new Error('Event name should be a valid non-empty string!'); } -} +}; -function assertValidHandler(handler) { +const assertValidHandler = function(handler) { if (typeof handler !== 'function') { throw new Error('Handler should be a function!'); } -} +}; -function assertAllowedEventName(allowedEvents, eventName) { +const assertAllowedEventName = function(allowedEvents, eventName) { if (allowedEvents && allowedEvents.indexOf(eventName) < 0) { throw new Error(`Event "${eventName}" is not allowed!`); } -} +}; const p = Object.freeze({ allowedEvents: Symbol('allowedEvents'), diff --git a/app/js/lib/foxbox/common/sequential-timer.js b/app/js/lib/foxbox/common/sequential-timer.js new file mode 100644 index 0000000..30c50f9 --- /dev/null +++ b/app/js/lib/foxbox/common/sequential-timer.js @@ -0,0 +1,98 @@ +'use strict'; + +const p = Object.freeze({ + started: Symbol('started'), + nextTickHandle: Symbol('nextTickHandle'), + + // Private methods. + scheduleTick: Symbol('scheduleTick'), + onTick: Symbol('onTick'), +}); + +export default class SequentialTimer { + /** + * Creates new SequentialTimer instance. + * @param {number} interval Minimum interval between two consequent ticks. + */ + constructor(interval) { + this.interval = interval; + + this[p.started] = false; + this[p.nextTickHandle] = null; + this[p.onTick] = null; + + Object.seal(this); + } + + /** + * Indicates whether timer started or not. + * + * @return {boolean} + */ + get started() { + return this[p.started]; + } + + /** + * Starts timer. If timer has already been started nothing happens. + * @param {function} onTick Function that will be called on every tick. + */ + start(onTick) { + if (this[p.started]) { + console.warn('Timer has been already started.'); + return; + } + + if (typeof onTick !== 'function') { + throw new Error('onTick handler should be a valid function.'); + } + + this[p.started] = true; + this[p.onTick] = onTick; + + this[p.scheduleTick](); + } + + /** + * Stops timer. If timer has not been started yet nothing happens. + */ + stop() { + if (!this[p.started]) { + console.warn('Timer has not been started yet.'); + return; + } + + this[p.started] = false; + + clearTimeout(this[p.nextTickHandle]); + this[p.nextTickHandle] = null; + this[p.onTick] = null; + } + + /** + * Schedules next tick. + * + * @private + */ + [p.scheduleTick]() { + if (!this[p.started] || this[p.nextTickHandle]) { + return; + } + + this[p.nextTickHandle] = setTimeout(() => { + // Use Promise constructor to handle all possible results e.g. promises, + // unexpected exceptions and any other non-promise values. + (new Promise((resolve) => resolve(this[p.onTick]()))) + .catch((error) => { + console.error( + 'onTick handler failed, scheduling next tick anyway: %o', + error + ); + }) + .then(() => { + this[p.nextTickHandle] = null; + this[p.scheduleTick](); + }); + }, this.interval); + } +} diff --git a/app/js/lib/foxbox/db.js b/app/js/lib/foxbox/db.js index 8a7b9f3..2514518 100644 --- a/app/js/lib/foxbox/db.js +++ b/app/js/lib/foxbox/db.js @@ -1,6 +1,6 @@ 'use strict'; -import Defer from './defer'; +import Defer from './common/defer'; // Private members. const p = Object.freeze({ diff --git a/app/js/lib/foxbox/network.js b/app/js/lib/foxbox/network.js index 8a3200e..7d88879 100644 --- a/app/js/lib/foxbox/network.js +++ b/app/js/lib/foxbox/network.js @@ -1,6 +1,7 @@ 'use strict'; -import EventDispatcher from './event-dispatcher'; +import EventDispatcher from './common/event-dispatcher'; +import SequentialTimer from './common/sequential-timer'; // Private members. const p = Object.freeze({ @@ -8,12 +9,16 @@ const p = Object.freeze({ settings: Symbol('settings'), local: Symbol('local'), remote: Symbol('remote'), - pingInterval: Symbol('pingInterval'), + + localPingTimer: Symbol('localPingTimer'), + remotePingTimer: Symbol('remotePingTimer'), // Private methods. fetch: Symbol('fetch'), ping: Symbol('ping'), - pingBox: Symbol('pingBox'), + pingLocalBox: Symbol('pingLocalBox'), + pingRemoteBox: Symbol('pingRemoteBox'), + pingAllBoxes: Symbol('pingAllBoxes'), onPong: Symbol('onPong'), }); @@ -27,8 +32,13 @@ export default class Network extends EventDispatcher { this[p.local] = false; // Whether we can connect to the box via a remote connection. this[p.remote] = false; - // A reference to the interval to get the online status. - this[p.pingInterval] = null; + + this[p.localPingTimer] = null; + this[p.remotePingTimer] = null; + + this[p.pingLocalBox] = this[p.pingLocalBox].bind(this); + this[p.pingRemoteBox] = this[p.pingRemoteBox].bind(this); + this[p.pingAllBoxes] = this[p.pingAllBoxes].bind(this); Object.seal(this); } @@ -39,24 +49,30 @@ export default class Network extends EventDispatcher { * @return {Promise} */ init() { - const pingBox = this[p.pingBox].bind(this); - - window.addEventListener('online', pingBox); - window.addEventListener('offline', pingBox); + window.addEventListener('online', this[p.pingAllBoxes]); + window.addEventListener('offline', this[p.pingAllBoxes]); + let pingInterval; if ('connection' in navigator && 'onchange' in navigator.connection) { - navigator.connection.addEventListener('change', pingBox); + navigator.connection.addEventListener('change', this[p.pingAllBoxes]); // We also ping the box every few minutes to make sure it's still there. - this[p.pingInterval] = setInterval(pingBox, - this[p.settings].onlineCheckingLongInterval); + pingInterval = this[p.settings].onlineCheckingLongInterval; } else { // If the Network Information API is not implemented, fallback to polling. - this[p.pingInterval] = setInterval(pingBox, - this[p.settings].onlineCheckingInterval); + pingInterval = this[p.settings].onlineCheckingInterval; } - this[p.pingBox](); + // @todo Settings should emit event when it changes "tunnelOrigin" or + // "localOrigin" setting and we should listen for this change and start or + // stop timers according to these settings. + this[p.localPingTimer] = new SequentialTimer(pingInterval); + this[p.localPingTimer].start(this[p.pingLocalBox]); + + this[p.remotePingTimer] = new SequentialTimer(pingInterval); + this[p.remotePingTimer].start(this[p.pingRemoteBox]); + + this[p.pingAllBoxes](); return Promise.resolve(); } @@ -73,15 +89,11 @@ export default class Network extends EventDispatcher { } get localOrigin() { - const settings = this[p.settings]; - - return settings.localOrigin; + return this[p.settings].localOrigin; } get tunnelOrigin() { - const settings = this[p.settings]; - - return settings.tunnelOrigin; + return this[p.settings].tunnelOrigin; } get online() { @@ -175,27 +187,45 @@ export default class Network extends EventDispatcher { } /** - * Ping the box to detect whether we connect locally or remotely. Since - * 'online' state depends on two factors: local and remote server - * availability, there is a slight chance that this method will cause two - * events e.g. if previously box was available only locally and now it's - * available only remotely we'll likely generate event indicating that box - * went offline following by another event indicating that box is online - * again. + * Ping the local box to detect whether it's still alive. * + * @return {Promise} * @private */ - [p.pingBox]() { - const previousState = this[p.local] || this[p.remote]; + [p.pingLocalBox]() { + // @todo Find a better way to detect if a local connection is active. + if (!this[p.settings].localOrigin) { + return Promise.resolve(); + } - this[p.ping](`${this.localOrigin}/ping`) - .then((isOnline) => this[p.onPong](previousState, p.local, isOnline)); + return this[p.ping](`${this.localOrigin}/ping`) + .then((isOnline) => this[p.onPong](p.local, isOnline)); + } + /** + * Ping the remote box to detect whether it's still alive. + * + * @return {Promise} + * @private + */ + [p.pingRemoteBox]() { // @todo Find a better way to detect if a tunnel connection is active. - if (this[p.settings].tunnelOrigin) { - this[p.ping](`${this.tunnelOrigin}/ping`) - .then((isOnline) => this[p.onPong](previousState, p.remote, isOnline)); + if (!this[p.settings].tunnelOrigin) { + return Promise.resolve(); } + + return this[p.ping](`${this.tunnelOrigin}/ping`) + .then((isOnline) => this[p.onPong](p.remote, isOnline)); + } + + /** + * Pings both local and remote boxes simultaneously (if discovered). + * + * @private + */ + [p.pingAllBoxes]() { + this[p.pingLocalBox](); + this[p.pingRemoteBox](); } /** @@ -218,22 +248,28 @@ export default class Network extends EventDispatcher { /** * Process ping response (pong). If 'online' state is changed we emit 'online' - * event. + * event. Since 'online' state depends on two factors: local and remote server + * availability, there is a slight chance that this method will cause two + * events e.g. if previously box was available only locally and now it's + * available only remotely we'll likely generate event indicating that box + * went offline following by another event indicating that box is online + * again. * - * @param {boolean} previousOnlineState Previous 'online' state. * @param {Symbol} localOrRemote Symbol indicating whether we process local * pong or remote one. * @param {boolean} isOnline Flag that indicates whether pinged server * successfully responded to ping request. * @private */ - [p.onPong](previousOnlineState, localOrRemote, isOnline) { + [p.onPong](localOrRemote, isOnline) { // If value hasn't changed, there is no reason to think that overall // 'online' state has changed. if (this[localOrRemote] === isOnline) { return; } + const previousOnlineState = this[p.local] || this[p.remote]; + this[localOrRemote] = isOnline; const currentOnlineState = this[p.local] || this[p.remote]; diff --git a/app/js/lib/foxbox/services.js b/app/js/lib/foxbox/services.js index 1d556b6..121e2c9 100644 --- a/app/js/lib/foxbox/services.js +++ b/app/js/lib/foxbox/services.js @@ -1,6 +1,7 @@ 'use strict'; -import EventDispatcher from './event-dispatcher'; +import EventDispatcher from './common/event-dispatcher'; +import SequentialTimer from './common/sequential-timer'; import BaseService from './services/base'; import IpCameraService from './services/ip-camera'; import LightService from './services/light'; @@ -12,11 +13,9 @@ const p = Object.freeze({ settings: Symbol('settings'), db: Symbol('db'), cache: Symbol('services'), - isPollingEnabled: Symbol('isPollingEnabled'), - nextPollTimeout: Symbol('nextPollTimeout'), + pollingTimer: Symbol('pollingTimer'), // Private methods. - schedulePoll: Symbol('schedulePoll'), getServiceInstance: Symbol('getServiceInstance'), hasDoorLockChannel: Symbol('hasDoorLockChannel'), updateServiceList: Symbol('updateServiceList'), @@ -67,8 +66,11 @@ export default class Services extends EventDispatcher { this[p.settings] = settings; this[p.cache] = null; - this[p.isPollingEnabled] = false; - this[p.nextPollTimeout] = null; + this[p.pollingTimer] = new SequentialTimer( + this[p.settings].pollingInterval + ); + + this[p.updateServiceList] = this[p.updateServiceList].bind(this); Object.seal(this); } @@ -102,14 +104,14 @@ export default class Services extends EventDispatcher { * be started or stopped. */ togglePolling(pollingEnabled) { - this[p.isPollingEnabled] = pollingEnabled; + if (pollingEnabled === this[p.pollingTimer].started) { + return; + } if (pollingEnabled) { - this[p.schedulePoll](); + this[p.pollingTimer].start(this[p.updateServiceList]); } else { - // Cancel next poll attempt if it has been scheduled. - clearTimeout(this[p.nextPollTimeout]); - this[p.nextPollTimeout] = null; + this[p.pollingTimer].stop(); } } @@ -137,92 +139,67 @@ export default class Services extends EventDispatcher { }); } - /** - * Schedules an attempt to poll the server, does nothing if polling is not - * enabled or it has already been scheduled. New poll is scheduled only once - * previous one is completed or failed. - * - * @private - */ - [p.schedulePoll]() { - // Return early if polling is not enabled or it has already been scheduled. - if (!this[p.isPollingEnabled] || this[p.nextPollTimeout]) { - return; - } - - this[p.nextPollTimeout] = setTimeout(() => { - Promise.all([this[p.db].getServices(), this[p.api].get('services')]) - .then(([storedServices, fetchedServices]) => { - return this[p.updateServiceList](storedServices, fetchedServices); - }) - .catch((error) => { - console.error( - 'Polling has failed, scheduling one more attempt: ', - error - ); - }) - .then(() => { - this[p.nextPollTimeout] = null; - this[p.schedulePoll](); - }); - }, this[p.settings].pollingInterval); - } - /** * Tries to update service list and emits appropriate events. * - * @param {Array} storedServices Services currently stored in DB. - * @param {Array} fetchedServices Services returned from the server. * @return {Promise} * @private */ - [p.updateServiceList](storedServices, fetchedServices) { - return this[p.getCache]() - .then((cache) => { - let servicesToAddCount = 0; - fetchedServices.forEach((fetchedService) => { - const storedService = storedServices.find( - (storedService) => storedService.id === fetchedService.id - ); + [p.updateServiceList]() { + return Promise.all([ + this[p.api].get('services'), + this[p.db].getServices(), + this[p.getCache](), + ]) + .then(([fetchedServices, storedServices, cache]) => { + let servicesToAddCount = 0; + fetchedServices.forEach((fetchedService) => { + const storedService = storedServices.find( + (storedService) => storedService.id === fetchedService.id + ); + + const isExistingService = !!storedService; + + if (isExistingService && isSimilar(fetchedService, storedService)) { + return; + } - const isExistingService = !!storedService; + // Populate the db with the latest service. + this[p.db].setService(fetchedService); - if (isExistingService && isSimilar(fetchedService, storedService)) { - return; - } + const service = this[p.getServiceInstance](fetchedService); + cache.set(service.id, service); - // Populate the db with the latest service. - this[p.db].setService(fetchedService); + if (isExistingService) { + this.emit('service-changed', service); + } else { + servicesToAddCount++; + } + }); - const service = this[p.getServiceInstance](fetchedService); - cache.set(service.id, service); + const servicesToRemoveCount = storedServices.length + + servicesToAddCount - fetchedServices.length; + if (servicesToRemoveCount > 0) { + storedServices.forEach((storedService) => { + const fetchedService = fetchedServices.find( + (fetchedService) => fetchedService.id === storedService.id + ); + + if (!fetchedService) { + this[p.db].deleteService(storedService); - if (isExistingService) { - this.emit('service-changed', service); - } else { - servicesToAddCount++; + // We should teardown service instance and remove it from the cache. + const cachedService = cache.get(storedService.id); + cachedService.teardown(); + cache.delete(cachedService.id); } }); + } - const servicesToRemoveCount = storedServices.length + - servicesToAddCount - fetchedServices.length; - if (servicesToRemoveCount > 0) { - storedServices.forEach((storedService) => { - const fetchedService = fetchedServices.find( - (fetchedService) => fetchedService.id === storedService.id - ); - - if (!fetchedService) { - this[p.db].deleteService(storedService); - cache.delete(storedService.id); - } - }); - } - - if (servicesToAddCount > 0 || servicesToRemoveCount > 0) { - this.emit('services-changed'); - } - }); + if (servicesToAddCount > 0 || servicesToRemoveCount > 0) { + this.emit('services-changed'); + } + }); } /** diff --git a/app/js/lib/foxbox/services/base.js b/app/js/lib/foxbox/services/base.js index c6aea52..a08f9b4 100644 --- a/app/js/lib/foxbox/services/base.js +++ b/app/js/lib/foxbox/services/base.js @@ -1,5 +1,7 @@ 'use strict'; +import EventDispatcher from '../common/event-dispatcher'; + const TYPE = 'unknown'; const p = Object.freeze({ @@ -21,8 +23,10 @@ const p = Object.freeze({ getSetterValueType: Symbol('getSetterValueType'), }); -export default class BaseService { - constructor(props, api) { +export default class BaseService extends EventDispatcher { + constructor(props, api, allowedEvents) { + super(allowedEvents); + // Private properties. this[p.api] = api; @@ -105,6 +109,38 @@ export default class BaseService { .then((response) => response[getter.id]); } + /** + * Setups value watcher for the getter matching specified selector. + * + * @param {Object} selector Selector to match getter which value we would like + * to watch. + * @param {function} handler Function to be called once getter value changes. + */ + watch(selector, handler) { + const { id: getterId } = this[p.getChannel](this[p.getters], selector); + this[p.api].watch(getterId, handler); + } + + /** + * Removes value watcher for the getter matching specified selector. + * + * @param {Object} selector Selector to match getter for which we would like + * to remove value watcher. + * @param {function} handler Function that was used in corresponding watch + * call. + */ + unwatch(selector, handler) { + const { id: getterId } = this[p.getChannel](this[p.getters], selector); + this[p.api].unwatch(getterId, handler); + } + + /** + * Method that should be called when service instance is not needed anymore. + * Classes that extend BaseService and override this method should always call + * super.teardown() method as well. + */ + teardown() {} + /** * @param {Object} channels * @param {Object|string} selector A value matching the kind of a channel. diff --git a/app/js/lib/foxbox/services/motion-sensor.js b/app/js/lib/foxbox/services/motion-sensor.js index dbfbb4d..35a365f 100644 --- a/app/js/lib/foxbox/services/motion-sensor.js +++ b/app/js/lib/foxbox/services/motion-sensor.js @@ -4,13 +4,68 @@ import BaseService from './base'; const TYPE = 'motion-sensor'; +const p = Object.freeze({ + onMotionStateChanged: Symbol('onMotionStateChanged'), +}); + +/** + * Converts motion state value to boolean. Considers unknown state (null) the + * same as state when motion is not detected. + * + * @param {Object} motionState Motion state object. + * @return {boolean} + * @private + */ +const motionStateToBoolean = function(motionState) { + if (!motionState) { + return false; + } + + return motionState.OpenClosed === 'Open'; +}; + export default class MotionSensorService extends BaseService { constructor(props, api) { - super(props, api); - Object.seal(this); + super(props, api, ['motion']); + + this[p.onMotionStateChanged] = this[p.onMotionStateChanged].bind(this); + + // Let's watch for motion sensor value changes. + this.watch('OpenClosed', this[p.onMotionStateChanged]); + + Object.freeze(this); } get type() { return TYPE; } + + /** + * Returns motion sensor state. + * + * @return {Promise.} + */ + isMotionDetected() { + return this.get('OpenClosed').then(motionStateToBoolean); + } + + /** + * Removes motion sensor state watcher. + */ + teardown() { + super.teardown(); + + this.unwatch('OpenClosed', this[p.onMotionStateChanged]); + } + + /** + * Function that is called whenever motion state changes. + * + * @param {Object} motionState State that indicates whether motion detected + * or not. + * @private + */ + [p.onMotionStateChanged](motionState) { + this.emit('motion', motionStateToBoolean(motionState)); + } } diff --git a/app/js/lib/foxbox/settings.js b/app/js/lib/foxbox/settings.js index b8952aa..d3d5675 100644 --- a/app/js/lib/foxbox/settings.js +++ b/app/js/lib/foxbox/settings.js @@ -1,12 +1,13 @@ 'use strict'; -import EventDispatcher from './event-dispatcher'; +import EventDispatcher from './common/event-dispatcher'; // Prefix all entries to avoid collisions. const PREFIX = 'foxbox-'; const DEFAULT_POLLING_ENABLED = true; const POLLING_INTERVAL = 2000; +const WATCH_INTERVAL = 3000; const ONLINE_CHECKING_INTERVAL = 5000; const ONLINE_CHECKING_LONG_INTERVAL = 1000 * 60 * 5; const REGISTRATION_SERVICE = 'https://knilxof.org:4443/ping'; @@ -187,6 +188,14 @@ export default class Settings extends EventDispatcher { return ONLINE_CHECKING_LONG_INTERVAL; } + /** + * Minimal interval between consequent value watcher requests. + * @return {number} + */ + get watchInterval() { + return WATCH_INTERVAL; + } + get queryStringAuthTokenName() { return QUERY_STRING_AUTH_TOKEN_NAME; } diff --git a/app/js/lib/foxbox/webpush.js b/app/js/lib/foxbox/webpush.js index 17dfdf1..5d154ab 100644 --- a/app/js/lib/foxbox/webpush.js +++ b/app/js/lib/foxbox/webpush.js @@ -1,6 +1,6 @@ 'use strict'; -import EventDispatcher from './event-dispatcher'; +import EventDispatcher from './common/event-dispatcher'; // Private members const p = Object.freeze({ diff --git a/app/js/views/services-list-item.jsx b/app/js/views/services-list-item.jsx index 45ebc8e..0769650 100644 --- a/app/js/views/services-list-item.jsx +++ b/app/js/views/services-list-item.jsx @@ -8,10 +8,13 @@ export default class ServicesListItem extends React.Component { available: false, on: true, locked: true, + motionDetected: false, }; this.service = props.service; this.foxbox = props.foxbox; + + this.onMotion = this.onMotion.bind(this); } componentDidMount() { @@ -29,6 +32,11 @@ export default class ServicesListItem extends React.Component { }) .catch(console.error.bind(console)); break; + case 'motion-sensor': + this.service.isMotionDetected() + .then(this.onMotion); + this.service.on('motion', this.onMotion); + break; case 'door-lock': this.service.isLocked() .then((locked) => { @@ -41,6 +49,14 @@ export default class ServicesListItem extends React.Component { } } + componentWillUnmount() { + switch (this.service.type) { + case 'motion-sensor': + this.service.off('motion', this.onMotion); + break; + } + } + handleLightOnChange(evt) { const on = evt.target.checked; @@ -68,6 +84,10 @@ export default class ServicesListItem extends React.Component { }); } + onMotion(motionDetected) { + this.setState({ motionDetected }); + } + /** * Convert colours from xy space to RGB. * See details at: @@ -235,6 +255,27 @@ export default class ServicesListItem extends React.Component { ); } + renderMotionSensor() { + const motionSensorNameNode = this.service.name ? + ({` (${this.service.name})`}) : + null; + + let motionSensorClassName = 'service-list__item motion-sensor-item'; + if (this.state.motionDetected) { + motionSensorClassName += ' motion-sensor-item--motion-detected'; + } + + return ( +
  • + + Motion Sensor + {motionSensorNameNode} + +
  • + ); + } + renderGenericService(type = 'Unknown service', icon = 'unknown') { const serviceNameNode = this.service.name ? ({` (${this.service.name})`}) : @@ -260,7 +301,7 @@ export default class ServicesListItem extends React.Component { case 'light': return this.renderLightService(); case 'motion-sensor': - return this.renderGenericService('Motion Sensor', 'motion-sensor'); + return this.renderMotionSensor(); default: return this.renderGenericService(); } diff --git a/tests/unit/lib/foxbox/api_test.js b/tests/unit/lib/foxbox/api_test.js index 4e54262..3e1ba58 100644 --- a/tests/unit/lib/foxbox/api_test.js +++ b/tests/unit/lib/foxbox/api_test.js @@ -1,3 +1,4 @@ +import { waitForNextMacroTask } from '../../test-utils'; import API from 'js/lib/foxbox/api'; describe('API >', function () { @@ -6,9 +7,13 @@ describe('API >', function () { const testBlob = new Blob([], { type: 'image/jpeg' }); beforeEach(function () { + this.sinon = sinon.sandbox.create(); + this.sinon.useFakeTimers(); + settingsStub = sinon.stub({ session: 'fake_session', apiVersion: 5, + watchInterval: 1000, once: () => {}, }); @@ -25,6 +30,10 @@ describe('API >', function () { api = new API(netStub, settingsStub); }); + afterEach(function() { + this.sinon.clock.restore(); + }); + describe('when online and authenticated >', function() { describe('get >', function() { it('throws if wrong path is used', function(done) { @@ -227,6 +236,259 @@ describe('API >', function () { .then(done, done); }); }); + + describe('watch >', function() { + const testGetterId1 = 'getter-id-1'; + const testGetterId2 = 'getter-id-2'; + + const testGetter1Values = { + oldValue: { OpenClosed: 'Open' }, + newValue: { OpenClosed: 'Closed' }, + }; + + const testGetter2Values = { + oldValue: { DoorLocked: 'Locked' }, + newValue: { DoorLocked: 'Unlocked' }, + }; + + let onWatch1Stub; + let onWatch2Stub; + + beforeEach(function() { + onWatch1Stub = sinon.stub(); + onWatch2Stub = sinon.stub(); + + netStub.fetchJSON.withArgs( + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: testGetterId1 }] + ).returns( + Promise.resolve({ [testGetterId1]: testGetter1Values.oldValue }) + ); + + netStub.fetchJSON.withArgs( + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: testGetterId1 }, { id: testGetterId2 }] + ).returns( + Promise.resolve({ + [testGetterId1]: testGetter1Values.oldValue, + [testGetterId2]: testGetter2Values.oldValue, + }) + ); + }); + + it('starts watching only if at least one watcher is set', function(done) { + this.sinon.clock.tick(settingsStub.watchInterval); + + waitForNextMacroTask() + .then(() => { + sinon.assert.notCalled(netStub.fetchJSON); + + api.watch(testGetterId1, onWatch1Stub); + + this.sinon.clock.tick(settingsStub.watchInterval); + + return waitForNextMacroTask(); + }) + .then(() => { + sinon.assert.calledOnce(netStub.fetchJSON); + sinon.assert.calledWith(onWatch1Stub, testGetter1Values.oldValue); + }) + .then(done, done); + }); + + it('fires handler only if value changed', function(done) { + api.watch(testGetterId1, onWatch1Stub); + + this.sinon.clock.tick(settingsStub.watchInterval); + + waitForNextMacroTask() + .then(() => { + // Called once first value is retrieved, since by default we have + // null; + sinon.assert.calledOnce(netStub.fetchJSON); + sinon.assert.calledOnce(onWatch1Stub); + sinon.assert.calledWith(onWatch1Stub, testGetter1Values.oldValue); + + this.sinon.clock.tick(settingsStub.watchInterval); + return waitForNextMacroTask(); + }) + .then(() => { + // Value hasn't changed so handler function should not be called. + sinon.assert.calledTwice(netStub.fetchJSON); + sinon.assert.calledOnce(onWatch1Stub); + + // Now let's simulate changed value + netStub.fetchJSON.withArgs( + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: testGetterId1 }] + ).returns( + Promise.resolve({ [testGetterId1]: testGetter1Values.newValue }) + ); + + this.sinon.clock.tick(settingsStub.watchInterval); + return waitForNextMacroTask(); + }) + .then(() => { + sinon.assert.calledThrice(netStub.fetchJSON); + sinon.assert.calledTwice(onWatch1Stub); + sinon.assert.calledWith(onWatch1Stub, testGetter1Values.newValue); + }) + .then(done, done); + }); + + it('groups all watcher request into one network request', function(done) { + api.watch(testGetterId1, onWatch1Stub); + api.watch(testGetterId2, onWatch2Stub); + + this.sinon.clock.tick(settingsStub.watchInterval); + + waitForNextMacroTask() + .then(() => { + // Called once first value is retrieved, since by default we have + // null; + sinon.assert.calledOnce(netStub.fetchJSON); + + sinon.assert.calledOnce(onWatch1Stub); + sinon.assert.calledWith(onWatch1Stub, testGetter1Values.oldValue); + + sinon.assert.calledOnce(onWatch2Stub); + sinon.assert.calledWith(onWatch2Stub, testGetter2Values.oldValue); + + this.sinon.clock.tick(settingsStub.watchInterval); + return waitForNextMacroTask(); + }) + .then(() => { + // Value hasn't changed so handler function should not be called. + sinon.assert.calledTwice(netStub.fetchJSON); + + sinon.assert.calledOnce(onWatch1Stub); + sinon.assert.calledOnce(onWatch2Stub); + + // Now let's simulate changed value for second getter only. + netStub.fetchJSON.withArgs( + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: testGetterId1 }, { id: testGetterId2 }] + ).returns( + Promise.resolve({ + [testGetterId1]: testGetter1Values.oldValue, + [testGetterId2]: testGetter2Values.newValue, + }) + ); + + this.sinon.clock.tick(settingsStub.watchInterval); + return waitForNextMacroTask(); + }) + .then(() => { + sinon.assert.calledThrice(netStub.fetchJSON); + + // Getter 1 value hasn't changed. + sinon.assert.calledOnce(onWatch1Stub); + + sinon.assert.calledTwice(onWatch2Stub); + sinon.assert.calledWith(onWatch2Stub, testGetter2Values.newValue); + }) + .then(done, done); + }); + }); + + describe('unwatch >', function() { + const testGetterId1 = 'getter-id-1'; + const testGetterId2 = 'getter-id-2'; + + const getter1Value = { OpenClosed: 'Open' }; + const getter2Value = { DoorLocked: 'Locked' }; + + let onWatch1Stub; + let onWatch2Stub; + + beforeEach(function() { + onWatch1Stub = sinon.stub(); + onWatch2Stub = sinon.stub(); + + netStub.fetchJSON.withArgs( + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: testGetterId1 }] + ).returns( + Promise.resolve({ [testGetterId1]: getter1Value }) + ); + + netStub.fetchJSON.withArgs( + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: testGetterId1 }, { id: testGetterId2 }] + ).returns( + Promise.resolve({ + [testGetterId1]: getter1Value, + [testGetterId2]: getter2Value, + }) + ); + }); + + it('correctly removes unregistered watchers', function(done) { + api.watch(testGetterId1, onWatch1Stub); + api.watch(testGetterId2, onWatch2Stub); + + this.sinon.clock.tick(settingsStub.watchInterval); + + waitForNextMacroTask() + .then(() => { + // Called once first value is retrieved, since by default we have + // null; + sinon.assert.calledOnce(netStub.fetchJSON); + sinon.assert.calledWith( + netStub.fetchJSON, + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: testGetterId1 }, { id: testGetterId2 }] + ); + + sinon.assert.calledOnce(onWatch1Stub); + sinon.assert.calledWith(onWatch1Stub, getter1Value); + + sinon.assert.calledOnce(onWatch2Stub); + sinon.assert.calledWith(onWatch2Stub, getter2Value); + + // Let's unwatch second getter. + api.unwatch(testGetterId2, onWatch2Stub); + + this.sinon.clock.tick(settingsStub.watchInterval); + return waitForNextMacroTask(); + }) + .then(() => { + // Value hasn't changed so handler function should not be called. + sinon.assert.calledTwice(netStub.fetchJSON); + sinon.assert.calledWith( + netStub.fetchJSON, + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: testGetterId1 }] + ); + + sinon.assert.calledOnce(onWatch1Stub); + sinon.assert.calledOnce(onWatch2Stub); + + // Let's unwatch first getter and now watching should stop + // completely. + + api.unwatch(testGetterId1, onWatch1Stub); + + this.sinon.clock.tick(settingsStub.watchInterval); + return waitForNextMacroTask(); + }) + .then(() => { + // Nothing should be called once again. + sinon.assert.calledTwice(netStub.fetchJSON); + sinon.assert.calledOnce(onWatch1Stub); + sinon.assert.calledOnce(onWatch2Stub); + }) + .then(done, done); + }); + }); }); describe('when offline or not authenticated >', function() { @@ -238,8 +500,7 @@ describe('API >', function () { it('"get" correctly waits for the api readiness', function(done) { const resourcePromise = api.get('resource'); - // Let all in-progress micro tasks to complete. - Promise.resolve() + waitForNextMacroTask() .then(() => { sinon.assert.notCalled(netStub.fetchJSON); @@ -272,8 +533,7 @@ describe('API >', function () { 'resource-post', { parameters: 'parameters' } ); - // Let all in-progress micro tasks to complete. - Promise.resolve() + waitForNextMacroTask() .then(() => { sinon.assert.notCalled(netStub.fetchJSON); @@ -308,8 +568,7 @@ describe('API >', function () { 'resource-put', { parameters: 'parameters' } ); - // Let all in-progress micro tasks to complete. - Promise.resolve() + waitForNextMacroTask() .then(() => { sinon.assert.notCalled(netStub.fetchJSON); @@ -346,8 +605,7 @@ describe('API >', function () { 'blob/x-blob' ); - // Let all in-progress micro tasks to complete. - Promise.resolve() + waitForNextMacroTask() .then(() => { sinon.assert.notCalled(netStub.fetchBlob); @@ -377,5 +635,54 @@ describe('API >', function () { }) .then(done, done); }); + + it('"watch" correctly waits for the api readiness', function(done) { + const getterToWatchId = 'getter-id-1'; + const onWatchStub = sinon.stub(); + + netStub.fetchJSON.withArgs( + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: getterToWatchId }] + ).returns( + Promise.resolve({ [getterToWatchId]: { OpenClosed: 'Open' } }) + ); + + api.watch(getterToWatchId, onWatchStub); + + this.sinon.clock.tick(settingsStub.watchInterval); + + waitForNextMacroTask() + .then(() => { + sinon.assert.notCalled(netStub.fetchJSON); + sinon.assert.notCalled(onWatchStub); + + netStub.online = true; + netStub.once.withArgs('online').yield(); + }) + .then(() => { + // We're online, but still don't have authenticated session. + sinon.assert.notCalled(netStub.fetchJSON); + sinon.assert.notCalled(onWatchStub); + + settingsStub.session = 'session'; + settingsStub.once.withArgs('session').yield(); + + return waitForNextMacroTask(); + }) + .then(() => { + sinon.assert.calledOnce(netStub.fetchJSON); + sinon.assert.calledWithExactly( + netStub.fetchJSON, + 'https://secure-box.com/api/v5/channels/get', + 'PUT', + [{ id: getterToWatchId }] + ); + + sinon.assert.calledOnce(onWatchStub); + sinon.assert.calledWithExactly(onWatchStub, { OpenClosed: 'Open' }); + }) + .then(done, done); + }); }); }); diff --git a/tests/unit/lib/foxbox/defer_test.js b/tests/unit/lib/foxbox/common/defer_test.js similarity index 96% rename from tests/unit/lib/foxbox/defer_test.js rename to tests/unit/lib/foxbox/common/defer_test.js index b0c44b3..db87dc8 100644 --- a/tests/unit/lib/foxbox/defer_test.js +++ b/tests/unit/lib/foxbox/common/defer_test.js @@ -1,4 +1,4 @@ -import Defer from 'js/lib/foxbox/defer'; +import Defer from 'js/lib/foxbox/common/defer'; describe('Defer >', function () { let defer, onResolve, onReject; diff --git a/tests/unit/lib/foxbox/event-dispatcher_test.js b/tests/unit/lib/foxbox/common/event-dispatcher_test.js similarity index 99% rename from tests/unit/lib/foxbox/event-dispatcher_test.js rename to tests/unit/lib/foxbox/common/event-dispatcher_test.js index 8b74670..94c7d0b 100644 --- a/tests/unit/lib/foxbox/event-dispatcher_test.js +++ b/tests/unit/lib/foxbox/common/event-dispatcher_test.js @@ -1,4 +1,4 @@ -import EventDispatcher from 'js/lib/foxbox/event-dispatcher'; +import EventDispatcher from 'js/lib/foxbox/common/event-dispatcher'; describe('EventDispatcher >', function () { const allowedEvents = ['allowed-event-1', 'allowed-event-2']; diff --git a/tests/unit/lib/foxbox/common/sequential-timer_test.js b/tests/unit/lib/foxbox/common/sequential-timer_test.js new file mode 100644 index 0000000..80eb9e0 --- /dev/null +++ b/tests/unit/lib/foxbox/common/sequential-timer_test.js @@ -0,0 +1,224 @@ +import { waitForNextMacroTask } from '../../../test-utils'; + +import Defer from 'js/lib/foxbox/common/defer'; +import SequentialTimer from 'js/lib/foxbox/common/sequential-timer'; + +describe('SequentialTimer >', function () { + const DEFAULT_INTERVAL = 2000; + + let timer; + let onTick; + + beforeEach(function() { + this.sinon = sinon.sandbox.create(); + this.sinon.useFakeTimers(); + + onTick = sinon.stub(); + timer = new SequentialTimer(DEFAULT_INTERVAL); + }); + + afterEach(function() { + timer.stop(); + this.sinon.clock.restore(); + }); + + describe('start >', function() { + it('throws if "onTick" is not provided or not a function', function() { + assert.throws(() => timer.start()); + assert.throws(() => timer.start(null)); + assert.throws(() => timer.start({})); + }); + + it('correctly updates "started" property', function() { + assert.isFalse(timer.started); + + timer.start(onTick); + + assert.isTrue(timer.started); + + // Should shart, but not call "onTick" immediately. + sinon.assert.notCalled(onTick); + }); + + it('calls "onTick" only when needed', function(done) { + timer.start(onTick); + this.sinon.clock.tick(DEFAULT_INTERVAL - 1); + + // Sequential timer uses promises inside so we should make sure to assert + // only once all scheduled promises are handled. + waitForNextMacroTask() + .then(() => { + // Should not be called earlier than needed. + sinon.assert.notCalled(onTick); + this.sinon.clock.tick(1); + + return waitForNextMacroTask(); + }) + .then(() => { + sinon.assert.calledOnce(onTick); + this.sinon.clock.tick(DEFAULT_INTERVAL); + + return waitForNextMacroTask(); + }) + .then(() => sinon.assert.calledTwice(onTick)) + .then(done, done); + }); + + it('tick should wait for the "onTick" resolved promise', function(done) { + const onTickDefer = new Defer(); + onTick.returns(onTickDefer.promise); + + timer.start(onTick); + this.sinon.clock.tick(DEFAULT_INTERVAL); + + waitForNextMacroTask() + .then(() => { + sinon.assert.calledOnce(onTick); + this.sinon.clock.tick(DEFAULT_INTERVAL); + + return waitForNextMacroTask(); + }) + .then(() => { + // onTick should not be called once again until previous promise is + // resolved. + sinon.assert.calledOnce(onTick); + onTickDefer.resolve(); + + return waitForNextMacroTask(); + }) + .then(() => { + // This time next tick should be called. + this.sinon.clock.tick(DEFAULT_INTERVAL); + return waitForNextMacroTask(); + }) + .then(() => sinon.assert.calledTwice(onTick)) + .then(done, done); + }); + + it('tick should wait for the "onTick" rejected promise', function(done) { + const onTickDefer = new Defer(); + onTick.returns(onTickDefer.promise); + + timer.start(onTick); + this.sinon.clock.tick(DEFAULT_INTERVAL); + + waitForNextMacroTask() + .then(() => { + sinon.assert.calledOnce(onTick); + this.sinon.clock.tick(DEFAULT_INTERVAL); + + return waitForNextMacroTask(); + }) + .then(() => { + // onTick should not be called once again until previous promise is + // rejected. + sinon.assert.calledOnce(onTick); + onTickDefer.reject(); + + return waitForNextMacroTask(); + }) + .then(() => { + // This time next tick should be called. + this.sinon.clock.tick(DEFAULT_INTERVAL); + return waitForNextMacroTask(); + }) + .then(() => sinon.assert.calledTwice(onTick)) + .then(done, done); + }); + }); + + describe('stop >', function() { + beforeEach(function() { + timer.start(onTick); + }); + + it('correctly updates "started" property', function() { + assert.isTrue(timer.started); + + timer.stop(); + + assert.isFalse(timer.started); + }); + + it('prevents first tick if stopped early', function(done) { + this.sinon.clock.tick(DEFAULT_INTERVAL - 1); + + timer.stop(); + + this.sinon.clock.tick(1); + + waitForNextMacroTask() + .then(() => sinon.assert.notCalled(onTick)) + .then(done, done); + }); + + it('prevents all consequent ticks', function(done) { + this.sinon.clock.tick(DEFAULT_INTERVAL); + + timer.stop(); + + this.sinon.clock.tick(1); + + waitForNextMacroTask() + .then(() => { + sinon.assert.calledOnce(onTick); + + timer.stop(); + }) + .then(() => { + this.sinon.clock.tick(DEFAULT_INTERVAL); + return waitForNextMacroTask(); + }) + .then(() => sinon.assert.calledOnce(onTick)) + .then(done, done); + }); + }); + + describe('interval >', function() { + it('is correctly set by default', function() { + assert.equal(timer.interval, DEFAULT_INTERVAL); + }); + + it('used for the first tick if updated before timer is started', + function(done) { + timer.interval = DEFAULT_INTERVAL - 100; + + timer.start(onTick); + + this.sinon.clock.tick(DEFAULT_INTERVAL - 100); + + waitForNextMacroTask() + .then(() => sinon.assert.calledOnce(onTick)) + .then(done, done); + }); + + it('used for the next tick if updated when timer is already started', + function(done) { + timer.start(onTick); + + timer.interval = DEFAULT_INTERVAL + 100; + + this.sinon.clock.tick(DEFAULT_INTERVAL); + + waitForNextMacroTask() + .then(() => { + // Called using interval set before timer started. + sinon.assert.calledOnce(onTick); + + this.sinon.clock.tick(DEFAULT_INTERVAL); + + return waitForNextMacroTask(); + }) + .then(() => { + // Should not be called second time since new interval is bigger now. + sinon.assert.calledOnce(onTick); + + this.sinon.clock.tick(100); + + return waitForNextMacroTask(); + }) + .then(() => sinon.assert.calledTwice(onTick)) + .then(done, done); + }); + }); +}); diff --git a/tests/unit/lib/foxbox/foxbox_test.js b/tests/unit/lib/foxbox/foxbox_test.js index ae4bdc3..6c78bdb 100644 --- a/tests/unit/lib/foxbox/foxbox_test.js +++ b/tests/unit/lib/foxbox/foxbox_test.js @@ -5,7 +5,7 @@ describe('Foxbox >', function() { public_ip: '1.1.1.1', client: 'abc', message: JSON.stringify({ - local_origin: 'https://local.abc.box.knilxof.org:3000', + local_origin: 'https://local.abc.box.fake.org:3000', tunnel_origin: 'null', }), timestamp: Math.floor(Date.now() / 1000), @@ -15,14 +15,16 @@ describe('Foxbox >', function() { public_ip: '2.2.2.2', client: 'def', message: JSON.stringify({ - local_origin: 'http://local.def.box.knilxof.org:3000', + local_origin: 'http://local.def.box.fake.org:3000', tunnel_origin: 'null', }), timestamp: Math.floor(Date.now() / 1000), }, ]); + let foxbox; let netStub; + let settingsStub; beforeEach(function() { netStub = sinon.stub({ @@ -30,7 +32,17 @@ describe('Foxbox >', function() { fetchJSON: () => {}, on: () => {}, }); - foxbox = new Foxbox({ net: netStub }); + + settingsStub = sinon.stub({ + pollingEnabled: false, + registrationService: 'https://fake.org:4443/ping', + on: () => {}, + }); + + foxbox = new Foxbox({ + net: netStub, + settings: settingsStub, + }); }); describe('constructor >', function() { @@ -45,7 +57,7 @@ describe('Foxbox >', function() { it('a net parameter can be provided', function(done) { netStub.fetchJSON - .withArgs('https://knilxof.org:4443/ping') + .withArgs('https://fake.org:4443/ping') .returns(Promise.resolve()); foxbox.init() @@ -60,7 +72,7 @@ describe('Foxbox >', function() { describe('exposes an array of boxes', function() { it('in single box mode', function(done) { netStub.fetchJSON - .withArgs('https://knilxof.org:4443/ping') + .withArgs('https://fake.org:4443/ping') .returns(Promise.resolve(singleBox)); assert.isArray(foxbox.boxes); @@ -68,7 +80,7 @@ describe('Foxbox >', function() { foxbox.init() .then(() => { assert.deepEqual(foxbox.boxes, [{ - local_origin: 'https://local.abc.box.knilxof.org:3000', + local_origin: 'https://local.abc.box.fake.org:3000', tunnel_origin: 'null', client: 'abc', }]); @@ -78,7 +90,7 @@ describe('Foxbox >', function() { it('in multiple boxes mode', function(done) { netStub.fetchJSON - .withArgs('https://knilxof.org:4443/ping') + .withArgs('https://fake.org:4443/ping') .returns(Promise.resolve(multiBox)); foxbox.init() diff --git a/tests/unit/lib/foxbox/services_test.js b/tests/unit/lib/foxbox/services_test.js index 4b1c221..d8e2d12 100644 --- a/tests/unit/lib/foxbox/services_test.js +++ b/tests/unit/lib/foxbox/services_test.js @@ -1,4 +1,4 @@ -import { waitFor } from '../../test-utils'; +import { waitForNextMacroTask } from '../../test-utils'; import Services from 'js/lib/foxbox/services'; import BaseService from 'js/lib/foxbox/services/base'; @@ -117,19 +117,24 @@ describe('Services >', function () { services.togglePolling(true); }); + afterEach(function() { + this.sinon.clock.restore(); + }); + it('new service should be added to the list', function(done) { apiStub.get.withArgs('services').returns( Promise.resolve([...dbServices, doorLockServiceRaw]) ); - // Tick 2000 and immediately restore clock so that 'waitFor' can use real - // setTimeout and setInterval. this.sinon.clock.tick(2000); - this.sinon.clock.restore(); // Wait until services cache is updated. - waitFor(() => onServicesChangedStub.called) - .then(() => services.getAll()) + waitForNextMacroTask() + .then(() => { + sinon.assert.calledOnce(onServicesChangedStub); + + return services.getAll(); + }) .then((services) => { assert.lengthOf(services, 3); @@ -162,14 +167,15 @@ describe('Services >', function () { Promise.resolve([dbServices[0]]) ); - // Tick 2000 and immediately restore clock so that 'waitFor' can use real - // setTimeout and setInterval. this.sinon.clock.tick(2000); - this.sinon.clock.restore(); // Wait until services cache is updated. - waitFor(() => onServicesChangedStub.called) - .then(() => services.getAll()) + waitForNextMacroTask() + .then(() => { + sinon.assert.calledOnce(onServicesChangedStub); + + return services.getAll(); + }) .then((services) => { assert.lengthOf(services, 1); diff --git a/tests/unit/test-utils.js b/tests/unit/test-utils.js index bd7ffbf..44d02a5 100644 --- a/tests/unit/test-utils.js +++ b/tests/unit/test-utils.js @@ -8,3 +8,27 @@ export function waitFor(fn) { .subscribe(resolve); }); } + +/** + * Allows to wait for a new macro task and until all currently scheduled + * micro tasks are completed (e.g. promises or mutation observers). + * Post message trick is used instead of setTimeout as there is a high chance + * that setTimeout and alike are mocked by Sinon. + * + * @return {Promise} + */ +export function waitForNextMacroTask() { + return new Promise((resolve) => { + function onMessage(evt) { + if (evt.data !== 'x-macro-task') { + return; + } + + window.removeEventListener('message', onMessage); + resolve(); + } + + window.addEventListener('message', onMessage); + window.postMessage('x-macro-task', window.location.origin); + }); +} diff --git a/tests/unit/views/services/camera_test.js b/tests/unit/views/services/camera_test.js index 46fe2f1..49ddc78 100644 --- a/tests/unit/views/services/camera_test.js +++ b/tests/unit/views/services/camera_test.js @@ -1,5 +1,4 @@ import React from 'components/react'; - import CameraServiceView from 'js/views/services/camera'; import { waitFor } from '../../test-utils'; @@ -20,6 +19,12 @@ describe('Camera service view tests', function() { component = TestUtils.renderIntoDocument( ); + + assert.isDefined( + component, + 'ReactDOM.render() did not render component ' + + '(see https://github.com/fxbox/app/issues/133).' + ); }); it('Renders itself in the correct state', function() {