diff --git a/karma.conf.js b/karma.conf.js index f6d4a0bc..c17c80ac 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -20,6 +20,7 @@ module.exports = function(config) { 'src/components/q/q.js', 'src/components/idbwrapper/idbstore.js', 'spec/helpers/*.js', + 'src/js/factories/*.js', 'src/js/directives/*.js', 'src/js/services/*.js', 'src/js/views/*.js', diff --git a/spec/factories/pollerSpec.js b/spec/factories/pollerSpec.js new file mode 100644 index 00000000..a82abfcd --- /dev/null +++ b/spec/factories/pollerSpec.js @@ -0,0 +1,103 @@ +describe('poller', function () { + "use strict"; + + var ngFactories = factory('factories/ng-factories'); + var module = factory('factories/poller', { + 'factories/ng-factories': ngFactories + }); + + var Poller; + var $interval; + var poller; + var callback; + var INTERVAL = 1000; + + beforeEach(function () { + angular.mock.module(module.name); + angular.mock.inject(["Poller", "$interval", function (_Poller_, _$interval_) { + Poller = _Poller_; + $interval = _$interval_; + callback = jasmine.createSpy("callback"); + poller = new Poller(INTERVAL, callback); + }]); + + }); + + describe('constructor', function () { + it('should not start poller', function () { + $interval.flush(1001); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('start', function () { + it('should be started when someone is interested', function () { + poller.start(); + $interval.flush(INTERVAL + 1); + expect(callback).toHaveBeenCalledTimes(1); + }); + it('should keep running when someone is interested', function () { + poller.start(); + $interval.flush(INTERVAL); + expect(callback).toHaveBeenCalledTimes(1); + $interval.flush(INTERVAL); + expect(callback).toHaveBeenCalledTimes(2); + }); + }); + + describe('stop', function () { + it('should stop when no-one is interested, case 1', function () { + poller.start(); + $interval.flush(INTERVAL); + expect(callback).toHaveBeenCalledTimes(1); + poller.stop(); + $interval.flush(INTERVAL); + expect(callback).toHaveBeenCalledTimes(1); + }); + it('should stop when no-one is interested, case 2', function () { + poller.start(); + poller.start(); + $interval.flush(INTERVAL); + expect(callback).toHaveBeenCalledTimes(1); + poller.stop(); + poller.stop(); + $interval.flush(INTERVAL); + expect(callback).toHaveBeenCalledTimes(1); + }); + it('should not stop when at least one user is interested', function () { + poller.start(); + poller.start(); + $interval.flush(INTERVAL); + expect(callback).toHaveBeenCalledTimes(1); + poller.stop(); + $interval.flush(INTERVAL); + expect(callback).toHaveBeenCalledTimes(2); + }); + it('should throw when stopping more then starting', function () { + poller.start(); + poller.stop(); + expect(function () { + poller.stop(); + }).toThrowError(); + }) + }); + + describe('reset', function () { + it('should not start timer when no-one interested', function () { + $interval.flush(INTERVAL / 2); + poller.reset(); + $interval.flush(2 * INTERVAL); + expect(callback).not.toHaveBeenCalled(); + }); + it('should restart timer when someone interested', function () { + poller.start(); + $interval.flush(INTERVAL / 2); + expect(callback).not.toHaveBeenCalled(); + poller.reset(); + $interval.flush(INTERVAL / 2); + expect(callback).not.toHaveBeenCalled(); + $interval.flush(INTERVAL / 2); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/mocks/scoresMock.js b/spec/mocks/scoresMock.js index 3f4a7042..19fc3ff7 100644 --- a/spec/mocks/scoresMock.js +++ b/spec/mocks/scoresMock.js @@ -25,6 +25,8 @@ function createScoresMock($q,scoreboard) { update: jasmine.createSpy('scoreUpdateSpy'), _update: jasmine.createSpy('score_UpdateSpy'), save: jasmine.createSpy('scoreSaveSpy'), + enableAutoRefresh: jasmine.createSpy('enableAutoRefresh'), + disableAutoRefresh: jasmine.createSpy('disableAutoRefresh'), getRankings: jasmine.createSpy('getRankings').and.returnValue(scoreboard), }; } diff --git a/spec/services/ng-scoresSpec.js b/spec/services/ng-scoresSpec.js index 62cd2a39..40667328 100644 --- a/spec/services/ng-scoresSpec.js +++ b/spec/services/ng-scoresSpec.js @@ -5,6 +5,7 @@ describe('ng-scores',function() { var $stages; var $teams; var $q; + var $interval; var dummyTeam = { number: 123, name: 'foo' @@ -40,12 +41,16 @@ describe('ng-scores',function() { angular.mock.module(function($provide) { $provide.value('$fs', fsMock); }); - angular.mock.inject(["$scores", "$stages", "$teams", "$q", function(_$scores_, _$stages_, _$teams_,_$q_) { - $scores = _$scores_; - $stages = _$stages_; - $teams = _$teams_; - $q = _$q_; - }]); + angular.mock.inject([ + "$scores", "$stages", "$teams", "$q", "$interval", + function(_$scores_, _$stages_, _$teams_, _$q_, $_interval_) { + $scores = _$scores_; + $stages = _$stages_; + $teams = _$teams_; + $q = _$q_; + $interval = $_interval_; + } + ]); return $stages.init().then(function() { mockStage = $stages.get(rawMockStage.id); @@ -560,5 +565,54 @@ describe('ng-scores',function() { }); }); + describe('autoRefresh', function() { + it('should not be started when no-one is interested', function () { + fsMock.read.calls.reset(); + $interval.flush($scores.AUTO_REFRESH_INTERVAL + 1); + expect(fsMock.read).not.toHaveBeenCalled(); + }); + it('should be started when someone is interested', function () { + fsMock.read.calls.reset(); + $scores.enableAutoRefresh(); + $interval.flush($scores.AUTO_REFRESH_INTERVAL + 1); + expect(fsMock.read).toHaveBeenCalled(); + }); + it('should keep refreshing when someone is interested', function () { + fsMock.read.calls.reset(); + $scores.enableAutoRefresh(); + $interval.flush($scores.AUTO_REFRESH_INTERVAL + 1); + expect(fsMock.read).toHaveBeenCalled(); + fsMock.read.calls.reset(); + $interval.flush($scores.AUTO_REFRESH_INTERVAL + 1); + expect(fsMock.read).toHaveBeenCalled(); + }); + it('should be stopped when everyone unregistered', function () { + $scores.enableAutoRefresh(); + $interval.flush($scores.AUTO_REFRESH_INTERVAL + 1); + $scores.disableAutoRefresh(); + + fsMock.read.calls.reset(); + $interval.flush($scores.AUTO_REFRESH_INTERVAL + 1); + expect(fsMock.read).not.toHaveBeenCalled(); + }); + it('should skip refresh when busy', function () { + fsMock.read.calls.reset(); + $scores.enableAutoRefresh(); + $scores.beginupdate(); + $interval.flush($scores.AUTO_REFRESH_INTERVAL + 1); + expect(fsMock.read).not.toHaveBeenCalled(); + }); + it('should continue refresh after it was busy before', function () { + fsMock.read.calls.reset(); + $scores.enableAutoRefresh(); + $scores.beginupdate(); + $interval.flush($scores.AUTO_REFRESH_INTERVAL + 1); + expect(fsMock.read).not.toHaveBeenCalled(); + $scores.endupdate(); + $interval.flush($scores.AUTO_REFRESH_INTERVAL + 1); + expect(fsMock.read).toHaveBeenCalled(); + }); + }); + }); diff --git a/src/js/factories/ng-factories.js b/src/js/factories/ng-factories.js new file mode 100644 index 00000000..a1affc2b --- /dev/null +++ b/src/js/factories/ng-factories.js @@ -0,0 +1,3 @@ +define('factories/ng-factories', ['angular'], function () { + return angular.module('factories', []); +}); diff --git a/src/js/factories/poller.js b/src/js/factories/poller.js new file mode 100644 index 00000000..f069199a --- /dev/null +++ b/src/js/factories/poller.js @@ -0,0 +1,79 @@ +/** + * Variant of $interval() which only starts the interval + * when at least one user is interested. + */ +define('factories/poller', [ + 'factories/ng-factories', +], function (module) { + "use strict"; + + return module.factory('Poller', [ + '$interval', + function($interval) { + + /** + * Ref-counted $interval. + * Starts calling `callback` every `interval` milliseconds when + * number of calls to `start()` is bigger than number of calls to `stop()`. + * + * Usage example: + * const p = new Poller(10000, () => { console.log("tick"); }); + * // when instantiating controller: + * p.start(); + * // when destroying controller: + * p.stop(); + */ + function Poller(interval, callback) { + this._interval = interval; + this._callback = callback; + this._refs = 0; + this._handle = undefined; // $interval handle + } + + /** + * Enable polling. + * Multiple calls to start() can be made. As long as the number of + * stop() calls is smaller than the number of start() calls, the + * poller keeps running. + */ + Poller.prototype.start = function () { + this._refs++; + this.reset(); + }; + + /** + * Disable polling when the number of stop calls is equal to + * the number of start calls. + * It is an error to call stop more often than start. + */ + Poller.prototype.stop = function () { + if (this._refs <= 0) { + throw new Error("start/stop calls mismatched"); + } + this._refs--; + if (this._refs === 0) { + // Keep timer running if others are still interested + this.reset(); + } + }; + + /** + * Restart poller, if it is currently running. + */ + Poller.prototype.reset = function() { + var self = this; + if (this._handle !== undefined) { + $interval.cancel(this._handle); + this._handle = undefined; + } + if (this._refs > 0) { + this._handle = $interval(function () { + self._callback(); + }, this._interval); + } + }; + + return Poller; + } + ]); +}); diff --git a/src/js/main.js b/src/js/main.js index 1b9e7cb9..7bdff802 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -6,6 +6,7 @@ define([ 'views/scoresheet', 'views/scores', 'views/ranking', + 'factories/ng-factories', 'services/ng-services', 'directives/ng-directives', 'directives/size', @@ -17,7 +18,7 @@ define([ 'angular-touch', 'angular-sanitize', 'angular' -],function(log,session,settings,teams,scoresheet,scores,ranking,services,directives,size,filters,indexFilter,fsTest,dbTest) { +],function(log,session,settings,teams,scoresheet,scores,ranking,factories,services,directives,size,filters,indexFilter,fsTest,dbTest) { log('device ready'); @@ -107,6 +108,7 @@ define([ ranking.name, filters.name, services.name, - directives.name + directives.name, + factories.name, ]); }); diff --git a/src/js/services/ng-scores.js b/src/js/services/ng-scores.js index 88fe350e..fad72e58 100644 --- a/src/js/services/ng-scores.js +++ b/src/js/services/ng-scores.js @@ -7,6 +7,7 @@ define('services/ng-scores',[ 'services/log', 'services/ng-fs', 'services/ng-stages', + 'factories/poller', 'services/ng-teams', ],function(module,log) { "use strict"; @@ -16,8 +17,8 @@ define('services/ng-scores',[ var SCORES_VERSION = 2; return module.service('$scores', - ['$rootScope', '$fs', '$stages', '$q', '$teams', - function($rootScope, $fs, $stages, $q, $teams) { + ['$rootScope', '$fs', '$stages', '$q', '$teams', 'Poller', + function($rootScope, $fs, $stages, $q, $teams, Poller) { // Replace placeholders in format string. // Example: format("Frobnicate {0} {1} {2}", "foo", "bar") @@ -131,6 +132,8 @@ define('services/ng-scores',[ this._updating = 0; this._initialized = null; // Promise this._pollingSheets = null; // Promise + this.AUTO_REFRESH_INTERVAL = 30 * 1000; // milliseconds + this._autoRefreshPoller = new Poller(this.AUTO_REFRESH_INTERVAL, function() { self._onAutoRefresh(); }); this.init(); } @@ -178,6 +181,7 @@ define('services/ng-scores',[ Scores.prototype.load = function() { var self = this; + this._autoRefreshPoller.reset(); return $fs.read('scores.json').then(function(res) { self.beginupdate(); try { @@ -336,11 +340,42 @@ define('services/ng-scores',[ throw new Error("beginupdate()/endupdate() calls mismatched"); } this._updating--; - if (this._updating === 0) { + if (!this.isUpdating()) { this._update(); } }; + Scores.prototype.isUpdating = function () { + return this._updating > 0; + }; + + /** + * Enable automatic refresh of scores if no e.g. manual + * refresh happened for a while. This is mostly used as + * a backup scenario in case we e.g. missed an (MHub) event, + * or when MHub is not available. + * Use disableAutoRefresh() when no longer needed (e.g. on + * controller destruction). + */ + Scores.prototype.enableAutoRefresh = function() { + this._autoRefreshPoller.start(); + }; + + /** + * Unregister interest in auto-refresh. See enableAutoRefresh(). + */ + Scores.prototype.disableAutoRefresh = function () { + this._autoRefreshPoller.stop(); + }; + + Scores.prototype._onAutoRefresh = function () { + // Reload scores from server (if we're not already + // in the middle of something). + if (!this.isUpdating()) { + this.load(); + } + }; + /** * Poll storage for any new score sheets. * Ignore already processed sheets, add a new score entry for each @@ -421,7 +456,7 @@ define('services/ng-scores',[ }; Scores.prototype._update = function() { - if (this._updating > 0) { + if (this.isUpdating()) { return; } diff --git a/src/js/services/ng-services.js b/src/js/services/ng-services.js index 8374a32b..5485ba5f 100644 --- a/src/js/services/ng-services.js +++ b/src/js/services/ng-services.js @@ -1,3 +1,3 @@ define('services/ng-services',['angular'],function() { - return angular.module('services',[]); + return angular.module('services',['factories']); }); diff --git a/src/js/views/ranking.js b/src/js/views/ranking.js index 479a6055..3cb5c7b1 100644 --- a/src/js/views/ranking.js +++ b/src/js/views/ranking.js @@ -156,10 +156,16 @@ define('views/ranking',[ $scope.stages = $stages.stages; $scope.scoreboard = $scores.scoreboard; + // Ensure periodic updates of scores view + $scores.enableAutoRefresh(); + $scope.$on("$destroy", function () { + $scores.disableAutoRefresh(); + }); + $scope.getRoundLabel = function(round){ return "Round " + round; }; - + } ]); diff --git a/src/js/views/scores.js b/src/js/views/scores.js index 69bc0986..825b7f34 100644 --- a/src/js/views/scores.js +++ b/src/js/views/scores.js @@ -16,6 +16,12 @@ define('views/scores',[ $scope.scores = $scores.scores; $scope.stages = $stages.stages; + // Ensure periodic updates of scores view + $scores.enableAutoRefresh(); + $scope.$on("$destroy", function () { + $scores.disableAutoRefresh(); + }); + $scope.editing = {}; // Keep state of currently-editing scores $scope.original = {}; // Keep original score when edit started