From 1585e5c73a5b8212ce9afa5b17bc1a073e8fc480 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sat, 2 Sep 2017 23:45:00 +0300 Subject: [PATCH 01/19] Added lockfile --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index f9fac7a7..11c5b279 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "basic-auth-connect": "~1.0.0", + "lockfile": "~1.0.3", "uuid": "^3.1.0", "express": "^4.10.4", "express-session": "^1.5.0", From f1957fb5a4646a5144faf192a2cd42820bd69025 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sat, 2 Sep 2017 23:45:22 +0300 Subject: [PATCH 02/19] Added angular storage --- .gitignore | 1 + .../angular-storage/angular-storage.js | 237 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 src/components/angular-storage/angular-storage.js diff --git a/.gitignore b/.gitignore index 5431a602..c74d23f0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ src/components/*/* !src/components/angular-touch/angular*.js !src/components/angular-touch/angular*.map !src/components/angular-bootstrap/*.js +!src/components/angular-storage/*.js !src/components/bootstrap-css/css !src/components/bootstrap-css/img diff --git a/src/components/angular-storage/angular-storage.js b/src/components/angular-storage/angular-storage.js new file mode 100644 index 00000000..6cab8d58 --- /dev/null +++ b/src/components/angular-storage/angular-storage.js @@ -0,0 +1,237 @@ +(function (root, factory) { + 'use strict'; + + if (typeof define === 'function' && define.amd) { + define(['angular'], factory); + } else if (root.hasOwnProperty('angular')) { + // Browser globals (root is window), we don't register it. + factory(root.angular); + } else if (typeof exports === 'object') { + module.exports = factory(require('angular')); + } +}(this , function (angular) { + 'use strict'; + + // In cases where Angular does not get passed or angular is a truthy value + // but misses .module we can fall back to using window. + angular = (angular && angular.module ) ? angular : window.angular; + + + function isStorageSupported($window, storageType) { + + // Some installations of IE, for an unknown reason, throw "SCRIPT5: Error: Access is denied" + // when accessing window.localStorage. This happens before you try to do anything with it. Catch + // that error and allow execution to continue. + + // fix 'SecurityError: DOM Exception 18' exception in Desktop Safari, Mobile Safari + // when "Block cookies": "Always block" is turned on + var supported; + try { + supported = $window[storageType]; + } + catch(err) { + supported = false; + } + + // When Safari (OS X or iOS) is in private browsing mode, it appears as though localStorage and sessionStorage + // is available, but trying to call .setItem throws an exception below: + // "QUOTA_EXCEEDED_ERR: DOM Exception 22: An attempt was made to add something to storage that exceeded the quota." + if(supported) { + var key = '__' + Math.round(Math.random() * 1e7); + try { + $window[storageType].setItem(key, key); + $window[storageType].removeItem(key, key); + } + catch(err) { + supported = false; + } + } + + return supported; + } + + /** + * @ngdoc overview + * @name ngStorage + */ + + return angular.module('ngStorage', []) + + /** + * @ngdoc object + * @name ngStorage.$localStorage + * @requires $rootScope + * @requires $window + */ + + .provider('$localStorage', _storageProvider('localStorage')) + + /** + * @ngdoc object + * @name ngStorage.$sessionStorage + * @requires $rootScope + * @requires $window + */ + + .provider('$sessionStorage', _storageProvider('sessionStorage')); + + function _storageProvider(storageType) { + var providerWebStorage = isStorageSupported(window, storageType); + + return function () { + var storageKeyPrefix = 'ngStorage-'; + + this.setKeyPrefix = function (prefix) { + if (typeof prefix !== 'string') { + throw new TypeError('[ngStorage] - ' + storageType + 'Provider.setKeyPrefix() expects a String.'); + } + storageKeyPrefix = prefix; + }; + + var serializer = angular.toJson; + var deserializer = angular.fromJson; + + this.setSerializer = function (s) { + if (typeof s !== 'function') { + throw new TypeError('[ngStorage] - ' + storageType + 'Provider.setSerializer expects a function.'); + } + + serializer = s; + }; + + this.setDeserializer = function (d) { + if (typeof d !== 'function') { + throw new TypeError('[ngStorage] - ' + storageType + 'Provider.setDeserializer expects a function.'); + } + + deserializer = d; + }; + + this.supported = function() { + return !!providerWebStorage; + }; + + // Note: This is not very elegant at all. + this.get = function (key) { + return providerWebStorage && deserializer(providerWebStorage.getItem(storageKeyPrefix + key)); + }; + + // Note: This is not very elegant at all. + this.set = function (key, value) { + return providerWebStorage && providerWebStorage.setItem(storageKeyPrefix + key, serializer(value)); + }; + + this.remove = function (key) { + providerWebStorage && providerWebStorage.removeItem(storageKeyPrefix + key); + } + + this.$get = [ + '$rootScope', + '$window', + '$log', + '$timeout', + '$document', + + function( + $rootScope, + $window, + $log, + $timeout, + $document + ){ + + // The magic number 10 is used which only works for some keyPrefixes... + // See https://github.com/gsklee/ngStorage/issues/137 + var prefixLength = storageKeyPrefix.length; + + // #9: Assign a placeholder object if Web Storage is unavailable to prevent breaking the entire AngularJS app + // Note: recheck mainly for testing (so we can use $window[storageType] rather than window[storageType]) + var isSupported = isStorageSupported($window, storageType), + webStorage = isSupported || ($log.warn('This browser does not support Web Storage!'), {setItem: angular.noop, getItem: angular.noop, removeItem: angular.noop}), + $storage = { + $default: function(items) { + for (var k in items) { + angular.isDefined($storage[k]) || ($storage[k] = angular.copy(items[k]) ); + } + + $storage.$sync(); + return $storage; + }, + $reset: function(items) { + for (var k in $storage) { + '$' === k[0] || (delete $storage[k] && webStorage.removeItem(storageKeyPrefix + k)); + } + + return $storage.$default(items); + }, + $sync: function () { + for (var i = 0, l = webStorage.length, k; i < l; i++) { + // #8, #10: `webStorage.key(i)` may be an empty string (or throw an exception in IE9 if `webStorage` is empty) + (k = webStorage.key(i)) && storageKeyPrefix === k.slice(0, prefixLength) && ($storage[k.slice(prefixLength)] = deserializer(webStorage.getItem(k))); + } + }, + $apply: function() { + var temp$storage; + + _debounce = null; + + if (!angular.equals($storage, _last$storage)) { + temp$storage = angular.copy(_last$storage); + angular.forEach($storage, function(v, k) { + if (angular.isDefined(v) && '$' !== k[0]) { + webStorage.setItem(storageKeyPrefix + k, serializer(v)); + delete temp$storage[k]; + } + }); + + for (var k in temp$storage) { + webStorage.removeItem(storageKeyPrefix + k); + } + + _last$storage = angular.copy($storage); + } + }, + $supported: function() { + return !!isSupported; + } + }, + _last$storage, + _debounce; + + $storage.$sync(); + + _last$storage = angular.copy($storage); + + $rootScope.$watch(function() { + _debounce || (_debounce = $timeout($storage.$apply, 100, false)); + }); + + // #6: Use `$window.addEventListener` instead of `angular.element` to avoid the jQuery-specific `event.originalEvent` + $window.addEventListener && $window.addEventListener('storage', function(event) { + if (!event.key) { + return; + } + + // Reference doc. + var doc = $document[0]; + + if ( (!doc.hasFocus || !doc.hasFocus()) && storageKeyPrefix === event.key.slice(0, prefixLength) ) { + event.newValue ? $storage[event.key.slice(prefixLength)] = deserializer(event.newValue) : delete $storage[event.key.slice(prefixLength)]; + + _last$storage = angular.copy($storage); + + $rootScope.$apply(); + } + }); + + $window.addEventListener && $window.addEventListener('beforeunload', function() { + $storage.$apply(); + }); + + return $storage; + } + ]; + }; + } + +})); \ No newline at end of file From a7d2112c082003e03251f5d68f1bdd28d7cf7d11 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sat, 2 Sep 2017 23:46:14 +0300 Subject: [PATCH 03/19] Added scores CRUD API --- server_modules/scores.js | 96 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/server_modules/scores.js b/server_modules/scores.js index ba65806b..ecfd35f9 100644 --- a/server_modules/scores.js +++ b/server_modules/scores.js @@ -1,6 +1,9 @@ +var Lock = require('./lock'); var utils = require('./utils'); var fileSystem = require('./file_system'); +var log = require('./log'); var Q = require('q'); +var id = require('uuid/v4'); function filterPublished(score) { return score.published; @@ -15,6 +18,45 @@ function reduceToMap(key) { } } +/** + * Atomically change scores file. + * Action callback is called with the current contents of the scores file, and is expected + * to return the new contents (or a Promise for it). + * A lock is acquired and held during the entire operation. + * @param action Callback that receives current scores.json object, must return new contents (or Promise for it) + * @return Promise for updated scores object + */ +function changeScores(action) { + var path = fileSystem.getDataFilePath('scores.json'); + var lock = new Lock('scores.json.lock', { retries: 5, retryWait: 100 }); + + console.log(lock.options); + + return lock.lock() + .then(() => fileSystem.readJsonFile(path)) + .catch((err) => { //Ignoring all file not found errors, and just returning empty scores.json + console.log("catching"); + if(err.message === 'file not found') { + console.log("hells yeah!"); + return { version:3, scores: [] }; + } else { + console.log("ho no! " + err.message); + throw err; + } + }) + .then(action) + .then(scores => { + return fileSystem.writeFile(path, JSON.stringify(scores)) + .then(() => { + return lock.unlock(); + }).catch((err) => { + return lock.unlock(); + }).then(function() { + return scores; + }); + }); +} + exports.route = function(app) { //get all, grouped by round @@ -32,7 +74,7 @@ exports.route = function(app) { return rounds; },{}); res.json(published); - }).catch(utils.sendError(res)).done(); + }).catch(err => utils.sendError(res, err)).done(); }); //get scores by round @@ -44,7 +86,57 @@ exports.route = function(app) { return score.published && score.round === round; }); res.json(scoresForRound); - }).catch(utils.sendError(res)).done(); + }).catch(err => utils.sendError(res, err)).done(); + }); + + //save a new score + app.post('/scores/create',function(req,res) { + var body = JSON.parse(req.body); + var scoresheet = body.scoresheet; + var score = body.score; + + fileSystem.writeFile(fileSystem.getDataFilePath("scoresheets/" + score.file), req.body) + .then(changeScores(function(result) { + if(typeof(score.id) === 'undefined') { + score.id = id(); + } + result.scores.push(score); + return result; + })) + .then(function(scores) { + res.json(scores).end(); + }).catch(err => utils.sendError(res, err)).done(); + + }); + + //delete a score at an id + app.post('/scores/delete/:id',function(req,res) { + changeScores(function(result) { + var index = result.scores.findIndex((score) => score.id === req.params.id); + if(index === -1) { + throw new Error(`Could not find score with id ${req.params.id}`); + } + result.scores.splice(index, 1); + return result; + }).then(function(scores) { + res.json(scores).end(); + }).catch(err => utils.sendError(res, err)).done(); }); + //edit a score at an id + app.post('/scores/update/:id',function(req,res) { + var score = JSON.parse(req.body); + changeScores(function(result) { + var index = result.scores.findIndex((score) => score.id === req.params.id); + if(index === -1) { + throw new Error(`Could not find score with id ${req.params.id}`); + } + result.scores[index] = score; + return result; + }).then(function(scores) { + res.json(scores).end(); + }).catch(err => utils.sendError(res, err)).done(); + }); + + }; From 13b58c10a848e2740b2f8322798cadc8363dbb67 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sat, 2 Sep 2017 23:46:44 +0300 Subject: [PATCH 04/19] Added backward compatibility on server startup --- server_modules/scores.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/server_modules/scores.js b/server_modules/scores.js index ecfd35f9..985a6c86 100644 --- a/server_modules/scores.js +++ b/server_modules/scores.js @@ -140,3 +140,27 @@ exports.route = function(app) { }; + +// For backward compatibility + +changeScores(function(scores) { + if(typeof(scores.version) === 'undefined') { + scores.forEach(score => score.id = id()) + return { + version: 3, + scores: scores + } + + } else if(scores.version === 3) { + return scores; + + } else if(scores.version === 2) { + scores.scores.forEach(score => score.id = id()) + scores.version = 3; + return scores; + + } else { + throw new Error('Unkown scores version'); + } + +}); From da2572b5657cae8747f458635b7f1b1ddfbd0878 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sat, 2 Sep 2017 23:47:04 +0300 Subject: [PATCH 05/19] Added indepndence service --- src/js/services/ng-independence.js | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/js/services/ng-independence.js diff --git a/src/js/services/ng-independence.js b/src/js/services/ng-independence.js new file mode 100644 index 00000000..d9e3c8eb --- /dev/null +++ b/src/js/services/ng-independence.js @@ -0,0 +1,70 @@ +/** + * Independence storage: backup for when the server is down. Save the actions now - + * use them later when the server's back up. + */ +define('services/ng-independence',[ + 'services/ng-services' +],function(module) { + "use strict"; + + return module.service('$independence', ['$q','$localStorage', '$http', + function($q,$localStorage,$http) { + function IndependentActionStroage() {} + + function actAheadOfServer(key, url, data) { + $localStorage[`action_${key}_${Date.now()}`] = JSON.stringify({ url: url, data: data }); + } + + IndependentActionStroage.prototype.act = function(token, url, data, fallback) { + var self = this; + return $http.post(url, data).then(function(res) { + self.sendSavedActionsToServer(token); + return res; + }).catch(function(err) { + actAheadOfServer(token, url, data); + if(fallback) { + fallback(); + } + }); + }; + + IndependentActionStroage.prototype.sendSavedActionsToServer = function(token) { + if(this._sendingSavedActionsToServer) return; + this._sendingSavedActionsToServer = true; + + var self = this; + let promises = []; + + for(let key in $localStorage) { + var _break = false; + + if(key.startsWith(`action_${token}`)) { + let action = JSON.parse($localStorage[key]); + + let promise = self.act(key, action.url, action.data).then(function() { + delete $localStorage[key]; + }, function() { + _break = true; + }); + + promises.push(promise); + } + if(_break) break; + } + if(promises.length === 0) { + self._sendingSavedActionsToServer = false; + return; + } + + $q.all(promises).then(function() { + self._sendingSavedActionsToServer = false; + }); + }; + + IndependentActionStroage.prototype.pendingActions = function(token) { + return Object.keys($localStorage).filter((k) => k.startsWith(`action_${token}`)).length; + }; + + return new IndependentActionStroage(); + }]); +}); From 29d3fd8129e01a67448ba6f9cd0f9b253e2e6656 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:04:03 +0300 Subject: [PATCH 06/19] Using the CRUD API to give the server control against changes overriding --- server_modules/scores.js | 5 +-- src/js/services/ng-scores.js | 68 +++++++++++++++--------------------- src/js/views/scores.js | 27 ++++++-------- src/js/views/scoresheet.js | 19 +++------- 4 files changed, 46 insertions(+), 73 deletions(-) diff --git a/server_modules/scores.js b/server_modules/scores.js index 985a6c86..7618141b 100644 --- a/server_modules/scores.js +++ b/server_modules/scores.js @@ -38,7 +38,7 @@ function changeScores(action) { console.log("catching"); if(err.message === 'file not found') { console.log("hells yeah!"); - return { version:3, scores: [] }; + return { version:3, scores: [], sheets: [] }; } else { console.log("ho no! " + err.message); throw err; @@ -148,7 +148,8 @@ changeScores(function(scores) { scores.forEach(score => score.id = id()) return { version: 3, - scores: scores + scores: scores, + sheets: [] } } else if(scores.version === 3) { diff --git a/src/js/services/ng-scores.js b/src/js/services/ng-scores.js index 0bea8f7e..e2eb90e3 100644 --- a/src/js/services/ng-scores.js +++ b/src/js/services/ng-scores.js @@ -12,7 +12,7 @@ define('services/ng-scores',[ // Current file version for scores. // Increment when adding/removing 'features' to stored scores. - var SCORES_VERSION = 2; + var SCORES_VERSION = 3; return module.service('$scores', ['$rootScope', '$fs', '$stages', '$q', '$teams', @@ -157,24 +157,6 @@ define('services/ng-scores',[ }; - Scores.prototype.clear = function() { - this._rawScores = []; - this._update(); - }; - - Scores.prototype.save = function() { - var data = { - version: 2, - scores: this._rawScores, - sheets: Object.keys(this._sheets), - }; - return $fs.write('scores.json', data).then(function() { - log('scores saved'); - }, function(err) { - log('scores write error', err); - }); - }; - Scores.prototype.load = function() { var self = this; return $fs.read('scores.json').then(function(res) { @@ -290,27 +272,30 @@ define('services/ng-scores',[ }; } - Scores.prototype.add = function(score) { - // Create a copy of the score, in case the - // original score is being modified... - this._rawScores.push(sanitizeEntry(score)); - this._update(); + Scores.prototype.create = function(scoresheet) { + var self = this; + + var score = sanitizeEntry(scoresheet); + return $independence.act('scores','/scores/create',{ scoresheet: scoresheet, score: score }, function() { + self._rawScores.push(score); + }).then((res) => self._update(res)); }; - /** - * Update score at given index. - * This differs from e.g. remove(index); add(score); in that - * it ensures that only allowed changes are made, and marks the - * the score as modified. - */ - Scores.prototype.update = function(index, score) { - if (index < 0 || index >= this._rawScores.length) { - throw new RangeError("unknown score index: " + index); - } - var newScore = sanitizeEntry(score); - newScore.edited = (new Date()).toString(); - this._rawScores.splice(index, 1, newScore); - this._update(); + Scores.prototype.delete = function(score) { + var self = this; + + return $independence.act('scores','/scores/delete/' + score.id, {}, function() { + self._rawScores.splice(self.scores.findIndex(s => s.id === score.id), 1); + }).then((res) => self._update(res)); + }; + + Scores.prototype.update = function(score) { + var self = this; + + score.edited = (new Date()).toString(); + return $independence.act('scores','/scores/update/' + score.id, score, function() { + self._rawScores[self.scores.findIndex(s => s.id === score.id)] = score; + }).then((res) => self._update(res)); }; Scores.prototype.beginupdate = function() { @@ -406,11 +391,16 @@ define('services/ng-scores',[ return self._pollingSheets; }; - Scores.prototype._update = function() { + Scores.prototype._update = function(response) { if (this._updating > 0) { return; } + if(response) { + this._rawScores = response.scores; + this._sheets = response.sheets; + } + var self = this; var results = this.getRankings(); diff --git a/src/js/views/scores.js b/src/js/views/scores.js index 574ab79c..461a41e1 100644 --- a/src/js/views/scores.js +++ b/src/js/views/scores.js @@ -20,46 +20,39 @@ define('views/scores',[ $scope.rev = (String($scope.sort) === String(col)) ? !$scope.rev : !!defaultSort; $scope.sort = col; }; - $scope.removeScore = function(index) { - $scores.remove(index); - return $scores.save(); + $scope.removeScore = function(scoreId) { + return $scores.remove(scoreId); }; - $scope.editScore = function(index) { - var score = $scores.scores[index]; + $scope.editScore = function(score) { score.$editing = true; }; - $scope.publishScore = function(index) { - var score = $scores.scores[index]; + $scope.publishScore = function(score) { score.published = true; saveScore(score); }; - $scope.unpublishScore = function(index) { - var score = $scores.scores[index]; + $scope.unpublishScore = function(score) { score.published = false; saveScore(score); }; - $scope.finishEditScore = function(index) { + $scope.finishEditScore = function(score) { // The score entry is edited 'inline', then used to // replace the entry in the scores list and its storage. // Because scores are always 'sanitized' before storing, // the $editing flag is automatically discarded. - var score = $scores.scores[index]; saveScore(score); }; function saveScore(score) { - try { - $scores.update(score.index, score); - $scores.save(); - } catch(e) { - $window.alert("Error updating score: " + e); - } + $scores.update(score).catch(function(err) { + $window.alert("Error updating score: " + err); + }); } $scope.cancelEditScore = function() { + score.$editing = false; $scores._update(); }; diff --git a/src/js/views/scoresheet.js b/src/js/views/scoresheet.js index 1e18838a..90f8fa7a 100644 --- a/src/js/views/scoresheet.js +++ b/src/js/views/scoresheet.js @@ -23,8 +23,8 @@ define('views/scoresheet',[ ]); return module.controller(moduleName + 'Ctrl', [ - '$scope','$fs','$stages','$settings','$challenge','$window','$q','$teams','$handshake', - function($scope,$fs,$stages,$settings,$challenge,$window,$q,$teams,$handshake) { + '$scope','$fs','$stages','$scores','$settings','$challenge','$window','$q','$teams','$handshake', + function($scope,$fs,$stages,$scores,$settings,$challenge,$window,$q,$teams,$handshake) { log('init scoresheet ctrl'); // Set up defaults @@ -242,7 +242,7 @@ define('views/scoresheet',[ data.signature = $scope.signature; data.score = $scope.score(); - var fn = [ + data.filename = [ 'score', data.stage.id, 'round' + data.round, @@ -251,18 +251,7 @@ define('views/scoresheet',[ data.uniqueId ].join('_')+'.json'; - return $fs.write("scoresheets/" + fn,data).then(function() { - log('result saved'); - $scope.clear(); - $window.alert('Thanks for submitting a score of ' + - data.score + - ' points for team (' + data.team.number + ') ' + data.team.name + - ' in ' + data.stage.name + ' ' + data.round + '.' - ); - }, function(err) { - $window.alert('Error submitting score: ' + String(err)); - throw err; - }); + return $scores.create(data); }; $scope.openDescriptionModal = function (mission) { From 9be85ba1a8d04f43080061a868f2047689bfdb72 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:18:48 +0300 Subject: [PATCH 07/19] Added ability to read files in directory --- server_modules/file_system.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server_modules/file_system.js b/server_modules/file_system.js index a704a6f2..146e780f 100644 --- a/server_modules/file_system.js +++ b/server_modules/file_system.js @@ -70,6 +70,20 @@ exports.readJsonFile = function(file) { return exports.readFile(file).then(parseData); }; +exports.filesInDir = function(path) { + path = exports.resolve(path); + + return Q.promise(function(resolve, reject) { + fs.readdir(path, (err, files) => { + if(err) { + reject(err); + } else { + resolve(files); + } + }); + }); +}; + exports.route = function(app) { //reading the "file system" From 129328870c00650611298ab3539286035bdb36eb Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:20:27 +0300 Subject: [PATCH 08/19] Added autoloading of scoresheets on server start. As for now, this is the only shared functionality between the client and the server, and thereafore it doesn't justify creating a whole platform for code sharing --- server_modules/scores.js | 71 ++++++++++++++++++++++++++++++++++++++ src/js/views/scoresheet.js | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/server_modules/scores.js b/server_modules/scores.js index 7618141b..202d1d79 100644 --- a/server_modules/scores.js +++ b/server_modules/scores.js @@ -101,6 +101,7 @@ exports.route = function(app) { score.id = id(); } result.scores.push(score); + result.sheets.push(score.file) return result; })) .then(function(scores) { @@ -165,3 +166,73 @@ changeScores(function(scores) { } }); + +// Polling for sheets automatically on server load + +function sanitizeScore(score) { + // Passthrough for already valid inputs + if (typeof score === "number") + return score; + switch (score) { + case "dnc": + case "dsq": + case null: + return score; + } + // Accept numbers stored as strings + var n = parseInt(score, 10); + if (String(n) === score) + return n; + // Try to convert some spellings of accepted strings + if (typeof score === "string") { + var s = score.toLowerCase(); + switch (s) { + case "dnc": + case "dsq": + return s; + case "": + return null; + } + } + // Pass through the rest + log("Warning: invalid score " + score); + return score; +} + +function loadScoresheetScore(filename) { + return fs.readJsonFile(fileSystem.getDataFilePath("scoresheets/" + score.file)).then(function(scoresheet) { + return { + file: (entry.file !== undefined && entry.file !== null) ? String(entry.file) : "", + teamNumber: parseInt((entry.teamNumber !== undefined) ? entry.teamNumber : entry.team.number, 10), + stageId: String((entry.stageId !== undefined) ? entry.stageId : entry.stage.id), + round: parseInt(entry.round, 10), + score: sanitizeScore(entry.score), // can be Number, null, "dnc", etc. + originalScore: parseInt(entry.originalScore !== undefined ? entry.originalScore : entry.score, 10), + edited: entry.edited !== undefined ? String(entry.edited) : undefined, // timestamp, e.g. "Wed Nov 26 2014 21:11:43 GMT+0100 (CET)" + published: !!entry.published, + table: entry.table + }; + }); +} + +changeScores(function(scores) { + return fileSystem.filesInDir('data/scoresheet').then(function(files) { + var promises = [] + + for(var i = 0; i < files.length; i++) { + if(!scores.sheets.includes(files[i])) { + var promise = loadScoresheetScore(files[i]).then(function(score) { + scores.scores.push(score); + scores.sheets.push(files[i]); + }).catch(function(err) { + log.error(`Error reading scoresheet ${files[i]}: ${err}`); + }); + promises.push(promise); + } + } + + return Q.all(promises).spread(function() { + return scores; + }); + }); +}); diff --git a/src/js/views/scoresheet.js b/src/js/views/scoresheet.js index 90f8fa7a..997ce150 100644 --- a/src/js/views/scoresheet.js +++ b/src/js/views/scoresheet.js @@ -242,7 +242,7 @@ define('views/scoresheet',[ data.signature = $scope.signature; data.score = $scope.score(); - data.filename = [ + data.file = [ 'score', data.stage.id, 'round' + data.round, From 309fb9d2fcde9d7dea552a10350931809c51b42f Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:22:00 +0300 Subject: [PATCH 09/19] Now that we're loading the scoresheet on server startup, we don't need to do it on demand --- src/js/services/ng-scores.js | 80 ------------------------------------ src/js/views/scores.js | 7 ---- src/views/pages/scores.html | 1 - 3 files changed, 88 deletions(-) diff --git a/src/js/services/ng-scores.js b/src/js/services/ng-scores.js index e2eb90e3..e463e5d4 100644 --- a/src/js/services/ng-scores.js +++ b/src/js/services/ng-scores.js @@ -129,7 +129,6 @@ define('services/ng-scores',[ this._updating = 0; this._initialized = null; // Promise - this._pollingSheets = null; // Promise this.init(); } @@ -312,85 +311,6 @@ define('services/ng-scores',[ } }; - /** - * Poll storage for any new score sheets. - * Ignore already processed sheets, add a new score entry for each - * new sheet. - * FIXME: this is a temporary hack to get basic multi-user scoring - * working very quickly. Functionality like this should be moved to - * a server-instance and/or be distributed. The reason for integrating - * it directly in $scores for now, is that this reduces the change of - * having the state of processed sheets getting out of sync with the - * list of scores. - */ - Scores.prototype.pollSheets = function() { - var self = this; - // Prevent (accidentally) performing the check in parallel - if (self._pollingSheets) { - return self._pollingSheets; - } - - self._pollingSheets = $fs.list("scoresheets/").catch(function(err) { - // Ignore the fact that there are no sheets at all yet - if (err.status === 404) { - return []; - } - // Convert to 'normal' errors in case of XHR response - if (err.status && err.responseText) { - throw new Error(format("error {0} ({1}): {2}", - err.status, err.statusText, - err.responseText - )); - } - // Otherwise, pass the error on - if (err instanceof Error) { - throw err; - } - // Fallback - throw new Error("unknown error: " + String(err)); - }).then(function(filenames) { - var promises = []; - // Walk over all sheets, find the 'new' ones - filenames.forEach(function(filename) { - if (filename in self._sheets) { - return; - } - // Retrieve the new sheet - var p = $fs.read("scoresheets/" + filename).then(function(sheet) { - // Convert to score entry and add to list - var score = { - file: filename, - teamNumber: sheet.teamNumber !== undefined ? sheet.teamNumber : sheet.team.number, - stageId: sheet.stageId !== undefined ? sheet.stageId : sheet.stage.id, - round: sheet.round, - score: sheet.score, - table: sheet.table - }; - self.add(score); - // Mark as processed - self._sheets[filename] = true; - log(format("Added new scoresheet: stage {0}, round {1}, team {2}, score {3}", - score.stageId, score.round, score.teamNumber, score.score - )); - }); - promises.push(p); - }); - // Make sure to wait for all sheets to be processed - // before resolving the promise. - return $q.all(promises).finally(function() { - // Always save scores if there was work to do, - // even in case of errors, as some scores may still - // have been added successfully. - if (promises.length > 0) { - return self.save(); - } - }); - }).finally(function() { - self._pollingSheets = null; - }); - return self._pollingSheets; - }; - Scores.prototype._update = function(response) { if (this._updating > 0) { return; diff --git a/src/js/views/scores.js b/src/js/views/scores.js index 461a41e1..b3eb65c0 100644 --- a/src/js/views/scores.js +++ b/src/js/views/scores.js @@ -56,13 +56,6 @@ define('views/scores',[ $scores._update(); }; - $scope.pollSheets = function() { - return $scores.pollSheets().catch(function(err) { - log("pollSheets() failed", err); - $window.alert("failed to poll sheets: " + err); - }); - }; - $scope.refresh = function() { $scores.load(); }; diff --git a/src/views/pages/scores.html b/src/views/pages/scores.html index 0e21587c..9e74eec4 100644 --- a/src/views/pages/scores.html +++ b/src/views/pages/scores.html @@ -7,7 +7,6 @@

-

From 79f87aed21067e804d639f054350424e8d354538 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:24:17 +0300 Subject: [PATCH 10/19] Added listening option to ng-message --- src/js/services/ng-message.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/js/services/ng-message.js b/src/js/services/ng-message.js index c12324cf..bf850e2b 100644 --- a/src/js/services/ng-message.js +++ b/src/js/services/ng-message.js @@ -12,6 +12,7 @@ define('services/ng-message',[ '$http','$settings','$q', function($http,$settings,$q) { var ws; + var listeners = []; function init() { if (ws) { @@ -37,10 +38,17 @@ define('services/ng-message',[ ws.onclose = function() { log("socket close"); }; - ws.onmessage = function(msg) { - log("socket message",msg); - // var data = JSON.parse(msg.data); - // handleMessage(data); + ws.onmessage = function(msg)function(msg) { + var data = JSON.parse(msg.data); + var headers = JSON.parse(msg.headers); + var topic = data.topic; + + listeners.filter((listener) => { + return (typeof(listener.topic) === 'string' && topic === listener.topic) || + (listener.topic instanceof RegExp && topic.matches(listener.topic)); + }).forEach(function(listener) { + listener.handler(data, msg); + }); }; return def.promise; }); @@ -50,13 +58,17 @@ define('services/ng-message',[ return { send: function(topic,data) { return init().then(function(ws) { + data = data || {}; ws.send(JSON.stringify({ type: "publish", node: ws.node, topic: topic, - data: data + data: data, })); }); + }, + on: function(topic, handler) { + listeners.push({ topic: topic, handler: handler }); } }; } From 37a60efd656e26bd851f2fee949576fc74e4a87a Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:25:03 +0300 Subject: [PATCH 11/19] Added token identity to fllscoring clients --- src/js/services/ng-message.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/js/services/ng-message.js b/src/js/services/ng-message.js index bf850e2b..e22e3c6e 100644 --- a/src/js/services/ng-message.js +++ b/src/js/services/ng-message.js @@ -13,6 +13,7 @@ define('services/ng-message',[ function($http,$settings,$q) { var ws; var listeners = []; + var token = parseInt(Math.floor(0x100000*(Math.random())), 16); function init() { if (ws) { @@ -43,6 +44,9 @@ define('services/ng-message',[ var headers = JSON.parse(msg.headers); var topic = data.topic; + msg.from = headers["scoring-token"];; + msg.fromMe = msg.from === token; + listeners.filter((listener) => { return (typeof(listener.topic) === 'string' && topic === listener.topic) || (listener.topic instanceof RegExp && topic.matches(listener.topic)); @@ -64,6 +68,7 @@ define('services/ng-message',[ node: ws.node, topic: topic, data: data, + headers: { "scoring-token": token } })); }); }, From 1fbcbed3349d83ae903ec99efa989b8c6c7cb049 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:27:42 +0300 Subject: [PATCH 12/19] Listening to updates from other clients --- src/js/services/ng-scores.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/js/services/ng-scores.js b/src/js/services/ng-scores.js index e463e5d4..6cff7da4 100644 --- a/src/js/services/ng-scores.js +++ b/src/js/services/ng-scores.js @@ -15,8 +15,8 @@ define('services/ng-scores',[ var SCORES_VERSION = 3; return module.service('$scores', - ['$rootScope', '$fs', '$stages', '$q', '$teams', - function($rootScope, $fs, $stages, $q, $teams) { + ['$rootScope', '$fs', '$stages', '$q', '$teams', '$message', + function($rootScope, $fs, $stages, $q, $teams, $message) { // Replace placeholders in format string. // Example: format("Frobnicate {0} {1} {2}", "foo", "bar") @@ -152,6 +152,13 @@ define('services/ng-scores',[ return self.load(); }); } + message.on('scores:reload', function(data, msg) { + if(msg.fromMe){ + return; + } + self.load(); + }); + return this._initialized; }; @@ -350,6 +357,7 @@ define('services/ng-scores',[ } }); $rootScope.$broadcast('validationError', this.validationErrors); + $message.send('scores:reload'); }; /** From be84591b9c771bf315ad51d139949103cd1bfcb6 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:28:55 +0300 Subject: [PATCH 13/19] Now that each client is listening to the others, the sync is completely seamless, and there's no need for the button. --- src/js/views/scores.js | 4 ---- src/views/pages/scores.html | 3 --- 2 files changed, 7 deletions(-) diff --git a/src/js/views/scores.js b/src/js/views/scores.js index b3eb65c0..ceae9385 100644 --- a/src/js/views/scores.js +++ b/src/js/views/scores.js @@ -55,10 +55,6 @@ define('views/scores',[ score.$editing = false; $scores._update(); }; - - $scope.refresh = function() { - $scores.load(); - }; } ]); }); diff --git a/src/views/pages/scores.html b/src/views/pages/scores.html index 9e74eec4..ce8c7b8c 100644 --- a/src/views/pages/scores.html +++ b/src/views/pages/scores.html @@ -7,9 +7,6 @@

- -
-

Showing {{scores.length}} scores.

From 9d0a92aa29d4d3c3f4b68bfc853afc199ecec08c Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:31:24 +0300 Subject: [PATCH 14/19] bugfix --- server_modules/scores.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server_modules/scores.js b/server_modules/scores.js index 202d1d79..d9b43417 100644 --- a/server_modules/scores.js +++ b/server_modules/scores.js @@ -74,7 +74,7 @@ exports.route = function(app) { return rounds; },{}); res.json(published); - }).catch(err => utils.sendError(res, err)).done(); + }).catch(utils.sendError(res)).done(); }); //get scores by round @@ -86,7 +86,7 @@ exports.route = function(app) { return score.published && score.round === round; }); res.json(scoresForRound); - }).catch(err => utils.sendError(res, err)).done(); + }).catch(utils.sendError(res)).done(); }); //save a new score @@ -106,7 +106,7 @@ exports.route = function(app) { })) .then(function(scores) { res.json(scores).end(); - }).catch(err => utils.sendError(res, err)).done(); + }).catch(utils.sendError(res)).done(); }); @@ -121,7 +121,7 @@ exports.route = function(app) { return result; }).then(function(scores) { res.json(scores).end(); - }).catch(err => utils.sendError(res, err)).done(); + }).catch(utils.sendError(res)).done(); }); //edit a score at an id @@ -136,7 +136,7 @@ exports.route = function(app) { return result; }).then(function(scores) { res.json(scores).end(); - }).catch(err => utils.sendError(res, err)).done(); + }).catch(utils.sendError(res)).done(); }); From e347c815665a4937306cfafbfea7521ad8677d6c Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:33:37 +0300 Subject: [PATCH 15/19] bugfix --- src/js/views/scores.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/views/scores.js b/src/js/views/scores.js index ceae9385..74e40424 100644 --- a/src/js/views/scores.js +++ b/src/js/views/scores.js @@ -21,7 +21,7 @@ define('views/scores',[ $scope.sort = col; }; $scope.removeScore = function(scoreId) { - return $scores.remove(scoreId); + return $scores.delete(scoreId); }; $scope.editScore = function(score) { score.$editing = true; From 7be2389de1516beba073f4497c01d21db9e226ce Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Sun, 3 Sep 2017 00:35:41 +0300 Subject: [PATCH 16/19] bugfix --- src/views/pages/scores.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/views/pages/scores.html b/src/views/pages/scores.html index ce8c7b8c..5a7443cd 100644 --- a/src/views/pages/scores.html +++ b/src/views/pages/scores.html @@ -72,39 +72,39 @@

From 9239f3c7ca1b8b14904f76810bd3438268045fd9 Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Mon, 4 Sep 2017 22:18:12 +0300 Subject: [PATCH 17/19] Some bugfixes --- package.json | 2 +- server_modules/lock.js | 33 +++++++++++++++++++++++++++++++++ server_modules/scores.js | 8 ++++---- src/js/services/ng-message.js | 2 +- src/js/services/ng-scores.js | 21 +++++---------------- 5 files changed, 44 insertions(+), 22 deletions(-) create mode 100644 server_modules/lock.js diff --git a/package.json b/package.json index 11c5b279..a39220d9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "start": "node localserver", - "test": "./node_modules/.bin/karma start --single-run --browsers Firefox --reporters dots,coverage" + "test": "./node_modules/.bin/karma start --single-run --reporters dots,coverage" }, "repository": { "type": "git", diff --git a/server_modules/lock.js b/server_modules/lock.js new file mode 100644 index 00000000..2b00dacc --- /dev/null +++ b/server_modules/lock.js @@ -0,0 +1,33 @@ +//This module wraps lockfile with promises. +var lockfile = require('lockfile'); + +module.exports = function(filename, options) { + this.filename = filename; + this.options = options || {}; + + this.lock = function() { + var self = this; + + return new Promise(function(resolve, reject) { + lockfile.lock('scores.json.lock', self.options, function(err) { + if(err && err.code !== 'EEXIST') { + reject(err); + } + + resolve(); + }); + }); + }; + + this.unlock = function() { + return new Promise(function(resolve, reject) { + lockfile.unlock('scores.json.lock', function(err) { + if(err && err.code !== 'EEXIST') { + reject(err); + } + + resolve(); + }); + }); + }; +} diff --git a/server_modules/scores.js b/server_modules/scores.js index d9b43417..d0430e2e 100644 --- a/server_modules/scores.js +++ b/server_modules/scores.js @@ -1,7 +1,7 @@ var Lock = require('./lock'); var utils = require('./utils'); var fileSystem = require('./file_system'); -var log = require('./log'); +var log = require('./log').log; var Q = require('q'); var id = require('uuid/v4'); @@ -195,12 +195,12 @@ function sanitizeScore(score) { } } // Pass through the rest - log("Warning: invalid score " + score); + log.warn("Invalid score " + score); return score; } function loadScoresheetScore(filename) { - return fs.readJsonFile(fileSystem.getDataFilePath("scoresheets/" + score.file)).then(function(scoresheet) { + return fileSystem.readJsonFile(fileSystem.getDataFilePath("scoresheets/" + filename)).then(function(entry) { return { file: (entry.file !== undefined && entry.file !== null) ? String(entry.file) : "", teamNumber: parseInt((entry.teamNumber !== undefined) ? entry.teamNumber : entry.team.number, 10), @@ -216,7 +216,7 @@ function loadScoresheetScore(filename) { } changeScores(function(scores) { - return fileSystem.filesInDir('data/scoresheet').then(function(files) { + return fileSystem.filesInDir('data/scoresheets').then(function(files) { var promises = [] for(var i = 0; i < files.length; i++) { diff --git a/src/js/services/ng-message.js b/src/js/services/ng-message.js index e22e3c6e..c478b50d 100644 --- a/src/js/services/ng-message.js +++ b/src/js/services/ng-message.js @@ -39,7 +39,7 @@ define('services/ng-message',[ ws.onclose = function() { log("socket close"); }; - ws.onmessage = function(msg)function(msg) { + ws.onmessage = function(msg) { var data = JSON.parse(msg.data); var headers = JSON.parse(msg.headers); var topic = data.topic; diff --git a/src/js/services/ng-scores.js b/src/js/services/ng-scores.js index 6cff7da4..cc8b8f99 100644 --- a/src/js/services/ng-scores.js +++ b/src/js/services/ng-scores.js @@ -152,7 +152,7 @@ define('services/ng-scores',[ return self.load(); }); } - message.on('scores:reload', function(data, msg) { + $message.on('scores:reload', function(data, msg) { if(msg.fromMe){ return; } @@ -189,7 +189,7 @@ define('services/ng-scores',[ } self.clear(); scores.forEach(function(score) { - self.add(score); + self._rawScores.push(sanitizeScore(score)); }); self._sheets = {}; sheetNames.forEach(function(name) { self._sheets[name] = true; }); @@ -202,20 +202,9 @@ define('services/ng-scores',[ }); }; - Scores.prototype.remove = function(index) { - // TODO: this function used to remove an associated - // score sheet file. - // However, as creating that scoresheet was not - // the concern of this class, I (Martin) decided - // that removing it should not be its concern either. - // Note that e.g. the clear() method also did not - // remove 'obsolete' scoresheet files. - // Additionally note that a scoresheet may be the digital - // representation of a 'physical' scoresheet, something - // with a signature even, and may indeed be a very different - // beast than 'merely' a score entry. - this._rawScores.splice(index, 1); - this._update(); + Scores.prototype.clear = function() { + this._rawScores = []; + this._sheets = {}; }; /** From 99c2e47e1f98586cf5782f6bf883e2b01cfea6af Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Tue, 5 Sep 2017 22:57:08 +0300 Subject: [PATCH 18/19] Tests pass --- .../ExportRankingDialiogControllerSpec.js | 2 +- spec/mocks/independenceMock.js | 6 + spec/mocks/messageMock.js | 7 +- spec/mocks/scoresMock.js | 10 +- spec/services/ng-scoresSpec.js | 461 ++---------------- spec/views/rankingSpec.js | 2 +- spec/views/scoresSpec.js | 67 +-- spec/views/scoresheetSpec.js | 36 +- src/js/services/ng-scores.js | 5 +- src/js/views/scores.js | 8 +- src/js/views/scoresheet.js | 9 +- 11 files changed, 101 insertions(+), 512 deletions(-) create mode 100644 spec/mocks/independenceMock.js diff --git a/spec/controllers/ExportRankingDialiogControllerSpec.js b/spec/controllers/ExportRankingDialiogControllerSpec.js index 8a146949..66e41f95 100644 --- a/spec/controllers/ExportRankingDialiogControllerSpec.js +++ b/spec/controllers/ExportRankingDialiogControllerSpec.js @@ -14,7 +14,7 @@ describe('ExportRankingDialogController',function() { angular.mock.inject(function($controller,$rootScope,$q,_$timeout_) { $scope = $rootScope.$new(); $timeout = _$timeout_; - scoresMock = createScoresMock($q,fakeScoreboard); + scoresMock = createScoresMock(fakeScoreboard); handshakeMock = createHandshakeMock($q); stagesMock = createStagesMock(); controller = $controller('ExportRankingDialogController', { diff --git a/spec/mocks/independenceMock.js b/spec/mocks/independenceMock.js new file mode 100644 index 00000000..d88d7d75 --- /dev/null +++ b/spec/mocks/independenceMock.js @@ -0,0 +1,6 @@ +var createIndependenceMock = function() { + return { + act: jasmine.createSpy('independenceActSpy').and.returnValue(Promise.resolve()), + pendingActions: jasmine.createSpy('independencePendingActionsSpy') + }; +}; diff --git a/spec/mocks/messageMock.js b/spec/mocks/messageMock.js index db24365d..9d05191e 100644 --- a/spec/mocks/messageMock.js +++ b/spec/mocks/messageMock.js @@ -1,3 +1,6 @@ var createMessageMock = function() { - return {}; -} \ No newline at end of file + return { + send: jasmine.createSpy('messageSendSpy'), + on: jasmine.createSpy('messageOnSpy') + }; +} diff --git a/spec/mocks/scoresMock.js b/spec/mocks/scoresMock.js index c9002e76..f2c1d4f2 100644 --- a/spec/mocks/scoresMock.js +++ b/spec/mocks/scoresMock.js @@ -1,4 +1,4 @@ -function createScoresMock($q,scoreboard) { +function createScoresMock(scoreboard) { scoreboard = scoreboard || {}; return { scores: [{ @@ -9,12 +9,12 @@ function createScoresMock($q,scoreboard) { index: 1 }], scoreboard: scoreboard, - remove: jasmine.createSpy('scoreRemoveSpy'), load: jasmine.createSpy('scoreLoadSpy'), - pollSheets: jasmine.createSpy('scorePollSheetsSpy').and.returnValue($q.when()), - update: jasmine.createSpy('scoreUpdateSpy'), + clear: jasmine.createSpy('scoreClearSpy'), + create: jasmine.createSpy('scoreCreateSpy').and.returnValue(Promise.resolve()), + delete: jasmine.createSpy('scoreDeleteSpy'), + update: jasmine.createSpy('scoreUpdateSpy').and.returnValue(Promise.resolve()), _update: jasmine.createSpy('score_UpdateSpy'), - save: jasmine.createSpy('scoreSaveSpy'), getRankings: jasmine.createSpy('getRankings').and.returnValue({ scoreboard: scoreboard }) diff --git a/spec/services/ng-scoresSpec.js b/spec/services/ng-scoresSpec.js index 1ffa2729..cbc0df6d 100644 --- a/spec/services/ng-scoresSpec.js +++ b/spec/services/ng-scoresSpec.js @@ -30,6 +30,7 @@ describe('ng-scores',function() { var mockScore; var mockTeam; var fsMock; + var independenceMock; beforeEach(function() { fsMock = createFsMock({ @@ -37,9 +38,13 @@ describe('ng-scores',function() { "stages.json": [rawMockStage], "teams.json": [dummyTeam] }); + independenceMock = createIndependenceMock(); + angular.mock.module(module.name); angular.mock.module(function($provide) { $provide.value('$fs', fsMock); + $provide.value('$message', createMessageMock()); + $provide.value('$independence', independenceMock); }); angular.mock.inject(["$scores", "$stages", "$teams", "$q", function(_$scores_, _$stages_, _$teams_,_$q_) { $scores = _$scores_; @@ -80,456 +85,50 @@ describe('ng-scores',function() { }); } - describe('init',function() { - it('should load mock score initially',function() { - expect(filteredScores()).toEqual([mockScore]); - }); - }); - - describe('clear',function() { - it('should clear the scores',function() { - expect(filteredScores()).toEqual([mockScore]); - $scores.clear(); - expect(filteredScores()).toEqual([]); - }); - }); - - describe('save',function() { - it('should write scores to scores.json',function() { - return $scores.save().then(function() { - expect(fsMock.write).toHaveBeenCalledWith( - 'scores.json', - { - version: 2, - scores: [rawMockScore], - sheets: [] - } - ); - }); - }); - - it('should log an error if writing fails',function() { - fsMock.write.and.returnValue(Q.reject('write err')); - return $scores.save().then(function() { - expect(logMock).toHaveBeenCalledWith('scores write error','write err'); - }); - }); - }); - describe('load',function() { - it('should load from scores.json',function() { - return $scores.load().then(function() { + it('shuold read scores.json', function() { + $scores.load().then(function() { expect(fsMock.read).toHaveBeenCalledWith('scores.json'); - expect(filteredScores()).toEqual([mockScore]); - }); - }); - - it('should log an error if loading fails',function() { - fsMock.read.and.returnValue(Q.reject('read err')); - return $scores.load().then(function() { - expect(logMock).toHaveBeenCalledWith('scores read error','read err'); }); }); }); - describe('remove',function() { - it('should remove the provided index', function() { + describe('clear',function() { + it('should clear the scores',function() { expect(filteredScores()).toEqual([mockScore]); - $scores.remove(0); - expect(filteredScores()).toEqual([]); - }); - }); - - describe('add',function() { - beforeEach(function() { $scores.clear(); expect(filteredScores()).toEqual([]); }); - it('should add a score to the list', function() { - $scores.add(mockScore); - expect(filteredScores()).toEqual([mockScore]); - }); - it('should allow duplicates', function() { - // Duplicate scores are 'allowed' during adding, but - // are rejected in scoreboard computation. - $scores.add(mockScore); - $scores.add(mockScore); - expect(filteredScores()).toEqual([mockScore, mockScore]); - expect($scores.validationErrors.length).toBeGreaterThan(0); - }); - it('should accept numeric scores as strings', function() { - var tmp = angular.copy(mockScore); - tmp.score = String(tmp.score); - $scores.add(tmp); - // Note: the 'accepted' score should really be a number, not a string - expect($scores.scores[0].score).toEqual(150); - expect($scores.validationErrors.length).toEqual(0); - }); - it('should accept and convert different casing for DNC', function() { - var tmp = angular.copy(mockScore); - tmp.score = "DnC"; - $scores.add(tmp); - expect($scores.scores[0].score).toEqual("dnc"); - expect($scores.validationErrors.length).toEqual(0); - }); - it('should accept and convert different casing for DSQ', function() { - var tmp = angular.copy(mockScore); - tmp.score = "DsQ"; - $scores.add(tmp); - expect($scores.scores[0].score).toEqual("dsq"); - expect($scores.validationErrors.length).toEqual(0); - }); - it('should reject but convert an empty score', function() { - var tmp = angular.copy(mockScore); - tmp.score = ""; - $scores.add(tmp); - expect($scores.scores[0].score).toEqual(null); - expect($scores.validationErrors.length).toEqual(1); - }); - it('should store the edited date of a score as string',function() { - var tmp = angular.copy(mockScore); - tmp.edited = new Date(2015,1,7); - $scores.add(tmp); - expect(typeof $scores.scores[0].edited).toBe('string'); - }); - }); - - describe('update', function() { - beforeEach(function() { - $scores.clear(); - $scores.add(mockScore); - }); - it('should mark modified scores', function() { - mockScore.score++; - // Simply changing the added score shouldn't matter... - expect($scores.scores[0].score).toEqual(150); - // ... but updating it should - $scores.update(0, mockScore); - expect($scores.scores[0].originalScore).toEqual(150); - expect($scores.scores[0].score).toEqual(151); - expect($scores.scores[0].modified).toBeTruthy(); - expect($scores.scores[0].edited).toBeTruthy(); - }); - it('should accept numeric scores as strings',function() { - mockScore.score = "151"; - $scores.update(0, mockScore); - // Note: the 'accepted' score should really be a number, not a string - expect($scores.scores[0].originalScore).toEqual(150); - expect($scores.scores[0].score).toEqual(151); - }); - it('should throw an error if a score out of range is edited',function() { - var f = function() { - $scores.update(-1,mockScore); - }; - expect(f).toThrowError('unknown score index: -1'); - }); - it('should throw an error if a score out of range is edited',function() { - var f = function() { - $scores.update(1,mockScore); - }; - expect(f).toThrowError('unknown score index: 1'); - }); }); - describe('scoreboard', function() { - var board; - beforeEach(function() { - board = $scores.scoreboard; - }); - function fillScores(input, allowErrors) { - $scores.beginupdate(); - $scores.clear(); - input.map(function(score) { - $scores.add(score); + describe('create',function() { + it('should call independence act',function() { + $scores.create(mockScore).then(function() { + expect(independenceMock.act).toHaveBeenCalled(); + expect(independenceMock.act.calls.mostRecent().args[0]).toBe('scores'); + expect(independenceMock.act.calls.mostRecent().args[1]).toBe('/scores/create'); }); - $scores.endupdate(); - if (!allowErrors) { - $scores.scores.forEach(function(score) { - expect(score.error).toBeFalsy(); - }); - } - } - var team1 = { number: 1, name: "Fleppie 1" }; - var team2 = { number: 2, name: "Fleppie 2" }; - var team3 = { number: 3, name: "Fleppie 3" }; - var team4 = { number: 4, name: "Fleppie 4" }; - - beforeEach(function() { - $teams.clear(); - $teams.add(team1); - $teams.add(team2); - $teams.add(team3); - $teams.add(team4); - }); - - it('should output used stages', function() { - fillScores([]); - expect(Object.keys(board)).toEqual(["test"]); - }); - - it('should fill in all rounds for a team', function() { - // If a team has played at all (i.e., they have a score for that stage) - // then all other rounds for that team need to have an entry (which can - // be null). - fillScores([ - { team: team1, stage: mockStage, round: 2, score: 10 } - ]); - expect(board["test"][0].scores).toEqual([null, 10, null]); - }); - - it('should rank number > dnc > dsq > null', function() { - fillScores([ - { team: team1, stage: mockStage, round: 1, score: 'dsq' }, - { team: team2, stage: mockStage, round: 1, score: 'dnc' }, - { team: team3, stage: mockStage, round: 1, score: -1 }, - { team: team4, stage: mockStage, round: 1, score: 1 }, - ]); - var result = board["test"].map(function(entry) { - return { - rank: entry.rank, - teamNumber: entry.team.number, - highest: entry.highest - }; - }); - expect(result).toEqual([ - { rank: 1, teamNumber: team4.number, highest: 1 }, - { rank: 2, teamNumber: team3.number, highest: -1 }, - { rank: 3, teamNumber: team2.number, highest: 'dnc' }, - { rank: 4, teamNumber: team1.number, highest: 'dsq' }, - ]); - - }); - - it("should assign equal rank to equal scores", function() { - fillScores([ - { team: team1, stage: mockStage, round: 1, score: 10 }, - { team: team1, stage: mockStage, round: 2, score: 20 }, - { team: team1, stage: mockStage, round: 3, score: 30 }, - { team: team2, stage: mockStage, round: 1, score: 30 }, - { team: team2, stage: mockStage, round: 2, score: 10 }, - { team: team2, stage: mockStage, round: 3, score: 20 }, - { team: team3, stage: mockStage, round: 1, score: 30 }, - { team: team3, stage: mockStage, round: 2, score: 0 }, - { team: team3, stage: mockStage, round: 3, score: 20 }, - ]); - var result = board["test"].map(function(entry) { - return { - rank: entry.rank, - teamNumber: entry.team.number, - highest: entry.highest - }; - }); - // Note: for equal ranks, teams are sorted according - // to (ascending) team id - expect(result).toEqual([ - { rank: 1, teamNumber: team1.number, highest: 30 }, - { rank: 1, teamNumber: team2.number, highest: 30 }, - { rank: 2, teamNumber: team3.number, highest: 30 }, - ]); - }); - - it("should allow filtering rounds", function() { - fillScores([ - { team: team1, stage: mockStage, round: 1, score: 10 }, - { team: team1, stage: mockStage, round: 2, score: 20 }, - { team: team1, stage: mockStage, round: 3, score: 30 }, - { team: team2, stage: mockStage, round: 1, score: 30 }, - { team: team2, stage: mockStage, round: 2, score: 10 }, - { team: team2, stage: mockStage, round: 3, score: 20 }, - { team: team3, stage: mockStage, round: 1, score: 30 }, - { team: team3, stage: mockStage, round: 2, score: 0 }, - { team: team3, stage: mockStage, round: 3, score: 20 }, - ]); - var filtered = $scores.getRankings({ - "test": 2 - }); - var result = filtered.scoreboard["test"].map(function(entry) { - return { - rank: entry.rank, - teamNumber: entry.team.number, - scores: entry.scores - }; - }); - // Note: for equal ranks, teams are sorted according - // to (ascending) team id - expect(result).toEqual([ - { rank: 1, teamNumber: team2.number, scores: [30, 10] }, - { rank: 2, teamNumber: team3.number, scores: [30, 0] }, - { rank: 3, teamNumber: team1.number, scores: [10, 20] }, - ]); - }); - - it("should ignore but warn about scores for unknown rounds / stages", function() { - fillScores([ - { team: team1, stage: { id: "foo" }, round: 1, score: 0 }, - { team: team1, stage: mockStage, round: 0, score: 0 }, - { team: team1, stage: mockStage, round: 4, score: 0 }, - ], true); - expect($scores.scores[0].error).toEqual(jasmine.any($scores.UnknownStageError)); - expect($scores.scores[1].error).toEqual(jasmine.any($scores.UnknownRoundError)); - expect($scores.scores[2].error).toEqual(jasmine.any($scores.UnknownRoundError)); - expect(board["test"].length).toEqual(0); - expect($scores.validationErrors.length).toEqual(3); - }); - - it("should ignore but warn about invalid score", function() { - fillScores([ - { team: team1, stage: mockStage, round: 1, score: "foo" }, - { team: team1, stage: mockStage, round: 2, score: NaN }, - { team: team1, stage: mockStage, round: 3, score: Infinity }, - { team: team2, stage: mockStage, round: 1, score: {} }, - { team: team2, stage: mockStage, round: 2, score: true }, - ], true); - $scores.scores.forEach(function(score) { - expect(score.error).toEqual(jasmine.any($scores.InvalidScoreError)); - }); - expect(board["test"].length).toEqual(0); - expect($scores.validationErrors.length).toEqual(5); - }); - - it("should ignore but warn about duplicate score", function() { - fillScores([ - { team: team1, stage: mockStage, round: 1, score: 10 }, - { team: team1, stage: mockStage, round: 1, score: 20 }, - ], true); - expect($scores.scores[1].error).toEqual(jasmine.any($scores.DuplicateScoreError)); - expect(board["test"][0].highest).toEqual(10); - expect($scores.validationErrors.length).toBeGreaterThan(0); - }); - - it("should ignore but warn about invalid team", function() { - $teams.remove(team1.number); - fillScores([ - { team: team1, stage: mockStage, round: 1, score: 10 }, - ], true); - expect($scores.scores[0].error).toEqual(jasmine.any($scores.UnknownTeamError)); - expect($scores.validationErrors.length).toEqual(1); - }); - - it("should allow resolving error", function() { - fillScores([ - { team: team1, stage: mockStage, round: 1, score: 10 }, - { team: team1, stage: mockStage, round: 1, score: 20 }, - ], true); - expect($scores.validationErrors.length).toBeGreaterThan(0); - $scores.update(1, { team: team1, stage: mockStage, round: 2, score: 20 }); - expect($scores.validationErrors.length).toEqual(0); }); }); - describe("pollSheets", function() { - var importedScore; - var mockFiles; - var mockDirs; - - beforeEach(function() { - importedScore = { - file: "sheet_1.json", - team: mockTeam, - stage: mockStage, - round: 1, - score: 456, - originalScore: 456 - }; - mockFiles = { - "scores.json": { version: 2, scores: [], sheets: [] }, - "scoresheets/sheet_1.json": { teamNumber: 123, stageId: "test", round: 1, score: 456 } - }; - mockDirs = { - "scoresheets": ["sheet_1.json"], - }; - fsMock._setFiles(mockFiles); - fsMock._setDirs(mockDirs); - $scores.clear(); - }); - - it("should pick up a new sheet", function() { - return $scores.pollSheets().then(function() { - expect(filteredScores()).toEqual([importedScore]); - }); - }); - - it("should ignore already processed sheets", function() { - return $scores.pollSheets().then(function() { - expect($scores.scores.length).toEqual(1); - return $scores.pollSheets(); - }).then(function() { - expect($scores.scores.length).toEqual(1); - }); - }); - - it("should ignore already processed sheets across loads", function() { - mockFiles["scores.json"] = { version: 2, scores: [], sheets: ["sheet_1.json"] }; - return $scores.load().then(function() { - return $scores.pollSheets(); - }).then(function() { - expect($scores.scores.length).toEqual(0); + describe('delete',function() { + it('should call independence act',function() { + var id = '1df9'; + $scores.delete({ id: id }).then(function() { + expect(independenceMock.act).toHaveBeenCalled(); + expect(independenceMock.act.calls.mostRecent().args[0]).toBe('scores'); + expect(independenceMock.act.calls.mostRecent().args[1]).toBe(`/scores/delete/${id}`); }); }); + }); - it("should remember processed sheets", function() { - return $scores.pollSheets().then(function() { - expect(fsMock.write).toHaveBeenCalledWith( - 'scores.json', - { - version: 2, - scores: [{ - file: "sheet_1.json", - teamNumber: 123, - stageId: "test", - round: 1, - score: 456, - originalScore: 456, - published: false, - edited: undefined, - table: undefined, - }], - sheets: ["sheet_1.json"] - } - ); - }); - }); - - describe('clicking the button twice should not poll twice (#172)',function() { - it('should not add the same sheet twice',function() { - return $q.all([ - $scores.pollSheets(), - $scores.pollSheets() - ]).then(function() { - expect($scores.scores.length).toEqual(1); - }); - }); - }); - - describe('error recovery',function() { - it('should continue with no sheets when a 404 is returned',function() { - fsMock.list.and.returnValue(Q.reject({status:404})); - $scores.save = jasmine.createSpy('save'); - return $scores.pollSheets().then(function() { - expect(fsMock.write).not.toHaveBeenCalled(); - expect($scores.save).not.toHaveBeenCalled(); - }); - }); - - it('throw an error if an http error is received',function() { - fsMock.list.and.returnValue(Q.reject({status:500,responseText:'server error',statusText:'foo'})); - return $scores.pollSheets().catch(function(err) { - expect(err.message).toEqual('error 500 (foo): server error'); - }); - }); - - it('should rethrow the error if something just goes wrong',function() { - fsMock.list.and.returnValue(Q.reject(new Error('squeek'))); - return $scores.pollSheets().catch(function(err) { - expect(err.message).toEqual('squeek'); - }); - }); - - it('should throw an unknown error if strange stuff is returned',function() { - fsMock.list.and.returnValue(Q.reject('darn')); - return $scores.pollSheets().catch(function(err) { - expect(err.message).toEqual('unknown error: darn'); - }); + describe('update',function() { + it('should call independence act',function() { + var id = '1df9'; + $scores.update({ id: id }).then(function() { + expect(independenceMock.act).toHaveBeenCalled(); + expect(independenceMock.act.calls.mostRecent().args[0]).toBe('scores'); + expect(independenceMock.act.calls.mostRecent().args[1]).toBe(`/scores/update/${id}`); }); }); }); diff --git a/spec/views/rankingSpec.js b/spec/views/rankingSpec.js index f93b6c12..d0a24715 100644 --- a/spec/views/rankingSpec.js +++ b/spec/views/rankingSpec.js @@ -16,7 +16,7 @@ describe('ranking', function() { angular.mock.module(module.name); angular.mock.inject(function($controller, $rootScope,$q) { $scope = $rootScope.$new(); - scoresMock = createScoresMock($q); + scoresMock = createScoresMock(); handshakeMock = createHandshakeMock($q); stagesMock = createStagesMock(); messageMock = createMessageMock(); diff --git a/spec/views/scoresSpec.js b/spec/views/scoresSpec.js index 19d97c6f..c945bfbb 100644 --- a/spec/views/scoresSpec.js +++ b/spec/views/scoresSpec.js @@ -12,7 +12,7 @@ describe('scores', function() { $scope = $rootScope.$new(); $window = _$window_; $q = _$q_; - scoresMock = createScoresMock($q); + scoresMock = createScoresMock(); teamsMock = createTeamsMock(); stagesMock = createStagesMock(); controller = $controller('scoresCtrl', { @@ -58,77 +58,62 @@ describe('scores', function() { describe('removeScore',function() { it('should remove a score',function() { - $scope.removeScore(1); - expect(scoresMock.remove).toHaveBeenCalledWith(1); - expect(scoresMock.save).toHaveBeenCalledWith(); + let score = $scope.scores[0]; + $scope.removeScore(score); + expect(scoresMock.delete).toHaveBeenCalledWith(score); }); }); describe('editScore',function() { it('should edit a score',function() { - $scope.editScore(0); - expect($scope.scores[0].$editing).toBe(true); + let score = $scope.scores[0]; + $scope.editScore(score); + expect(score.$editing).toBe(true); }); }); describe('publishScore',function() { it('should publish a score and save it',function() { - $scope.publishScore(0); - expect(scoresMock.update).toHaveBeenCalledWith(0, {score: 1, index: 0, published: true}); - expect(scoresMock.save).toHaveBeenCalled(); + let score = $scope.scores[0]; + $scope.publishScore(score); + expect(score.published).toBe(true); + expect(scoresMock.update).toHaveBeenCalledWith(score); }); }); describe('unpublishScore',function() { it('should unpublish a score and save it',function() { - $scope.unpublishScore(0); - expect(scoresMock.update).toHaveBeenCalledWith(0, {score: 1, index: 0, published: false}); - expect(scoresMock.save).toHaveBeenCalled(); + let score = $scope.scores[0]; + $scope.unpublishScore(score); + expect(score.published).toBe(false); + expect(scoresMock.update).toHaveBeenCalledWith(score); }); }); describe('finishEditScore',function() { it('should call update and save',function() { - $scope.editScore(0); - $scope.finishEditScore(0); - expect(scoresMock.update).toHaveBeenCalledWith(0, {score: 1, index: 0, $editing: true}); - expect(scoresMock.save).toHaveBeenCalled(); + let score = $scope.scores[0]; + $scope.editScore(score); + $scope.finishEditScore(score); + expect(scoresMock.update).toHaveBeenCalledWith(score); }); it('should alert if an error is thrown from scores',function() { scoresMock.update.and.throwError('update error'); - $scope.editScore(0); - $scope.finishEditScore(0); + let score = $scope.scores[0]; + $scope.editScore(score); + $scope.finishEditScore(score); expect($window.alert).toHaveBeenCalledWith('Error updating score: Error: update error'); }); }); describe('cancelEditScore',function() { it('should call _update to reset the scores',function() { - $scope.editScore(0); - $scope.cancelEditScore(); + let score = $scope.scores[0]; + $scope.editScore(score); + $scope.cancelEditScore(score); + expect(score.$editing).toBe(false); expect(scoresMock._update).toHaveBeenCalled(); }); }); - describe('pollSheets',function() { - xit('should call pollSheets of scores',function() { - $scope.pollSheets(); - expect(scoresMock.pollSheets).toHaveBeenCalled(); - }); - - it('should alert on fail',function() { - scoresMock.pollSheets.and.returnValue($q.reject(new Error('foo'))); - $scope.pollSheets(); - expect(scoresMock.pollSheets).toHaveBeenCalled(); - $scope.$digest(); - expect($window.alert).toHaveBeenCalledWith('failed to poll sheets: Error: foo'); - }); - }); - - describe('refresh',function() { - it('should call load of scores',function() { - $scope.refresh(); - expect(scoresMock.load).toHaveBeenCalled(); - }); - }); }); diff --git a/spec/views/scoresheetSpec.js b/spec/views/scoresheetSpec.js index 698d809f..e5a2193c 100644 --- a/spec/views/scoresheetSpec.js +++ b/spec/views/scoresheetSpec.js @@ -14,6 +14,7 @@ describe('scoresheet',function() { }; var dummyStage = { id: "qualifying", name: "Voorrondes", rounds: 3 }; var fsMock = createFsMock({"settings.json": []}); + var scoresMock; var settingsMock, handshakeMock, challengeMock; beforeEach(function() { @@ -25,6 +26,7 @@ describe('scoresheet',function() { settingsMock = createSettingsMock($q,'settings'); handshakeMock = createHandshakeMock($q); challengeMock = createChallengeMock(); + scoresMock = createScoresMock(); $scope = $rootScope.$new(); $window = { Date: function() { @@ -42,6 +44,7 @@ describe('scoresheet',function() { '$handshake': handshakeMock, '$teams': {}, '$challenge': challengeMock, + '$scores': scoresMock, '$window': $window }); }); @@ -558,39 +561,20 @@ describe('scoresheet',function() { $scope.referee = 'foo'; $scope.signature = [1,2,3,4]; return $scope.save().then(function() { - expect(fsMock.write.calls.mostRecent().args[0]).toEqual('scoresheets/score_qualifying_round1_table7_team123_abcdef01.json'); - expect(fsMock.write.calls.mostRecent().args[1]).toEqual({ - uniqueId: "abcdef01", - team: dummyTeam, - stage: dummyStage, + expect(scoresMock.create).toHaveBeenCalledWith({ + uniqueId: 'abcdef01', + team: { number: '123', name: 'foo' }, + stage: { id: 'qualifying', name: 'Voorrondes', rounds: 3 }, round: 1, table: 7, referee: 'foo', - signature: [1,2,3,4], - score: 0 + signature: [ 1, 2, 3, 4 ], + score: 0, + file: 'score_qualifying_round1_table7_team123_abcdef01.json' }); expect($window.alert).toHaveBeenCalledWith('Thanks for submitting a score of 0 points for team (123) foo in Voorrondes 1.'); }); }); - it('should alert a message if scoresheet cannot be saved', function() { - $scope.team = dummyTeam; - $scope.field = {}; - $scope.stage = dummyStage; - $scope.round = 1; - $scope.table = 7; - var oldId = $scope.uniqueId; - fsMock.write.and.returnValue(Q.reject(new Error('argh'))); - var firstFilename; - return $scope.save().catch(function() { - expect($window.alert).toHaveBeenCalledWith('Error submitting score: Error: argh'); - firstFilename = fsMock.write.calls.mostRecent().args[0]; - // verify that filename stays the same - return $scope.save(); - }).catch(function() { - var secondFilename = fsMock.write.calls.mostRecent().args[0]; - expect(secondFilename).toBe(firstFilename); - }); - }); }); describe('openDesciptionModal',function() { diff --git a/src/js/services/ng-scores.js b/src/js/services/ng-scores.js index cc8b8f99..2bdb205b 100644 --- a/src/js/services/ng-scores.js +++ b/src/js/services/ng-scores.js @@ -15,8 +15,8 @@ define('services/ng-scores',[ var SCORES_VERSION = 3; return module.service('$scores', - ['$rootScope', '$fs', '$stages', '$q', '$teams', '$message', - function($rootScope, $fs, $stages, $q, $teams, $message) { + ['$rootScope', '$fs', '$stages', '$q', '$teams', '$message','$independence', + function($rootScope, $fs, $stages, $q, $teams, $message, $independence) { // Replace placeholders in format string. // Example: format("Frobnicate {0} {1} {2}", "foo", "bar") @@ -205,6 +205,7 @@ define('services/ng-scores',[ Scores.prototype.clear = function() { this._rawScores = []; this._sheets = {}; + this.scores = []; }; /** diff --git a/src/js/views/scores.js b/src/js/views/scores.js index 74e40424..8893d575 100644 --- a/src/js/views/scores.js +++ b/src/js/views/scores.js @@ -42,7 +42,11 @@ define('views/scores',[ // replace the entry in the scores list and its storage. // Because scores are always 'sanitized' before storing, // the $editing flag is automatically discarded. - saveScore(score); + try { + saveScore(score); + } catch(e) { + alert(`Error updating score: ${e}`); + } }; function saveScore(score) { @@ -51,7 +55,7 @@ define('views/scores',[ }); } - $scope.cancelEditScore = function() { + $scope.cancelEditScore = function(score) { score.$editing = false; $scores._update(); }; diff --git a/src/js/views/scoresheet.js b/src/js/views/scoresheet.js index 997ce150..db87ee2c 100644 --- a/src/js/views/scoresheet.js +++ b/src/js/views/scoresheet.js @@ -251,7 +251,14 @@ define('views/scoresheet',[ data.uniqueId ].join('_')+'.json'; - return $scores.create(data); + return $scores.create(data).then(function() { + $window.alert(`Thanks for submitting a score of ${data.score}` + + ` points for team (${data.team.number})` + + ` ${data.team.name} in ${data.stage.name} ${data.round}.`); + }).catch(function() { + $window.alert(`Error submitting score to the server. +The score will be saved locally until contact with the server is resotred`); + }); }; $scope.openDescriptionModal = function (mission) { From ca744633c843bcf35f45ee0983d06cb2f15a449c Mon Sep 17 00:00:00 2001 From: Idan Stark Date: Tue, 5 Sep 2017 23:35:14 +0300 Subject: [PATCH 19/19] Restored rankings tests --- spec/services/ng-scoresSpec.js | 178 +++++++++++++++++++++++++++++++++ src/js/services/ng-scores.js | 9 +- 2 files changed, 185 insertions(+), 2 deletions(-) diff --git a/spec/services/ng-scoresSpec.js b/spec/services/ng-scoresSpec.js index cbc0df6d..4a7bd665 100644 --- a/spec/services/ng-scoresSpec.js +++ b/spec/services/ng-scoresSpec.js @@ -133,4 +133,182 @@ describe('ng-scores',function() { }); }); + describe('scoreboard', function() { + var board; + + beforeEach(function() { + board = $scores.scoreboard; + }); + + function fillScores(input, allowErrors) { + $scores.beginupdate(); + $scores.clear(); + input.map(function(score) { + $scores._addRawScore(score); + }); + $scores.endupdate(); + if (!allowErrors) { + $scores.scores.forEach(function(score) { + expect(score.error).toBeFalsy(); + }); + } + } + var team1 = { number: 1, name: "Fleppie 1" }; + var team2 = { number: 2, name: "Fleppie 2" }; + var team3 = { number: 3, name: "Fleppie 3" }; + var team4 = { number: 4, name: "Fleppie 4" }; + + beforeEach(function() { + $teams.clear(); + $teams.add(team1); + $teams.add(team2); + $teams.add(team3); + $teams.add(team4); + }); + + it('should output used stages', function() { + fillScores([]); + expect(Object.keys(board)).toEqual(["test"]); + }); + + it('should fill in all rounds for a team', function() { + // If a team has played at all (i.e., they have a score for that stage) + // then all other rounds for that team need to have an entry (which can + // be null). + fillScores([ + { team: team1, stage: mockStage, round: 2, score: 10 } + ]); + expect(board["test"][0].scores).toEqual([null, 10, null]); + }); + + it('should rank number > dnc > dsq > null', function() { + fillScores([ + { team: team1, stage: mockStage, round: 1, score: 'dsq' }, + { team: team2, stage: mockStage, round: 1, score: 'dnc' }, + { team: team3, stage: mockStage, round: 1, score: -1 }, + { team: team4, stage: mockStage, round: 1, score: 1 }, + ]); + var result = board["test"].map(function(entry) { + return { + rank: entry.rank, + teamNumber: entry.team.number, + highest: entry.highest + }; + }); + expect(result).toEqual([ + { rank: 1, teamNumber: team4.number, highest: 1 }, + { rank: 2, teamNumber: team3.number, highest: -1 }, + { rank: 3, teamNumber: team2.number, highest: 'dnc' }, + { rank: 4, teamNumber: team1.number, highest: 'dsq' }, + ]); + + }); + + it("should assign equal rank to equal scores", function() { + fillScores([ + { team: team1, stage: mockStage, round: 1, score: 10 }, + { team: team1, stage: mockStage, round: 2, score: 20 }, + { team: team1, stage: mockStage, round: 3, score: 30 }, + { team: team2, stage: mockStage, round: 1, score: 30 }, + { team: team2, stage: mockStage, round: 2, score: 10 }, + { team: team2, stage: mockStage, round: 3, score: 20 }, + { team: team3, stage: mockStage, round: 1, score: 30 }, + { team: team3, stage: mockStage, round: 2, score: 0 }, + { team: team3, stage: mockStage, round: 3, score: 20 }, + ]); + var result = board["test"].map(function(entry) { + return { + rank: entry.rank, + teamNumber: entry.team.number, + highest: entry.highest + }; + }); + // Note: for equal ranks, teams are sorted according + // to (ascending) team id + expect(result).toEqual([ + { rank: 1, teamNumber: team1.number, highest: 30 }, + { rank: 1, teamNumber: team2.number, highest: 30 }, + { rank: 2, teamNumber: team3.number, highest: 30 }, + ]); + }); + + it("should allow filtering rounds", function() { + fillScores([ + { team: team1, stage: mockStage, round: 1, score: 10 }, + { team: team1, stage: mockStage, round: 2, score: 20 }, + { team: team1, stage: mockStage, round: 3, score: 30 }, + { team: team2, stage: mockStage, round: 1, score: 30 }, + { team: team2, stage: mockStage, round: 2, score: 10 }, + { team: team2, stage: mockStage, round: 3, score: 20 }, + { team: team3, stage: mockStage, round: 1, score: 30 }, + { team: team3, stage: mockStage, round: 2, score: 0 }, + { team: team3, stage: mockStage, round: 3, score: 20 }, + ]); + var filtered = $scores.getRankings({ + "test": 2 + }); + var result = filtered.scoreboard["test"].map(function(entry) { + return { + rank: entry.rank, + teamNumber: entry.team.number, + scores: entry.scores + }; + }); + // Note: for equal ranks, teams are sorted according + // to (ascending) team id + expect(result).toEqual([ + { rank: 1, teamNumber: team2.number, scores: [30, 10] }, + { rank: 2, teamNumber: team3.number, scores: [30, 0] }, + { rank: 3, teamNumber: team1.number, scores: [10, 20] }, + ]); + }); + + it("should ignore but warn about scores for unknown rounds / stages", function() { + fillScores([ + { team: team1, stage: { id: "foo" }, round: 1, score: 0 }, + { team: team1, stage: mockStage, round: 0, score: 0 }, + { team: team1, stage: mockStage, round: 4, score: 0 }, + ], true); + expect($scores.scores[0].error).toEqual(jasmine.any($scores.UnknownStageError)); + expect($scores.scores[1].error).toEqual(jasmine.any($scores.UnknownRoundError)); + expect($scores.scores[2].error).toEqual(jasmine.any($scores.UnknownRoundError)); + expect(board["test"].length).toEqual(0); + expect($scores.validationErrors.length).toEqual(3); + }); + + it("should ignore but warn about invalid score", function() { + fillScores([ + { team: team1, stage: mockStage, round: 1, score: "foo" }, + { team: team1, stage: mockStage, round: 2, score: NaN }, + { team: team1, stage: mockStage, round: 3, score: Infinity }, + { team: team2, stage: mockStage, round: 1, score: {} }, + { team: team2, stage: mockStage, round: 2, score: true }, + ], true); + $scores.scores.forEach(function(score) { + expect(score.error).toEqual(jasmine.any($scores.InvalidScoreError)); + }); + expect(board["test"].length).toEqual(0); + expect($scores.validationErrors.length).toEqual(5); + }); + + it("should ignore but warn about duplicate score", function() { + fillScores([ + { team: team1, stage: mockStage, round: 1, score: 10 }, + { team: team1, stage: mockStage, round: 1, score: 20 }, + ], true); + expect($scores.scores[1].error).toEqual(jasmine.any($scores.DuplicateScoreError)); + expect(board["test"][0].highest).toEqual(10); + expect($scores.validationErrors.length).toBeGreaterThan(0); + }); + + it("should ignore but warn about invalid team", function() { + $teams.remove(team1.number); + fillScores([ + { team: team1, stage: mockStage, round: 1, score: 10 }, + ], true); + expect($scores.scores[0].error).toEqual(jasmine.any($scores.UnknownTeamError)); + expect($scores.validationErrors.length).toEqual(1); + }); + }); + }); diff --git a/src/js/services/ng-scores.js b/src/js/services/ng-scores.js index 2bdb205b..f76a0e53 100644 --- a/src/js/services/ng-scores.js +++ b/src/js/services/ng-scores.js @@ -189,7 +189,7 @@ define('services/ng-scores',[ } self.clear(); scores.forEach(function(score) { - self._rawScores.push(sanitizeScore(score)); + self._addRawScore(score); }); self._sheets = {}; sheetNames.forEach(function(name) { self._sheets[name] = true; }); @@ -308,13 +308,18 @@ define('services/ng-scores',[ } }; + // Making this function visible only for testing + Scores.prototype._addRawScore = function(score) { + this._rawScores.push(sanitizeEntry(score)); + }; + Scores.prototype._update = function(response) { if (this._updating > 0) { return; } if(response) { - this._rawScores = response.scores; + this._rawScores = response.scores.map(sanitizeEntry); this._sheets = response.sheets; }