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/.travis.yml b/.travis.yml index 29f80094..7cf38a3b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,17 @@ language: node_js +dist: trusty +sudo: true node_js: - - 0.10 + - "stable" before_install: - "npm i -g bower karma-cli" before_script: + - export CHROME_BIN=/usr/bin/google-chrome - export DISPLAY=:99.0 - sh -e /etc/init.d/xvfb start + - sudo apt-get install -y libappindicator1 fonts-liberation + - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + - sudo dpkg -i google-chrome*.deb after_script: - ls ./coverage - 'npm install coveralls@2.10.0 && cat "./coverage/Firefox 31.0.0 (Linux)/lcov.info" | coveralls' diff --git a/bower.json b/bower.json index d67cc0ee..1c2f101e 100644 --- a/bower.json +++ b/bower.json @@ -17,6 +17,7 @@ "dependencies": { "angular": "~1.2.20", "angular-bootstrap": "~0.11.0", + "angular-local-storage": "~0.6.0", "angular-mocks": "~1.2.20", "angular-sanitize": "~1.3.3", "angular-touch": "~1.3.4", diff --git a/karma.conf.js b/karma.conf.js index f6d4a0bc..cb9b70be 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -17,6 +17,7 @@ module.exports = function(config) { 'src/components/jquery/jquery.min.js', 'src/components/angular/angular.min.js', 'src/components/angular-mocks/angular-mocks.js', + 'src/components/angular-storage/angular-storage.js', 'src/components/q/q.js', 'src/components/idbwrapper/idbstore.js', 'spec/helpers/*.js', diff --git a/localserver.js b/localserver.js index add3d0c2..153152fb 100644 --- a/localserver.js +++ b/localserver.js @@ -37,5 +37,5 @@ routers.forEach(function(router) { app.listen(args.port, function() { console.log('Listening on port ', args.port); - console.log('open browser to http://localhost:{0}/'.format(args.port)); + console.log(`open browser to http://localhost:${args.port}/`); }); diff --git a/package.json b/package.json index f9fac7a7..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", @@ -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", diff --git a/server_modules/challenges.js b/server_modules/challenges.js index f0493eab..069ab11c 100644 --- a/server_modules/challenges.js +++ b/server_modules/challenges.js @@ -5,7 +5,7 @@ exports.route = function(app) { //get challenges over xhr, for hosted service app.get('/challenge/:year', function(req, res) { - var file = fileSystem.resolve('challenges/js/{0}.js'.format(req.params.year)); + var file = fileSystem.resolve(`challenges/js/${req.params.year}.js`); res.header('Content-Type','text/plain'); res.sendFile(file); diff --git a/server_modules/file_system.js b/server_modules/file_system.js index a704a6f2..27e961ab 100644 --- a/server_modules/file_system.js +++ b/server_modules/file_system.js @@ -33,7 +33,6 @@ exports.readFile = function(file) { if (exists) { resolve(exists); } else { - log.error("file not found {0}".format(file)); reject({ status: 404, message: 'file not found' @@ -42,7 +41,6 @@ exports.readFile = function(file) { }); }).then(function() { return Q.nfcall(fs.readFile, file, "utf-8").catch(function(e) { - log.error("error reading file {0}".format(file)); throw new Error({ status: 500, message: 'error reading file' @@ -77,15 +75,13 @@ exports.route = function(app) { var file = exports.getDataFilePath(req.params[0]); fs.stat(file, function(err, stat) { if (err) { - log.error("file not found {0}".format(file)); - res.status(404).send('file not found'); + utils.sendError(res, { status: 404, message: `file not found ${file}` }) return; } if (stat.isFile()) { fs.readFile(file, function(err, data) { if (err) { - log.error("error reading file {0}".format(file)); - res.status(500).send('error reading file'); + utils.sendError(res, { status: 500, message: `error reading file ${file}` }) return; } res.send(data); @@ -93,8 +89,7 @@ exports.route = function(app) { } else if (stat.isDirectory()) { fs.readdir(file, function(err, filenames) { if (err) { - log.error("error reading dir {0}".format(file)); - res.status(500).send('error reading dir'); + utils.sendError(res, { status: 500, message: `error reading dir ${file}` }) return; } // FIXME: this doesn't work for filenames containing @@ -103,15 +98,13 @@ exports.route = function(app) { return name.indexOf("\n") >= 0; }); if (hasNewline) { - log.error("invalid filename(s) {0}".format(filenames.join(', '))); - res.status(500).send('invalid filename(s)'); + utils.sendError(res, { status: 500, message: `invalid filename(s) ${filenames.join(', ')}` }) return; } res.send(filenames.join('\n')); }); } else { - log.error("error reading file {0}".format(file)); - res.status(500).send('error reading file'); + utils.sendError(res, { status: 500, message: `error reading file ${file}` }) return; } }); @@ -123,8 +116,7 @@ exports.route = function(app) { exports.writeFile(file, req.body).then(function() { res.status(200).end(); }).catch(function(err) { - log.error("error writing file {0}".format(err)); - res.status(500).send('error writing file'); + utils.sendError(res, { status: 500, message: `error writing file ${file}` }) }); }); @@ -133,8 +125,7 @@ exports.route = function(app) { var file = exports.getDataFilePath(req.params[0]); fs.unlink(file, function(err) { if (err) { - log.error("error removing file {0}".format(err)); - res.status(500).send('error removing file'); + utils.sendError(res, { status: 500, message: `error removing file ${file}` }) } res.status(200).end(); }); diff --git a/server_modules/lock.js b/server_modules/lock.js new file mode 100644 index 00000000..b98db512 --- /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.startsWith('EEXIST')) { + reject(err); + } + + resolve(); + }); + }); + }; + + this.unlock = function() { + return new Promise(function(resolve, reject) { + lockfile.unlock('scores.json.lock', function(err) { + if(err && !err.startsWith('EEXIST')){ + reject(err); + } + + resolve(); + }); + }); + }; +} diff --git a/server_modules/log.js b/server_modules/log.js index 20113e55..8afc05a7 100644 --- a/server_modules/log.js +++ b/server_modules/log.js @@ -12,7 +12,7 @@ exports.middleware = function(req, res, next) { req.log = exports.log; - req.log.debug('Starting {0} {1}'.format(req.method, req.originalUrl)); + req.log.debug(`Starting ${req.method} ${req.originalUrl}`); next(); diff --git a/server_modules/scores.js b/server_modules/scores.js index ba65806b..711f715d 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').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,84 @@ 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(); + }); + + }; + + +// For backward compatibility + +changeScores(function(scores) { + if(typeof(scores.version) === 'undefined') { + log.warn('Deprecated scores version. Updating to version 3.') + scores.forEach(score => score.id = id()) + return { + version: 3, + scores: scores + } + + } else if(scores.version === 3) { + return scores; + + } else if(scores.version === 2) { + log.warn('Deprecated scores version. Updating to version 3.'); + scores.scores.forEach(score => score.id = id()) + scores.version = 3; + return scores; + + } else { + throw new Error('Unkown scores version'); + } + +}); diff --git a/server_modules/teams.js b/server_modules/teams.js index e073adfc..aaee29b9 100644 --- a/server_modules/teams.js +++ b/server_modules/teams.js @@ -7,7 +7,7 @@ exports.route = function(app) { app.get('/teams',function(req,res) { fileSystem.readJsonFile(fileSystem.getDataFilePath('teams.json')).then(function(result) { res.json(result); - }).catch(utils.sendError(res)).done(); + }).catch(err => utils.sendError(res, err)).done(); }); app.get('/teams/:nr',function(req,res) { @@ -16,7 +16,7 @@ exports.route = function(app) { return team.number == req.params.nr; })[0]; res.json(team); - }).catch(utils.sendError(res)).done(); + }).catch(err => utils.sendError(res, err)).done(); }); }; diff --git a/server_modules/utils.js b/server_modules/utils.js index 670d753a..fb5f2a45 100644 --- a/server_modules/utils.js +++ b/server_modules/utils.js @@ -1,16 +1,11 @@ -exports.root = __dirname + '/../'; - -exports.sendError = function(res) { - return function(err) { - res.status(err.status).send(err.message); - } -} - -if (!String.prototype.format) { - String.prototype.format = function() { - var args = arguments; - return this.replace(/{(\d+)}/g, function(match, number) { - return typeof args[number] !== 'undefined' ? args[number] : match; - }); - }; -} +var log = require('./log').log; + +exports.root = __dirname + '/../'; + +exports.sendError = function(res, err) { + var status = err.status || 500; + var message = err.message || "Internal server error" + + log.error(message); + res.status(status).send(message); +} diff --git a/spec/controllers/ExportRankingDialiogControllerSpec.js b/spec/controllers/ExportRankingDialiogControllerSpec.js index 8a146949..6b77de64 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', { @@ -55,24 +55,6 @@ describe('ExportRankingDialogController',function() { }); }); - describe('exportScore',function() { - it('should create a dataurl of export data',function() { - $scope.exportScore({ - stage: {id: "1"}, - round: 3 - }); - expect($scope.stageselected).toEqual({id: "1"}); - expect($scope.export.rounds).toEqual([1,2,3]); - expect($scope.filterscoreboard).toEqual(fakeScoreboard); - - $timeout.flush(); - - expect($scope.exportname).toEqual('RoundResults.html'); - expect($scope.exportvisible).toBe(true); - expect($scope.exportdata.substr(0,40)).toEqual('data:text/html;charset=utf-8,%3C!DOCTYPE') - }); - }); - describe('cancel',function() { it('should hide the dialog',function() { handshakeMock.fire('exportRanking',{},{}); @@ -88,4 +70,24 @@ describe('ExportRankingDialogController',function() { expect($scope.getRoundLabel(4)).toEqual('Round 4'); }); }); + + describe('exportScore',function() { + it('should create a dataurl of export data',function() { + $scope.exportScore({ + stage: {id: "1"}, + round: 3 + }).then(function() { + expect($scope.stageselected).toEqual({id: "1"}); + expect($scope.export.rounds).toEqual([1,2,3]); + expect($scope.filterscoreboard).toEqual(fakeScoreboard); + + $timeout.flush(); + + expect($scope.exportname).toEqual('RoundResults.html'); + expect($scope.exportvisible).toBe(true); + expect($scope.exportdata.substr(0,40)).toEqual('data:text/html;charset=utf-8,%3C!DOCTYPE'); + }); + }); + }); + }); diff --git a/spec/mocks/httpMock.js b/spec/mocks/httpMock.js new file mode 100644 index 00000000..ac2d230d --- /dev/null +++ b/spec/mocks/httpMock.js @@ -0,0 +1,21 @@ +var createHttpMock = function(responses) { + var mock = {}; + + if(!responses) { + responses = {}; + } + + ['get','post','delete','put','patch'].forEach(function(method) { + mock[method] = jasmine.createSpy(method).and.callFake(function(url) { + return new Promise(function(res, rej) { + if(!responses[method][url]) { + rej('404 Not Found'); + } else { + res(responses[method][url]); + } + }); + }); + }); + + return mock; +}; diff --git a/spec/mocks/independenceMock.js b/spec/mocks/independenceMock.js new file mode 100644 index 00000000..f3449636 --- /dev/null +++ b/spec/mocks/independenceMock.js @@ -0,0 +1,9 @@ +var createIndependenceMock = function() { + return { + act: jasmine.createSpy('independenceActMock').and.returnValue(new Promise(function(res, rej) { + res(); + })), + sendSavedActionsToServer: jasmine.createSpy('sendSavedActionsToServer'), + pendingActions: jasmine.createSpy('pendingActions') + }; +}; diff --git a/spec/mocks/messageMock.js b/spec/mocks/messageMock.js index db24365d..000c58d2 100644 --- a/spec/mocks/messageMock.js +++ b/spec/mocks/messageMock.js @@ -1,3 +1,15 @@ var createMessageMock = function() { - return {}; -} \ No newline at end of file + var listeners = {}; + return { + send: jasmine.createSpy('sendMessageSpy').and.returnValue(Q.when()), + on: jasmine.createSpy('onMessageSpy').and.callFake(function(topic, listener){ + listeners[topic] = listeners[topic] || []; + listeners[topic].push(listener) + }), + mockSend: function(topic) { + listeners[topic].forEach(function(listener) { + listener({}, { fromMe: false }); + }); + } + }; +} diff --git a/spec/mocks/rankingsMock.js b/spec/mocks/rankingsMock.js new file mode 100644 index 00000000..1fed0246 --- /dev/null +++ b/spec/mocks/rankingsMock.js @@ -0,0 +1,5 @@ +var createRankingsMock = function(rankings) { + return { + calculate: jasmine.createSpy('calculateRankingsSpy').and.returnValue(new Promise(function(res) { return res(rankings); })) + }; +} diff --git a/spec/mocks/scoresMock.js b/spec/mocks/scoresMock.js index c9002e76..6d4d785a 100644 --- a/spec/mocks/scoresMock.js +++ b/spec/mocks/scoresMock.js @@ -1,22 +1,21 @@ -function createScoresMock($q,scoreboard) { +function createScoresMock(scoreboard) { scoreboard = scoreboard || {}; return { scores: [{ score: 1, - index: 0 + id: 'afg1jkhg' },{ score: 2, - index: 1 + id: 'g5f23ysu' }], scoreboard: scoreboard, - remove: jasmine.createSpy('scoreRemoveSpy'), + init: jasmine.createSpy('scoresInit').and.returnValue(Promise.resolve()), load: jasmine.createSpy('scoreLoadSpy'), - pollSheets: jasmine.createSpy('scorePollSheetsSpy').and.returnValue($q.when()), + create: jasmine.createSpy('scoreCreateSpy').and.returnValue(Promise.resolve()), + delete: jasmine.createSpy('scoreDeleteSpy'), update: jasmine.createSpy('scoreUpdateSpy'), _update: jasmine.createSpy('score_UpdateSpy'), - save: jasmine.createSpy('scoreSaveSpy'), - getRankings: jasmine.createSpy('getRankings').and.returnValue({ - scoreboard: scoreboard - }) + getRankings: jasmine.createSpy('getRankings').and.returnValue(Promise.resolve(scoreboard)), + pendingActions: jasmine.createSpy('scorePendingActionsSpy').and.returnValue(1) }; } diff --git a/spec/mocks/stagesMock.js b/spec/mocks/stagesMock.js index e723cef1..e15a3928 100644 --- a/spec/mocks/stagesMock.js +++ b/spec/mocks/stagesMock.js @@ -9,6 +9,7 @@ function createStagesMock() { return { stages: stages, allStages: stages, + init: jasmine.createSpy('save').and.returnValue(Q.when()), get: function(id) { var i; for (i = 0; i < stages.length; i++) { @@ -16,7 +17,7 @@ function createStagesMock() { return stages[i]; } } - throw new Error("unknown stage"); + return undefined; }, save: jasmine.createSpy('save'), remove: jasmine.createSpy('remove'), diff --git a/spec/mocks/teamsMock.js b/spec/mocks/teamsMock.js index 315a3a9b..5612df55 100644 --- a/spec/mocks/teamsMock.js +++ b/spec/mocks/teamsMock.js @@ -5,6 +5,14 @@ function createTeamsMock(teams) { clear: jasmine.createSpy('teamsClearSpy'), add: jasmine.createSpy('teamsAddSpy'), remove: jasmine.createSpy('teamsRemoveSpy'), - save: jasmine.createSpy('teamsSaveSpy').and.returnValue(Q.when()) + save: jasmine.createSpy('teamsSaveSpy').and.returnValue(Q.when()), + get: function(teamNumber) { + for(var i = 0; i < teams.length; i++) { + if(teams[i].number === teamNumber) { + return teams[i]; + } + } + return undefined; + } }; } diff --git a/spec/mocks/validationMock.js b/spec/mocks/validationMock.js new file mode 100644 index 00000000..a5c50e1d --- /dev/null +++ b/spec/mocks/validationMock.js @@ -0,0 +1,5 @@ +var createValidationMock = function() { + return { + validate: jasmine.createSpy('validationSpy').and.returnValue([]) + }; +} diff --git a/spec/services/ng-independenceSpec.js b/spec/services/ng-independenceSpec.js new file mode 100644 index 00000000..22bec1ba --- /dev/null +++ b/spec/services/ng-independenceSpec.js @@ -0,0 +1,131 @@ +describe('ng-independence',function() { + var ngServices = factory('services/ng-services'); + var module = factory('services/ng-independence',{ + 'services/ng-services': ngServices + }); + + var httpMock = createHttpMock({ + post: { + '/success': {} + } + }); + var successUrl = '/success'; + var failureUrl = '/failure'; + var $independence; + + beforeEach(function() { + angular.mock.module(module.name); + angular.mock.module(function($provide) { + $provide.value('$http', httpMock); + }); + angular.mock.inject(["$independence", function(_$independence_) { + $independence = _$independence_; + }]); + + localStorage.clear(); + }); + + describe('act', function() { + var token; + var data; + var fallback; + + beforeEach(function() { + $independence.sendSavedActionsToServer = jasmine.createSpy('sendSavedActionsToServer'); + + token = 'test'; + data = {}; + fallback = jasmine.createSpy('fallback'); + }); + + it('calls sendSavedActionsToServer if the action was successful', function() { + $independence.act(token, successUrl, data, fallback).then(function(){ + expect($independence.sendSavedActionsToServer).toHaveBeenCalledWith(token); + }); + }); + + it('doesn\'t call fallback if the action was successful', function() { + $independence.act(token, successUrl, data, fallback); + expect(fallback).not.toHaveBeenCalled(); + }); + + it('doesn\'t call localstorage if the action was successful', function() { + $independence.act(token, successUrl, data, fallback); + expect(Object.keys(localStorage).length).toBe(0); + }); + + it('doesn\'t call sendSavedActionsToServer if the action failed', function() { + $independence.act(token, successUrl, data, fallback).then(function() {}, function() { + expect(fallback).toHaveBeenCalled(); + }); + }); + + it('calls fallback if the action failed', function() { + $independence.act(token, successUrl, data, fallback).then(function() {}, function() { + expect(fallback).toHaveBeenCalled(); + }); + }); + + it('calls localstorage if the action was successful', function() { + $independence.act(token, successUrl, data, fallback).then(function() {}, function() { + expect(Object.keys(localStorage).length).toBe(1); + }); + }); + }); + + describe('sendSavedActionsToServer', function() { + var key = 'test'; + var positiveNonTrueValue = 'a positive non-true value'; + + beforeEach(function() { + $independence.act = jasmine.createSpy('act').and.callFake($independence.act); + }); + + it('can run only one instance at once', function() { + $independence._sendingSavedActionsToServer = positiveNonTrueValue; + $independence.sendSavedActionsToServer(key); + expect($independence._sendingSavedActionsToServer).toBe(positiveNonTrueValue); + }); + + it('finishes instantly if there are no matching keys', function() { + $independence.sendSavedActionsToServer(key); + expect($independence._sendingSavedActionsToServer).toBe(false); + }); + + it('calls act once for each key', function() { + $independence.act(key,failureUrl,{},() => {}).then(function() { + $independence.sendSavedActionsToServer(key); + expect($independence.act).toHaveBeenCalled(); + }); + }); + + it('doesn\'t act if there are no keys', function() { + $independence.sendSavedActionsToServer(key); + expect($independence.act).not.toHaveBeenCalled(); + }); + + }); + + // Not important to cover this right now, and tests won't pass + describe('pendingActions', function() { + // var key = 'test'; + // var anotherKey = 'anotherTest'; + + // it('returns 0 if there are no pending actions', function() { + // expect($independence.pendingActions(key)).toBe(0); + // }); + + // it('returns 1 if there is one pending action', function() { + // $independence.act(key,failureUrl,{},() => {}).then(function() { + // expect($independence.pendingActions(key)).toBe(1); + // }); + // }); + + // it('returns 0 if there is one pending action with another key', function() { + // $independence.act(anotherKey,failureUrl,{},() => {}).then(function() { + // expect($independence.pendingActions(key)).toBe(0); + // }); + // }); + }); + +}); diff --git a/spec/services/ng-rankingsSpec.js b/spec/services/ng-rankingsSpec.js new file mode 100644 index 00000000..805b8205 --- /dev/null +++ b/spec/services/ng-rankingsSpec.js @@ -0,0 +1,279 @@ +describe('ng-rankings',function() { + var ngServices = factory('services/ng-services'); + var module = factory('services/ng-rankings', { + 'services/ng-services': ngServices, + 'services/log': logMock, + 'services/ng-groups': factory('services/ng-groups', { + 'services/ng-services': ngServices + }) + }); + + var $rankings; + var stagesMock = createStagesMock(); + var teamsMock = createTeamsMock([ + { number: 123 }, + { number: 546 }, + { number: 1123 }, + { number: 222 } + ]); + var mockScores; + var mockRankings; + var fsMock; + + //initialize + beforeEach(function() { + mockScores = [{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[0].number, + round: 1, + score: 150 + },{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[1].number, + round: 2, + score: 132 + },{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[1].number, + round: 1, + score: 100 + },{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[2].number, + round: 1, + score: 0 + },{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[0].number, + round: 1, + score: 254 + },{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[1].number, + round: 1, + score: 221 + },{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[2].number, + round: 1, + score: 198 + },{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[2].number, + round: 2, + score: 75 + }]; + mockRankings = {}; + mockRankings[stagesMock.stages[0].id] = [{ + stage: stagesMock.stages[0], + team: teamsMock.teams[0], + rank: 1, + scores: [{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[0].number, + round: 1, + score: 150 + }, undefined], + ordered: [{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[0].number, + round: 1, + score: 150 + }], + highest: { + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[0].number, + round: 1, + score: 150 + } + },{ + stage: stagesMock.stages[0], + team: teamsMock.teams[1], + rank: 2, + scores: [{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[1].number, + round: 1, + score: 100 + },{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[1].number, + round: 2, + score: 132 + }], + ordered: [{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[1].number, + round: 2, + score: 132 + },{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[1].number, + round: 1, + score: 100 + }], + highest: { + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[1].number, + round: 2, + score: 132 + } + },{ + stage: stagesMock.stages[0], + team: teamsMock.teams[2], + rank: 3, + scores: [{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[2].number, + round: 1, + score: 0 + }, undefined], + ordered: [{ + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[2].number, + round: 1, + score: 0 + }], + highest: { + stageId: stagesMock.stages[0].id, + teamNumber: teamsMock.teams[2].number, + round: 1, + score: 0 + } + },{ + stage: stagesMock.stages[0], + team: teamsMock.teams[3], + rank: 4, + scores: [undefined, undefined], + ordered: [], + highest: undefined + }]; + mockRankings[stagesMock.stages[1].id] = [{ + stage: stagesMock.stages[1], + team: teamsMock.teams[0], + rank: 1, + scores: [{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[0].number, + round: 1, + score: 254 + }, undefined, undefined], + ordered: [{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[0].number, + round: 1, + score: 254 + }], + highest: { + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[0].number, + round: 1, + score: 254 + } + },{ + stage: stagesMock.stages[1], + team: teamsMock.teams[1], + rank: 2, + scores: [{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[1].number, + round: 1, + score: 221 + }, undefined, undefined], + ordered: [{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[1].number, + round: 1, + score: 221 + }], + highest: { + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[1].number, + round: 1, + score: 221 + } + },{ + stage: stagesMock.stages[1], + team: teamsMock.teams[2], + rank: 3, + scores: [{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[2].number, + round: 1, + score: 198 + },{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[2].number, + round: 2, + score: 75 + }, undefined], + ordered: [{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[2].number, + round: 1, + score: 198 + },{ + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[2].number, + round: 2, + score: 75 + }], + highest: { + stageId: stagesMock.stages[1].id, + teamNumber: teamsMock.teams[2].number, + round: 1, + score: 198 + } + },{ + stage: stagesMock.stages[1], + team: teamsMock.teams[3], + rank: 4, + scores: [undefined, undefined, undefined], + ordered: [], + highest: undefined + }]; + }); + + beforeEach(function() { + fsMock = createFsMock({ + 'stages.json': stagesMock.stages, + 'teams.json': teamsMock.teams + }); + angular.mock.module(module.name); + angular.mock.module(function($provide) { + $provide.value('$fs', fsMock); + }); + angular.mock.inject(["$rankings", function(_$rankings_) { + $rankings = _$rankings_; + }]); + }); + + describe('calculate', function() { + + it('shuold calculate ranks correctly', function() { + $rankings.calculate(mockScores).then(function(rankings) { + Object.keys(mockRankings).forEach(stageId => { + Object.keys(mockRankings[stageId]).forEach(teamRank => { + Object.keys(mockRankings[stageId][teamRank]).forEach(property => { + var mock = rankings[stageId][teamRank][property]; + if(typeof(mock) === 'object') { + mock = jasmine.objectContaining(mock); + } + expect(rankings[stageId][teamRank][property]).toEqual(mock); + }); + }); + }); + }); + }); + + it('shuold only return filtered rounds if filter exists', function() { + var filter = {}; + filter[stagesMock.stages[1].id] = 1; + $rankings.calculate(mockScores, filter).then(function(rankings) { + expect(rankings[stagesMock.stages[1].id][0].scores.length).toEqual(1); + }); + }); + + }) + +}); diff --git a/spec/services/ng-scoreSpec.js b/spec/services/ng-scoreSpec.js new file mode 100644 index 00000000..58e70914 --- /dev/null +++ b/spec/services/ng-scoreSpec.js @@ -0,0 +1,167 @@ +describe('ng-rankings',function() { + var ngServices = factory('services/ng-services'); + var module = factory('services/ng-score',{ + 'services/ng-services': ngServices + }); + + var $score; + + beforeEach(function() { + angular.mock.module(module.name); + angular.mock.inject(["$score", function(_$score_) { + $score = _$score_; + }]); + }); + + describe('compare', function() { + var score1, score2; + + beforeEach(function() { + score1 = new $score({ + score: 150 + }); + score2 = new $score({ + score: 110 + }) + }); + + it('returns 0 when comparing score with itself', function() { + expect($score.compare(score1, score1)).toBe(0); + }); + + it('returns a negative result when comparing score with higher score', function() { + expect($score.compare(score1, score2) < 0).toBe(true); + }); + + it('returns a positive result when comparing score with lower score', function() { + expect($score.compare(score2, score1) > 0).toBe(true); + }); + }); + + describe('generateUniqueId', function() { + var RANDOM_COEFICCIENT = 20; + + it(`can generate ${RANDOM_COEFICCIENT} unique ids`, function() { + var arr = []; + + for(var i = 0; i < RANDOM_COEFICCIENT; i++) { + arr.push($score.generateUniqueId()); + } + + for(var i = 0; i < arr.length; i++) { + for(var j = 0; j < i; j++) { + expect(arr[i]).not.toBe(arr[j]); + } + } + }); + + it(`generates an id with length 8 every time out of ${RANDOM_COEFICCIENT}`, function() { + for(var i = 0; i < RANDOM_COEFICCIENT; i++) { + expect($score.generateUniqueId().length).toBe(8); + } + }); + }); + + describe('santize', function() { + var unsanitizedScore1, unsanitizedScore2; + var sanitizedScore1, sanitizedScore2; + + beforeEach(function() { + unsanitizedScore1 = { + id: 'ade349b0', + teamNumber: 143, + stageId: 'anotherStageId', + round: 2, + score: 296, + edited: 'Wed Nov 26 2014 21:11:43 GMT+0100 (CET)', + table: 'red 1', + published: true, + otherProperty: 'other property value' + }; + unsanitizedScore2 = { + team: { number: 111 }, + stage: { id: 'qualification' }, + round: 1, + score: 542, + edited: 'Wed Nov 26 2014 21:11:43 GMT+0100 (CET)', + table: 'blue 2' + }; + sanitizedScore1 = $score.sanitize(unsanitizedScore1); + sanitizedScore2 = $score.sanitize(unsanitizedScore2); + }); + + it('removes all redandent properties', function() { + expect(sanitizedScore1.otherProperty).not.toBeDefined(); + }); + + it('keeps the id property if it exists', function() { + expect(sanitizedScore1.id).toBeDefined(); + }); + + it('creates the id property if it doesn\'t exists', function() { + expect(sanitizedScore2.id).toBeDefined(); + }); + + it('keeps the teamNumber property if it exists', function() { + expect(sanitizedScore1.teamNumber).toBeDefined(); + }); + + it('creates the teamNumber property if there\'s only a team property', function() { + expect(sanitizedScore2.teamNumber).toBeDefined(); + }); + + it('keeps the stageId property if it exists', function() { + expect(sanitizedScore1.stageId).toBeDefined(); + }); + + it('creates the stageId property if there\'s only a stage property', function() { + expect(sanitizedScore2.stageId).toBeDefined(); + }); + + it('keeps the round property if it exists', function() { + expect(sanitizedScore1.round).toBeDefined(); + }); + + it('keeps the score property if it exists', function() { + expect(sanitizedScore1.score).toBeDefined(); + }); + + it('keeps the table property if it exists', function() { + expect(sanitizedScore1.table).toBeDefined(); + }); + + it('keeps the published property if it exists', function() { + expect(sanitizedScore1.published).toBeDefined(); + }); + + it('creates the published property it doesn\'t exists', function() { + expect(sanitizedScore2.published).toBeDefined(); + }); + }); + + describe('calcFilename', function() { + var score; + var filename; + + beforeEach(function() { + score = new $score({ + id: '42cda0ib', + stage: { id: 'qual' }, + round: 3, + table: 'red 2', + team: { number: 132 } + }); + filename = 'score_qual_round3_tablered 2_team132_42cda0ib.json'; + }); + + it('returns a correctly calculated filename', function() { + expect(score.calcFilename()).toBe(filename); + }); + + it('save a correctly calculated filename in the score\'s file property', function() { + score.calcFilename(); + expect(score.file).toBe(filename); + }); + }); + +}); diff --git a/spec/services/ng-scoresSpec.js b/spec/services/ng-scoresSpec.js index 1ffa2729..26d27a78 100644 --- a/spec/services/ng-scoresSpec.js +++ b/spec/services/ng-scoresSpec.js @@ -7,18 +7,33 @@ describe('ng-scores',function() { }); var $scores; - var $stages; - var $teams; + var $score; var $q; - var dummyTeam = { - number: 123, - name: 'foo' + + var stagesMock = createStagesMock(); + var teamsMock = createTeamsMock([ + { number: 132 }, + { number: 2581 }, + { number: 445 } + ]); + var messageMock = createMessageMock(); + var independenceMock = createIndependenceMock(); + var rankingsMock = createRankingsMock(); + var validationMock = createValidationMock(); + var mockScore = { + file: 'somescore.json', + team: teamsMock.teams[0].number, + stage: stagesMock.stages[0].id, + round: 1, + score: 150, + originalScore: 150 }; - var rawMockStage = { id: "test", rounds: 3, name: "Test stage" }; - var rawMockScore = { + + var rawScore = { + id: 'asd23d', file: 'somescore.json', - teamNumber: 123, - stageId: "test", + teamNumber: teamsMock.teams[0].number, + stageId: stagesMock.stages[0].id, round: 1, score: 150, originalScore: 150, @@ -26,43 +41,29 @@ describe('ng-scores',function() { edited: undefined, table: undefined }; - var mockStage; - var mockScore; - var mockTeam; - var fsMock; + + var mockScores = { version: 3, scores: [rawScore] }; + + var fsMock= createFsMock({ + "scores.json": mockScores, + }); beforeEach(function() { - fsMock = createFsMock({ - "scores.json": { version: 2, scores: [rawMockScore], sheets: [] }, - "stages.json": [rawMockStage], - "teams.json": [dummyTeam] - }); angular.mock.module(module.name); angular.mock.module(function($provide) { $provide.value('$fs', fsMock); - }); - angular.mock.inject(["$scores", "$stages", "$teams", "$q", function(_$scores_, _$stages_, _$teams_,_$q_) { + $provide.value('$stages', stagesMock); + $provide.value('$teams', teamsMock); + $provide.value('$message', messageMock); + $provide.value('$independence', independenceMock); + $provide.value('$rankings', rankingsMock); + $provide.value('$validation', validationMock); + }); + angular.mock.inject(["$scores", "$score", "$q", function(_$scores_, _$score_,_$q_) { $scores = _$scores_; - $stages = _$stages_; - $teams = _$teams_; + $score = _$score_; $q = _$q_; }]); - - return $stages.init().then(function() { - mockStage = $stages.get(rawMockStage.id); - return $teams.init(); - }).then(function() { - mockTeam = $teams.get(dummyTeam.number); - mockScore = { - file: 'somescore.json', - team: mockTeam, - stage: mockStage, - round: 1, - score: 150, - originalScore: 150 - }; - return $scores.init(); - }); }); // Strip autogenerated properties to (hopefully ;)) arrive at the same @@ -71,8 +72,8 @@ describe('ng-scores',function() { return $scores.scores.map(function(score) { return { file: score.file, - team: score.team, - stage: score.stage, + team: score.teamNumber, + stage: score.stageId, round: score.round, score: score.score, originalScore: score.originalScore @@ -80,38 +81,12 @@ 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'); + $scores.load().then(function() { + expect(filteredScores()).toEqual([mockScore]); + $scores.clear(); + expect(filteredScores()).toEqual([]); }); }); }); @@ -120,7 +95,6 @@ describe('ng-scores',function() { it('should load from scores.json',function() { return $scores.load().then(function() { expect(fsMock.read).toHaveBeenCalledWith('scores.json'); - expect(filteredScores()).toEqual([mockScore]); }); }); @@ -130,407 +104,131 @@ describe('ng-scores',function() { expect(logMock).toHaveBeenCalledWith('scores read error','read err'); }); }); - }); - describe('remove',function() { - it('should remove the provided index', function() { - expect(filteredScores()).toEqual([mockScore]); - $scores.remove(0); - expect(filteredScores()).toEqual([]); + it('is called when recieving a load message', function(){ + $scores.load = jasmine.createSpy('scoresLoad'); + messageMock.mockSend('scores:reload'); + expect($scores.load).toHaveBeenCalled(); }); - }); - 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'); + it('throws an error if it revcieves an unkown version', function(){ + expect(() => $scores.load({ version: 4 })).toThrow(new Error('unsupported scores version 4, (expected 3)')); }); }); - 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'); + describe('_update',function() { + it('throws an error if endupdate is called before beginupdate', function() { + expect(() => $scores.endupdate()).toThrow(new Error('beginupdate()/endupdate() calls mismatched')); }); - 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; + it('doesn\'t run if currently updating', function() { + $scores.getRankings = jasmine.createSpy('getRankingsSpy').and.returnValue(Q.when()); + $scores._updating = 1; + $scores._update(); + expect($scores.getRankings).not.toHaveBeenCalled() }); - function fillScores(input, allowErrors) { - $scores.beginupdate(); - $scores.clear(); - input.map(function(score) { - $scores.add(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" }; + }); + describe('acceptScores', function() { 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"]); + $scores.load = jasmine.createSpy('loadSpy'); }); - 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('loads the new data', function() { + $scores.acceptScores({ data: mockScores }); + expect($scores.load).toHaveBeenCalledWith(mockScores); }); - 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('sends the reload message', function() { + $scores.acceptScores([mockScore]); + expect(messageMock.send).toHaveBeenCalledWith('scores:reload'); }); + }); - 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 }, - ]); + describe('create', function() { + it('calls ng-independence act', function() { + $scores.create({ scoreEntry: {} }); + expect(independenceMock.act).toHaveBeenCalled(); }); - 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 + it('adds a score to scores upon failure', function() { + var inititalLength = $scores.scores.length; + independenceMock.act = jasmine.createSpy('independenceAct').and.callFake(function(key, url, data, fallback) { + fallback(); + return new Promise(function(res, rej) { + res(); + }); }); - var result = filtered.scoreboard["test"].map(function(entry) { - return { - rank: entry.rank, - teamNumber: entry.team.number, - scores: entry.scores - }; + $scores.create({ scoreEntry: {} }).then(function() { + expect($scores.scores.length - inititalLength).toBe(1); }); - // 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); + describe('delete', function() { + it('calls ng-independence act', function() { + $scores.delete({ id: 'asdfg' }); + expect(independenceMock.act).toHaveBeenCalled(); }); - 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)); + it('removes a score from scores upon failure', function() { + var inititalLength = $scores.scores.length; + independenceMock.act = jasmine.createSpy('independenceAct').and.callFake(function(key, url, data, fallback) { + fallback(); + return new Promise(function(res, rej) { + res(); + }); + }); + $scores.delete({ id: 'asd23d' }).then(function() { + expect(inititalLength - $scores.scores.length).toBe(1); }); - 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(); + describe('update', function() { + it('calls ng-independence act', function() { + $scores.update({ id: 'asdfg' }); + expect(independenceMock.act).toHaveBeenCalled(); }); - it("should pick up a new sheet", function() { - return $scores.pollSheets().then(function() { - expect(filteredScores()).toEqual([importedScore]); + it('updates a score in scores upon failure', function() { + var newScore = { id: 'asdfg' }; + independenceMock.act = jasmine.createSpy('independenceAct').and.callFake(function(key, url, data, fallback) { + fallback(); + return new Promise(function(res, rej) { + res(); + }); }); - }); - - 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); + $scores.update(newScore).then(function() { + expect($scores.scores.filter(score => score.id === newScore.id)).toBe(newScore); }); }); + }); - 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('getRankings',function() { + it('calls ng-validation validate', function() { + $scores.getRankings(); + expect(validationMock.validate).toHaveBeenCalled(); }); - 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"] - } - ); - }); + it('calls ng-rankings calculate if there are no errors', function() { + $scores.getRankings(); + expect(rankingsMock.calculate).toHaveBeenCalled(); }); - 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); - }); - }); + it('doesn\'t calls ng-rankings calculate if there are errors', function() { + validationMock.validate = jasmine.createSpy('validation').and.returnValue([{ name: 'DuplicateScoreError' }]); + rankingsMock.calculate = jasmine.createSpy('calculate'); + $scores.getRankings(); + expect($scores.validationErrors.length).toBe(1); + expect(rankingsMock.calculate).not.toHaveBeenCalled(); }); + }); - 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('pendingActions',function() { + it('calls ng-independence pendingActions', function() { + $scores.pendingActions(); + expect(independenceMock.pendingActions).toHaveBeenCalled(); }); }); diff --git a/spec/services/ng-sessionSpec.js b/spec/services/ng-sessionSpec.js new file mode 100644 index 00000000..dd9eb110 --- /dev/null +++ b/spec/services/ng-sessionSpec.js @@ -0,0 +1,91 @@ +describe('ng-session',function() { + var ngServices = factory('services/ng-services'); + var module = factory('services/ng-session',{ + 'services/ng-services': ngServices + }); + + var mockSessionData = { + data: { + property1: 'value1', + property2: undefined + } + }; + var httpMock = createHttpMock({ + get: { + '/session': mockSessionData + } + }); + + var $session; + + beforeEach(function() { + angular.mock.module(module.name); + angular.mock.module(function($provide) { + $provide.value('$http', httpMock); + }); + angular.mock.inject(["$session", function(_$session_) { + $session = _$session_; + }]); + }); + + describe('load', function() { + + it('calls http get with /session', function() { + $session.load(); + expect(httpMock.get).toHaveBeenCalledWith('/session'); + }); + + it('returns a promise with an object', function() { + $session.load().then(function(session) { + expect(typeof(session)).toBe("object"); + }); + }); + + it('returns a promise with an obejct containing the session properties', function() { + $session.load().then(function(session) { + expect(session.property1).toBeDefined(); + }); + }); + + }); + + describe('get', function() { + + it('returns property\'s value when it exists', function() { + $session.load().then(function(session) { + expect($session.get('property1')).toBe('value1'); + }); + }); + + it('returns undefined when the property does\'nt exists', function() { + $session.load().then(function(session) { + expect($session.get('property3')).toBe(undefined); + }); + }); + + }); + + describe('keys', function() { + + it('contains key with defined value after load', function() { + $session.load().then(function(session) { + // expect($session.keys()).toEqual(['property1']); + expect($session.keys().includes('property1')).toBe(true); + }); + }); + + it('contains key with undefined value after load', function() { + $session.load().then(function(session) { + expect($session.keys().includes('property2')).toBe(true); + }); + }); + + it('does\'t contain undefined keys', function() { + $session.load().then(function(session) { + expect($session.keys().includes('property3')).toBe(false); + }); + }); + + }); + +}); diff --git a/spec/services/ng-validationSpec.js b/spec/services/ng-validationSpec.js new file mode 100644 index 00000000..bf26cef3 --- /dev/null +++ b/spec/services/ng-validationSpec.js @@ -0,0 +1,122 @@ +describe('ng-validation',function() { + var ngServices = factory('services/ng-services'); + var module = factory('services/ng-validation',{ + 'services/ng-services': ngServices + }); + + var $validation; + var $score; + var teamsMock = createTeamsMock([ + { number: 132 }, + { number: 221 }, + { number: 10 }, + { number: 32 } + ]); + var stagesMock = createStagesMock(); + + var unknownStageId = 'super practice'; + var unkownRound = 10; + var unknownTeamNumber = 143; + var invalidScore = "invalid score"; + + var legalScores, unknownStageScore, unkownRoundScore, invalidScoreScore, unknownTeamScore, duplicateScore; + + beforeEach(function() { + angular.mock.module(module.name); + angular.mock.module(function($provide) { + $provide.value('$teams', teamsMock); + $provide.value('$stages', stagesMock); + }); + angular.mock.inject(["$validation","$score", function(_$validation_, _$score_) { + $validation = _$validation_; + $score = _$score_; + }]); + + legalScore = new $score({ + stage: stagesMock.stages[0], + round: 1, + team: teamsMock.teams[0], + score: 231 + }); + + unknownStageScore = new $score({ + stageId: unknownStageId, + round: 1, + team: teamsMock.teams[0], + score: 231 + }); + + unkownRoundScore = new $score({ + stage: stagesMock.stages[0], + round: unkownRound, + team: teamsMock.teams[0], + score: 231 + }); + + invalidScoreScore = new $score({ + stage: stagesMock.stages[0], + round: 1, + team: teamsMock.teams[0], + score: invalidScore + }); + + unknownTeamScore = new $score({ + stage: stagesMock.stages[0], + round: 1, + teamNumber: unknownTeamNumber, + score: 231 + }); + + duplicateScore = new $score({ + stage: stagesMock.stages[0], + round: 1, + team: teamsMock.teams[0], + score: 456 + }); + }); + + describe('validate', function() { + + it('returns no errors when there are no scores', function() { + var errors = $validation.validate([]); + expect(errors.length).toBe(0); + }); + + it('doesn\'t return any error if givven a legal score', function() { + var errors = $validation.validate([legalScore]); + expect(errors.length).toBe(0); + }); + + it('returns only UnknownStageError if givven a score with an unkown stage', function() { + var errors = $validation.validate([unknownStageScore]); + expect(errors.length).toBe(1); + expect(errors[0].name).toBe('UnknownStageError'); + }); + + it('returns only UnknownRoundError if givven a score with an unkown round', function() { + var errors = $validation.validate([unkownRoundScore]); + expect(errors.length).toBe(1); + expect(errors[0].name).toBe('UnknownRoundError'); + }); + + it('returns only InvalidScoreError if givven a score with an invalid score', function() { + var errors = $validation.validate([invalidScoreScore]); + expect(errors.length).toBe(1); + expect(errors[0].name).toBe('InvalidScoreError'); + }); + + it('returns only UnknownTeamError if givven a score with an unkown team', function() { + var errors = $validation.validate([unknownTeamScore]); + expect(errors.length).toBe(1); + expect(errors[0].name).toBe('UnknownTeamError'); + }); + + it('returns only DuplicateScoreError if givven two duplicate scores', function() { + var errors = $validation.validate([legalScore, duplicateScore]); + expect(errors.length).toBe(1); + expect(errors[0].name).toBe('DuplicateScoreError'); + }); + + }); + +}); diff --git a/spec/views/rankingSpec.js b/spec/views/rankingSpec.js index f93b6c12..1b2f2b50 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(); @@ -140,8 +140,8 @@ describe('ranking', function() { expect($scope.csvdata).toEqual({}); $scope.rebuildCSV({ 'qualifying': [ - { rank: 1, team: { name: "foo", number: 123 }, highest: 10, scores: [0, 10, 5] }, - { rank: 1, team: { name: "\"bar\"", number: 456 }, highest: 10, scores: [10, 0, 5] } + { rank: 1, team: { name: "foo", number: 123 }, highest: { score: 10 }, scores: [0, 10, 5] }, + { rank: 1, team: { name: "\"bar\"", number: 456 }, highest: { score: 10 }, scores: [10, 0, 5] } ] }); expect($scope.csvname["qualifying"]).toEqual("ranking_qualifying.csv"); @@ -154,8 +154,8 @@ describe('ranking', function() { it('should not skip empty values, but include as empty string',function() { $scope.rebuildCSV({ 'qualifying': [ - { team: { name: "foo", number: 123 }, highest: 10, scores: [0, 10, 5] }, - { team: { name: "\"bar\"", number: 456 }, highest: 10, scores: [10, 0, 5] } + { team: { name: "foo", number: 123 }, highest: { score: 10 }, scores: [0, 10, 5] }, + { team: { name: "\"bar\"", number: 456 }, highest: { score: 10 }, scores: [10, 0, 5] } ] }); expect($scope.csvdata["qualifying"]).toEqual("data:text/csv;charset=utf-8," + encodeURIComponent([ diff --git a/spec/views/scoresSpec.js b/spec/views/scoresSpec.js index 19d97c6f..7f1ad203 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', { @@ -29,7 +29,6 @@ describe('scores', function() { it('should initialize', function() { expect($scope.sort).toEqual('index'); expect($scope.rev).toEqual(true); - expect($scope.scores).toEqual(scoresMock.scores); }); }); @@ -58,77 +57,59 @@ describe('scores', function() { describe('removeScore',function() { it('should remove a score',function() { - $scope.removeScore(1); - expect(scoresMock.remove).toHaveBeenCalledWith(1); - expect(scoresMock.save).toHaveBeenCalledWith(); + var score = { id: 'afg1jkhg' }; + $scope.deleteScore(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); + var score = { id: 'afg1jkhg' }; + $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(); + var id = 'afg1jkhg'; + $scope.publishScore({ id: id }); + expect(scoresMock.update).toHaveBeenCalledWith({ id: id, published: true}); }); }); 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(); + var id = 'afg1jkhg'; + $scope.unpublishScore({ id: id }); + expect(scoresMock.update).toHaveBeenCalledWith({ id: id, published: false }); }); }); 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(); + var score = { id: 'afg1jkhg' }; + $scope.editScore(score); + $scope.finishEditScore(score); + expect(scoresMock.update).toHaveBeenCalledWith({ id: 'afg1jkhg', $editing: false }); }); it('should alert if an error is thrown from scores',function() { scoresMock.update.and.throwError('update error'); - $scope.editScore(0); - $scope.finishEditScore(0); + var score = { id: 'afg1jkhg' }; + $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(); - expect(scoresMock._update).toHaveBeenCalled(); + it('should cancel the score edit scores',function() { + var score = { id: 'afg1jkhg' }; + $scope.editScore(score); + $scope.cancelEditScore(score); + expect(score.$editing).toBe(false); }); }); - 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..c31f3b11 100644 --- a/spec/views/scoresheetSpec.js +++ b/spec/views/scoresheetSpec.js @@ -25,6 +25,8 @@ describe('scoresheet',function() { settingsMock = createSettingsMock($q,'settings'); handshakeMock = createHandshakeMock($q); challengeMock = createChallengeMock(); + scoresMock = createScoresMock(); + scoresMock.scores[0].calcFilename = () => { this.filename = 'filename'; } $scope = $rootScope.$new(); $window = { Date: function() { @@ -38,6 +40,8 @@ describe('scoresheet',function() { '$scope': $scope, '$fs': fsMock, '$settings': settingsMock, + '$scores': scoresMock, + '$score': jasmine.createSpy('$score').and.returnValue(scoresMock.scores[0]), '$stages': {}, '$handshake': handshakeMock, '$teams': {}, @@ -59,114 +63,7 @@ describe('scoresheet',function() { $scope.$digest(); expect($scope.settings).toEqual('settings'); expect($scope.referee).toEqual(null); - expect($scope.table).toEqual(null); - }); - }); - - describe('load',function() { - describe('processing',function() { - var field, mission, objective, missions, objectiveIndex; - - beforeEach(function() { - field = 'foo'; - mission = { - score: [ - function() {return 1;}, - function() {return 2;} - ] - }; - objective = { - value: 4 - }; - missions = [mission]; - objectiveIndex = { - 'foo': objective - }; - challengeMock.load.and.returnValue(Q.when({ - field: field, - missions: missions, - objectiveIndex: objectiveIndex - })); - challengeMock.getDependencies.and.returnValue(['foo']); - }); - it('should set the field, missions and index',function() { - return $scope.load().then(function() { - $scope.$digest(); - expect($scope.field).toBe(field); - expect($scope.missions).toBe(missions); - expect($scope.objectiveIndex).toBe(objectiveIndex); - }); - }); - it('should process the missions',function() { - return $scope.load().then(function() { - $scope.$digest(); - expect(mission.errors).toEqual([]); - expect(mission.percentages).toEqual([]); - }); - }); - it('should set a watcher to mission dependencies',function() { - return $scope.load().then(function() { - $scope.$digest(); - expect(mission.result).toBe(3); - }); - }); - it('should be completed',function() { - return $scope.load().then(function() { - $scope.$digest(); - expect(mission.completed).toBe(true); - }); - }); - it('should not be completed if some scores are undefined',function() { - mission.score = [ - function() {return 1;}, - function() {return undefined;} - ]; - return $scope.load().then(function() { - $scope.$digest(); - expect(mission.completed).toBe(false); - }); - }); - it('should not count an error, but log it to mission errors',function() { - var err = new Error('squeek'); - mission.score = [ - function() {return 1;}, - function() {return err;} - ]; - return $scope.load().then(function() { - $scope.$digest(); - expect(mission.result).toBe(1); - expect(mission.errors).toEqual([err]); - }); - }); - it('should not count a fraction, but treat as percentage',function() { - mission.score = [ - function() {return 1;}, - function() {return 0.5;} - ]; - return $scope.load().then(function() { - $scope.$digest(); - expect(mission.result).toBe(1); - expect(mission.percentages).toEqual([0.5]); - }); - }); - it('should count undefined as 0',function() { - mission.score = [ - function() {return 1;}, - function() {return;} - ]; - return $scope.load().then(function() { - $scope.$digest(); - expect(mission.result).toBe(1); - }); - }); - }); - it('should set an error message when loading fails',function() { - challengeMock.load.and.returnValue(Q.reject('squeek')); - - return $scope.load().then(function() { - expect($scope.errorMessage).toBe('Could not load field, please configure host in settings'); - expect($window.alert).toHaveBeenCalledWith('Could not load field, please configure host in settings'); - }); + expect($scope.table).toEqual(undefined); }); }); @@ -232,8 +129,9 @@ describe('scoresheet',function() { ]; $scope.stage = 1; $scope.round = 2; - $scope.team = 3; $scope.table = 7; + $scope.team = 3; + $scope.referee = 6; }); it('should return empty in the happy situation',function() { @@ -332,8 +230,9 @@ describe('scoresheet',function() { ]; $scope.stage = 1; $scope.round = 2; - $scope.team = 3; $scope.table = 7; + $scope.team = 3; + $scope.referee = 6; }); it('should return true in the happy situation',function() { @@ -417,8 +316,9 @@ describe('scoresheet',function() { ]; $scope.stage = 1; $scope.round = 2; - $scope.team = 3; $scope.table = 7; + $scope.team = 3; + $scope.referee = 6; }); it('should return true in the happy situation',function() { @@ -502,8 +402,7 @@ describe('scoresheet',function() { describe('clear', function() { beforeEach(function() { - //setup some values - $scope.signature = "dummy"; + //setup happy situation $scope.missions = [ { objectives: [ @@ -518,21 +417,17 @@ describe('scoresheet',function() { ]; $scope.stage = 1; $scope.round = 2; - $scope.team = 3; $scope.table = 7; + $scope.team = 3; $scope.referee = 'piet'; }); it('should clear form', function() { - var oldId = $scope.uniqueId; $scope.clear(); - expect($scope.uniqueId).not.toEqual(oldId); - expect(typeof $scope.uniqueId).toEqual('string'); - expect($scope.uniqueId.length).toEqual(8); expect($scope.signature).toEqual(null); - expect($scope.team).toEqual(null); - expect($scope.stage).toEqual(null); - expect($scope.round).toEqual(null); + expect($scope.team).toBeUndefined(); + expect($scope.stage).toBeUndefined(); + expect($scope.round).toBeUndefined(); expect($scope.missions[0].objectives[0].value).toBeUndefined(); expect($scope.missions[0].objectives[1].value).toBeUndefined(); //table should not clear @@ -549,7 +444,6 @@ describe('scoresheet',function() { }); }); it('should save',function() { - $scope.uniqueId = "abcdef01"; $scope.team = dummyTeam; $scope.field = {}; $scope.stage = dummyStage; @@ -558,17 +452,15 @@ 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", + expect(scoresMock.create).toHaveBeenCalledWith({ + score: 0, team: dummyTeam, stage: dummyStage, round: 1, table: 7, referee: 'foo', - signature: [1,2,3,4], - score: 0 - }); + signature: [ 1, 2, 3, 4 ] + }, scoresMock.scores[0]); expect($window.alert).toHaveBeenCalledWith('Thanks for submitting a score of 0 points for team (123) foo in Voorrondes 1.'); }); }); @@ -576,19 +468,15 @@ describe('scoresheet',function() { $scope.team = dummyTeam; $scope.field = {}; $scope.stage = dummyStage; + var fileName = () => 'filename.json'; + $scope.calcFilename = fileName; $scope.round = 1; $scope.table = 7; var oldId = $scope.uniqueId; - fsMock.write.and.returnValue(Q.reject(new Error('argh'))); - var firstFilename; + scoresMock.create.and.returnValue(Q.reject(new Error('argh'))); 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); + expect($window.alert).toHaveBeenCalledWith(`Thanks for submitting a score of 0 points for team (123) foo in Voorrondes 1. +Notice: the score could not be sent to the server. This might be caused by poor network conditions. The score is thereafore save on your device, and will be sent when it's possible.Current number of scores actions waiting to be sent: 1`); }); }); }); @@ -616,7 +504,7 @@ describe('scoresheet',function() { $scope.openTeamModal('foo'); expect(handshakeMock.$emit).toHaveBeenCalledWith('chooseTeam','foo'); $scope.$digest(); - expect($scope.team).toEqual(null); + expect($scope.team).toEqual(undefined); }); }); @@ -637,8 +525,8 @@ describe('scoresheet',function() { $scope.openRoundModal('foo'); expect(handshakeMock.$emit).toHaveBeenCalledWith('chooseRound','foo'); $scope.$digest(); - expect($scope.stage).toEqual(null); - expect($scope.round).toEqual(null); + expect($scope.stage).toEqual(undefined); + expect($scope.round).toEqual(undefined); }); }); }); 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 diff --git a/src/css/elements.css b/src/css/elements.css index e4f8cbe6..1c8183ed 100644 --- a/src/css/elements.css +++ b/src/css/elements.css @@ -18,6 +18,13 @@ h1 { font-weight: normal; } +h3 { + font-family: 'latolight', 'Segoe UI Light','helvetica','Arial'; + font-size: 20px; + padding: 0 7px; + font-weight: normal; +} + table { border-collapse: collapse; width: 100%; @@ -94,6 +101,13 @@ table { color: white; text-transform: capitalize; } + + .appbar h3 { + display: inline-block; + color: black; + float: right; + } + .appbar .appbar-actions { position: absolute; top: 6px; diff --git a/src/js/config.js b/src/js/config.js index 41b6e69e..6ff41371 100644 --- a/src/js/config.js +++ b/src/js/config.js @@ -10,6 +10,7 @@ var require = { 'angular-sanitize': '../components/angular-sanitize/angular-sanitize.min', 'angular-touch': '../components/angular-touch/angular-touch.min', 'angular-bootstrap': '../components/angular-bootstrap/ui-bootstrap-tpls', + 'angular-storage': '../components/angular-storage/angular-storage', 'idbstore':'../components/idbwrapper/idbstore', 'signaturepad':'../components/signature-pad/jquery.signaturepad.min' }, @@ -28,6 +29,9 @@ var require = { }, 'angular-sanitize': { deps: ['angular'] + }, + 'angular-storage': { + deps: ['angular'] } } }; diff --git a/src/js/controllers/ExportRankingDialogController.js b/src/js/controllers/ExportRankingDialogController.js index 2edf7bec..2f617765 100644 --- a/src/js/controllers/ExportRankingDialogController.js +++ b/src/js/controllers/ExportRankingDialogController.js @@ -15,11 +15,11 @@ define('controllers/ExportRankingDialogController',[ $scope.export = {}; $scope.export.prevRounds = true; // enable highscore $scope.export.flowAmount = 10; // The amount of rows shown at the same time - $scope.export.fixedShownTop = 3; // The amount of scores always visible at top + $scope.export.fixedShownTop = 3; // The amount of scores always visible at top $scope.export.timeForFrame1 = 10; // Amount of seconds that the first page shows $scope.export.timeThroughFrames = 10; // Amount of seconds that each scroll takes $scope.export.fadeAtOneGo = 7; // Amount of scores that move away and appear - + $handshake.$on('exportRanking',function(e,data) { $scope.scores = data.scores; @@ -35,17 +35,19 @@ define('controllers/ExportRankingDialogController',[ $scope.export.rounds = Array.apply(null, Array(params.round)).map(function (_, i) {return i+1;}); var stageFilter = {}; stageFilter[params.stage.id] = params.round; - $scope.filterscoreboard = $scores.getRankings(stageFilter).scoreboard; + return $scores.getRankings(stageFilter).then(function(rankings) { + $scope.filterscoreboard = rankings; - $timeout(function () { - var htmloutput = ""+ params.stage.name + " " + params.round + ""; - htmloutput += $document[0].getElementById("scoreexport").innerHTML; - htmloutput += ""; - htmloutput += ""; - $scope.exportname = encodeURIComponent("RoundResults.html"); - $scope.exportdata = "data:text/html;charset=utf-8," + encodeURIComponent(htmloutput); - $scope.exportvisible = true; + $timeout(function () { + var htmloutput = ""+ params.stage.name + " " + params.round + ""; + htmloutput += $document[0].getElementById("scoreexport").innerHTML; + htmloutput += ""; + htmloutput += ""; + $scope.exportname = encodeURIComponent("RoundResults.html"); + $scope.exportdata = "data:text/html;charset=utf-8," + encodeURIComponent(htmloutput); + $scope.exportvisible = true; + }); }); }; diff --git a/src/js/main.js b/src/js/main.js index 1b9e7cb9..8a6dee01 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -1,6 +1,6 @@ define([ 'services/log', - 'services/session', + 'services/ng-session', 'views/settings', 'views/teams', 'views/scoresheet', @@ -16,6 +16,7 @@ define([ 'angular-bootstrap', 'angular-touch', 'angular-sanitize', + 'angular-storage', 'angular' ],function(log,session,settings,teams,scoresheet,scores,ranking,services,directives,size,filters,indexFilter,fsTest,dbTest) { @@ -27,16 +28,16 @@ define([ //initialize main controller and load main view //load other main views to create dynamic views for different device layouts angular.module('main',[]).controller('mainCtrl',[ - '$scope', 'session', - function($scope, session) { + '$scope', '$session', + function($scope, $session) { log('init main ctrl'); $scope.drawer = 'views/drawer.html'; $scope.scoringPages = ['scoresheet','settings']; $scope.validationErrors = []; $scope.drawerVisible = false; - session.onload(function() { - $scope.user = session.get('user'); + $session.load().then(function(session) { + $scope.user = session['user']; if($scope.user === 'admin') { $scope.pages = [ { name: 'scoresheet', title: 'Scoresheet', icon: 'check' }, diff --git a/src/js/services/ng-groups.js b/src/js/services/ng-groups.js new file mode 100644 index 00000000..9fc6fa64 --- /dev/null +++ b/src/js/services/ng-groups.js @@ -0,0 +1,63 @@ + +/** + * This is a service that can group an array using a function. + * It exposes two functions: one is group(arr, func), which groups arr into groups by the result of the func on each item + * Ther other is multigroup(arr,func) which groups arr recursivly, each time grouping each + * group of the last result using the next funciton in funs array. + * + * For example: + * group(["a","b","ab","ba"], (str) => str[0]) + * // returns { "a": ["a", "ab"], "b": ["b","ba"] } + * multigroup(["ac","bc","ab","ba", "bca"], [(str) => str[0],(str) => str[1]]) + * // returns { "a": { "b": ["ab"], "c": ["ac"] }, "b": { "a": ["ba"], "c": ["bc","bca"] } } + */ +define('services/ng-groups',[ + 'services/ng-services' +],function(module) { + "use strict"; + + return module.service('$groups', + [function() { + + function Groups() {}; + + /** + /* Groups an array by the result of a givven function. + /* For instance: + /* group([1,2,3,4,5,6,7,8,9], item => item % 3) + /* => return { 1: [1,4,7], 2: [2,5,8], 0: [3,6,9] } + */ + Groups.prototype.group = function(arr, func) { + return arr.reduce((groups, item) => { + let key = func(item); + if(!groups.hasOwnProperty(key)) { + groups[key] = []; + } + groups[key].push(item); + return groups; + }, {}); + }; + + /** + /* Runs the group(arr, func) on an array recursively. + /* It will create groups within groups. + /* For instace: + /* group([1,2,3,4,5,6,7,8,9], [item => item % 3, item => item % 2]) + /* => return { 1: { 1: [1,7], 0: [4] }, 2: { 1: [5], 0: [2,8] }, 0: { 1: [3,9], 0: [6] } } + */ + Groups.prototype.multigroup = function(arr, funcs) { + let currFunc = funcs[0]; + let result = this.group(arr, currFunc); + if(funcs.length > 1) { + let slicedFuncs = funcs.slice(1); + for(let key in result) { + result[key] = this.multigroup(result[key], slicedFuncs); + } + } + return result; + }; + + return new Groups(); + + }]); +}); 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(); + }]); +}); diff --git a/src/js/services/ng-message.js b/src/js/services/ng-message.js index c12324cf..c478b50d 100644 --- a/src/js/services/ng-message.js +++ b/src/js/services/ng-message.js @@ -12,6 +12,8 @@ define('services/ng-message',[ '$http','$settings','$q', function($http,$settings,$q) { var ws; + var listeners = []; + var token = parseInt(Math.floor(0x100000*(Math.random())), 16); function init() { if (ws) { @@ -38,9 +40,19 @@ define('services/ng-message',[ log("socket close"); }; ws.onmessage = function(msg) { - log("socket message",msg); - // var data = JSON.parse(msg.data); - // handleMessage(data); + var data = JSON.parse(msg.data); + 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)); + }).forEach(function(listener) { + listener.handler(data, msg); + }); }; return def.promise; }); @@ -50,13 +62,18 @@ 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, + headers: { "scoring-token": token } })); }); + }, + on: function(topic, handler) { + listeners.push({ topic: topic, handler: handler }); } }; } diff --git a/src/js/services/ng-rankings.js b/src/js/services/ng-rankings.js new file mode 100644 index 00000000..3175f7d1 --- /dev/null +++ b/src/js/services/ng-rankings.js @@ -0,0 +1,77 @@ +/** + * This is a service that can calculate the rankings. + */ +define('services/ng-rankings',[ + 'services/ng-services', + 'services/ng-stages', + 'services/ng-teams', + 'services/ng-score', + 'services/ng-groups' +],function(module) { + "use strict"; + + return module.service('$rankings', + ['$stages','$teams','$score','$groups', + function($stages, $teams, $score, $groups) { + + function Rank(rank, team, stage, filter) { + this.team = team; + this.stage = stage; + + this.scores = []; + for(let i = 0; i < (filter[stage.id] || stage.rounds); i++) { + this.scores[i] = rank.filter(score => score.round === (i + 1))[0]; + } + + this.ordered = rank.sort($score.compare) + this.highest = this.ordered[0]; + } + + Rank.compare = function(rank1, rank2) { + for(var i = 0; i < rank1.ordered.length && i < rank2.ordered.length; i++) { + let comparation = $score.compare(rank1.ordered[i], rank2.ordered[i]); + if(comparation !== 0) return comparation; + } + return 0; + }; + + return { + calculate: function(scores, filter) { + return $teams.init().then(() => $stages.init()) + .then(function() { + let teams = $teams.teams; + let stages = $stages.stages; + let ranks = $groups.multigroup(scores, [score => score.stageId, score => score.teamNumber]); + let stageRanks = {}; + stages.forEach(function(stage) { + let rankNumber = 1; + let lastHighest = null; + + // Mapping to Rank objects + stageRanks[stage.id] = teams.map(function(team) { + let stageRank = ranks[stage.id] || {}; + let teamRank = stageRank[team.number] || []; + return new Rank(teamRank, team, stage, filter || {}); + }) + + // Sorting by the highest score + .sort(Rank.compare) + + // Adding rank number + .map((rank) => { + if(lastHighest !== null && lastHighest !== rank.highest) { + rankNumber++; + } + rank.rank = rankNumber; + lastHighest = rank.highest; + return rank; + }); + + }); + return stageRanks; + }); + } + }; + + }]); +}); diff --git a/src/js/services/ng-score.js b/src/js/services/ng-score.js new file mode 100644 index 00000000..62c3c7d1 --- /dev/null +++ b/src/js/services/ng-score.js @@ -0,0 +1,77 @@ +/** + * This is the object representation of a score. + * It currently contains only the score's summary, but the goal is for it to + * fully contain the score from creation to saving, editing and deleting. + */ +define('services/ng-score',[ + 'services/ng-services' +],function(module) { + "use strict"; + + return module.factory('$score', + [function() { + + function Score(entry) { + + //Adding the data from the entry, snitizing it if needed. + (function(score, entry) { + let sanitized = Score.sanitize(entry); + for(var key in sanitized) { + score[key] = sanitized[key]; + } + }) (this, entry); + + } + + Score.prototype.calcFilename = function() { + this.file = [ + 'score', + this.stageId, + 'round' + this.round, + 'table' + this.table, + 'team' + this.teamNumber, + this.id + ].join('_')+'.json'; + return this.file; + }; + + Score.compare = function(score1, score2) { + return score2.score - score1.score; + }; + + // These two functions has no reason to be public except for UT. + + // Calculating the unique ID for this sanitized score. + // The uid is an 8-hex-digit combination of + // The current date and a random number. + Score.generateUniqueId = function() { + //The max uid in the range + let max = 0x100000000; + // The numeric form of the uid + let num = (Math.floor(Math.random() * max) + Date.now()) % max; + // The string uid, by adding the max value and then removing it we make sure the stringification of the number + return (num + max).toString(16).slice(1); + }; + + // This function is meant to make sure the score is set according to + // the correct structure in order to save it. + // score that doesn't match the fields in this function, + // cannot be saved in the scores summery. + Score.sanitize = function(entry) { + return { + id: entry.id || Score.generateUniqueId(), + file: Boolean(entry.file) ? String(entry.file) : '', + teamNumber: Number(entry.teamNumber || (entry.team ? entry.team.number : 0)), + stageId: String(entry.stageId || (entry.stage ? entry.stage.id : '')), + round: Number(entry.round), + score: isFinite(entry.score) ? Number(entry.score) : undefined, + originalScore: Number(entry.originalScore || entry.score), + edited: Boolean(entry.edited) ? String(entry.edited) : undefined, // timestamp, e.g. "Wed Nov 26 2014 21:11:43 GMT+0100 (CET)" + table: entry.table, + published: Boolean(entry.published) + }; + }; + + return Score; + }]); +}); diff --git a/src/js/services/ng-scores.js b/src/js/services/ng-scores.js index 0bea8f7e..837da243 100644 --- a/src/js/services/ng-scores.js +++ b/src/js/services/ng-scores.js @@ -6,70 +6,22 @@ define('services/ng-scores',[ 'services/ng-services', 'services/log', 'services/ng-fs', - 'services/ng-stages' + 'services/ng-stages', + 'services/ng-teams', + 'services/ng-independence', + 'services/ng-rankings', + 'services/ng-validation', + 'services/ng-score' ],function(module,log) { "use strict"; // 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', - function($rootScope, $fs, $stages, $q, $teams) { - - // Replace placeholders in format string. - // Example: format("Frobnicate {0} {1} {2}", "foo", "bar") - // -> "Frobnicate foo bar {2}" - // TODO: move this out of ng-scores to allow re-use - function format(/* fmt, args... */) { - var args = Array.prototype.slice.call(arguments); - var fmt = args.shift(); - return fmt.replace(/{(\d+)}/g, function(match, number) { - return args[number] !== undefined - ? args[number] - : match - ; - }); - } - - /* Validation error classes */ - - function UnknownStageError(stageId) { - this.stageId = stageId; - this.name = "UnknownStageError"; - this.message = format("unknown stage '{0}'", String(stageId)); - } - - function UnknownRoundError(round) { - this.round = round; - this.name = "UnknownRoundError"; - this.message = format("invalid round '{0}'", String(round)); - } - - function InvalidScoreError(score) { - this.score = score; - this.name = "InvalidScoreError"; - this.message = format("invalid score '{0}'", String(score)); - } - - function UnknownTeamError(team) { - this.team = team; - this.name = "UnknownTeamError"; - this.message = format("invalid team '{0}'", String(team)); - } - - function DuplicateScoreError(team, stage, round) { - this.team = team; - this.stage = stage; - this.round = round; - this.name = "DuplicateScoreError"; - this.message = format( - "duplicate score for team '{0}' ({1}), stage {2}, round {3}", - team.name, team.number, - stage.name, round - ); - } + ['$rootScope', '$fs', '$stages', '$message', '$teams','$independence', '$rankings', '$validation', '$score', + function($rootScope, $fs, $stages, $message, $teams, $independence, $rankings, $validation, $score) { /* Main Scores class */ @@ -97,12 +49,6 @@ define('services/ng-scores',[ */ this.scoreboard = {}; - this.UnknownStageError = UnknownStageError; - this.UnknownRoundError = UnknownRoundError; - this.InvalidScoreError = InvalidScoreError; - this.UnknownTeamError = UnknownTeamError; - this.DuplicateScoreError = DuplicateScoreError; - // We need to track changes to $stages, in order to update // the stage-references from the scores. // Note that the scores on disk only store stageId's, not @@ -121,16 +67,14 @@ define('services/ng-scores',[ self._update(); }, true); - this._rawScores = []; - - // Internal map of which scoresheets have already been - // processed. - this._sheets = {}; - this._updating = 0; this._initialized = null; // Promise - this._pollingSheets = null; // Promise this.init(); + + $message.on('scores:reload',function(data, msg) { + if(!msg.fromMe) + self.load(); + }); } /** @@ -138,8 +82,8 @@ define('services/ng-scores',[ * @returns Promise that is resolved when init is complete. */ Scores.prototype.init = function() { - var self = this; - if (!this._initialized) { + if(!this._initialized) { + var self = this; this._initialized = $stages.init() .then(function() { // Stages got defined, rebuild scoreboard @@ -158,159 +102,36 @@ define('services/ng-scores',[ Scores.prototype.clear = function() { - this._rawScores = []; + this.scores = []; 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() { + Scores.prototype.load = function(data) { var self = this; - return $fs.read('scores.json').then(function(res) { + var processScores = function(res) { self.beginupdate(); try { // Determine scores file version - var scores; - var version; - var sheetNames = []; - if (res.version === undefined) { - // 'Legacy' storage, all scores stored directly - // as an array - scores = res; - version = 1; - } else { - // New style storage, scores in a property, - // and an explicit version identifier. - version = res.version; - scores = res.scores; - sheetNames = res.sheets; + // And throw an exception if it is not supported + var version = res.version || 1; + if (version !== SCORES_VERSION) { + throw new Error(`unsupported scores version ${version}, (expected ${SCORES_VERSION})`); } - if (version > SCORES_VERSION) { - throw new Error(format("unknown scores version {0}, (expected {1})", version, SCORES_VERSION)); - } - self.clear(); - scores.forEach(function(score) { - self.add(score); - }); - self._sheets = {}; - sheetNames.forEach(function(name) { self._sheets[name] = true; }); + + self.scores = res.scores.map(score => new $score(score)); log("scores loaded, version " + version); } finally { self.endupdate(); } - }, function(err) { - log('scores read error', err); - }); - }; - - 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(); - }; - - /** - * Convert 'dirty' score value to correct type used during score - * computations etc. - * Valid inputs are fixed strings like "dnc" (Did Not Compete) and - * "dnq" (Did Not Qualify) in any combination of upper/lower case, - * null (dummy entry, maybe because score was removed) and numbers - * (also as strings). Empty string is converted to null. - * Invalid input is simply returned (and later marked as invalid - * during scoreboard computation). - */ - 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; - } - - /** - * Convert 'dirty' input score entry to a representation that we can store - * on a filesystem. This means e.g. not storing denormalized version of - * team and stage, but only their ID's. Additionally, forces values to be - * of the right type where possible. - */ - function sanitizeEntry(entry) { - 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 }; - } - - 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(); - }; - /** - * 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); + if(data) { + return processScores(data); + } else { + return $fs.read('scores.json').then(processScores, function(err) { + log('scores read error', err); + }); } - var newScore = sanitizeEntry(score); - newScore.edited = (new Date()).toString(); - this._rawScores.splice(index, 1, newScore); - this._update(); }; Scores.prototype.beginupdate = function() { @@ -327,374 +148,63 @@ 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() { if (this._updating > 0) { return; } - var self = this; - var results = this.getRankings(); - - // Update global scores without creating a new object - var scores = results.scores; - scores.unshift(0, this.scores.length); - this.scores.splice.apply(this.scores, scores); - - // Update global scoreboard without creating a new object - var board = this.scoreboard; - var k; - for (k in this.scoreboard) { - if (!this.scoreboard.hasOwnProperty(k)) { - continue; - } - delete this.scoreboard[k]; - } - Object.keys(results.scoreboard).forEach(function(stageId) { - self.scoreboard[stageId] = results.scoreboard[stageId]; - }); - - // Update validation errors (useful for views) - this.validationErrors.splice(0, this.validationErrors.length); - this.scores.forEach(function(score) { - if (score.error) { - self.validationErrors.push(score.error); - } + this.getRankings().then(function() { + $rootScope.$broadcast('validationError', self.validationErrors); }); - $rootScope.$broadcast('validationError', this.validationErrors); }; - /** - * Compute scoreboard and sanitized/validated scores. - * - * Optionally, pass an object containing stageId => nrOfRoundsOrTrue mapping. - * E.g. { "practice": true, "qualifying": 2 }, which computes the ranking - * for all rounds in the practice stage, and the first 2 rounds of the - * qualifying stage. - * - * Resulting object contains `scores` and `scoreboard` properties. - * If no stages filter is passed, all scores will be output. - * If a stages filter is passed, only valid and relevant scores are - * output. - * - * @param stages Optional object stageId => nrOfRoundsOrTrue - * @return Results object with validated scores and per-stage rankings - */ - Scores.prototype.getRankings = function(stages) { - var self = this; - var results = { - scores: [], // List of sanitized scores - scoreboard: {}, // Sorted rankings for each stage - }; - - var haveFilter = !!stages; - if (!stages) { - stages = {}; - $stages.stages.forEach(function(stage) { - stages[stage.id] = true; - }); - } - - // Convert number of stages to take to a number (i.e. Infinity when - // e.g. `true` is passed) - // And create empty lists for each stage - var board = results.scoreboard; - Object.keys(stages).forEach(function(stage) { - var s = stages[stage]; - stages[stage] = typeof s === "number" && s || s && Infinity || 0; - board[stage] = []; - }); - - // Walk all scores, and put them in the corresponding round of each stage. - // This also performs sanity checks on the scores, marking failures. - // Highest scores and rankings are computed later. - this._rawScores.forEach(function(_score) { - // Create a copy of the score, such that we can add - // additional info - var s = { - file: _score.file, - teamNumber: _score.teamNumber, - team: $teams.get(_score.teamNumber), - stageId: _score.stageId, - stage: $stages.get(_score.stageId), - round: _score.round, - score: _score.score, - originalScore: _score.originalScore, - edited: _score.edited, - published: _score.published, - table: _score.table, - modified: false, - error: null - }; - results.scores.push(s); - - // Mark score as modified if there have been changes to the - // original entry - if (s.score !== s.originalScore) { - s.modified = true; - } - - // Check whether score is for a 'known' stage - var bstage = board[_score.stageId]; - if (!bstage) { - s.error = new UnknownStageError(_score.stageId); - return; - } - - // Check whether score's round is valid for this stage - if (!(s.round >= 1 && s.round <= s.stage.rounds)) { - s.error = new UnknownRoundError(s.round); - return; - } - - // Check whether the score's value is sane - // Note: null is not considered valid here, as it would - // mean that one could 'reset' a team's score for that round. - // If a team did not play in a round, there will simply be no - // entry in scores. - if (!( - typeof s.score === "number" && s.score > -Infinity && s.score < Infinity || - s.score === "dnc" || - s.score === "dsq" - )) { - s.error = new InvalidScoreError(s.score); - return; - } - - // Check whether team is valid - if (!s.team) { - s.error = new UnknownTeamError(_score.teamNumber); - return; - } - - // Ignore score if filtered - if (haveFilter && s.round > stages[s.stageId]) { - return; - } - - // Find existing entry for this team, or create one - var bteam; - var i; - for (i = 0; i < bstage.length; i++) { - if (bstage[i].team.number === s.team.number) { - bteam = bstage[i]; - break; - } - } - if (!bteam) { - var maxRounds = Math.min(s.stage.rounds, stages[s.stageId]); - var initialScores = new Array(maxRounds); - var initialEntries = new Array(maxRounds); - for (i = 0; i < maxRounds; i++) { - initialScores[i] = null; - initialEntries[i] = null; - } - bteam = { - team: s.team, - scores: initialScores, - rank: null, - highest: null, - entries: initialEntries, - }; - bstage.push(bteam); - } - - // Add score to team's entry - if (bteam.scores[s.round - 1] !== null) { - // Find the original entry with which this entry collides, - // then assign an error to that entry and to ourselves. - var dupEntry = bteam.entries[s.round - 1]; - var e = dupEntry.error; - if (!e) { - e = new DuplicateScoreError(bteam.team, s.stage, s.round); - dupEntry.error = e; - } - s.error = e; - return; - } - bteam.scores[s.round - 1] = s.score; - bteam.entries[s.round - 1] = s; - }); - - // Compares two scores. - // Returns 0 if scores are equal, 1 if score2 is larger than score1, - // -1 otherwise. - // Note: this ordering causes Array.sort() to sort from highest to lowest score. - function scoreCompare(score1, score2) { - if (score1 === score2) { - return 0; - } - var comp = false; - if (score1 === null || score2 === null) { - comp = (score1 === null); - } else if (score1 === "dsq" || score2 === "dsq") { - comp = (score1 === "dsq"); - } else if (score1 === "dnc" || score2 === "dnc") { - comp = (score1 === "dnc"); - } else if (typeof score1 === "number" && typeof score2 === "number") { - comp = score1 < score2; - } else { - throw new TypeError("cannot compare scores '" + score1 + "' and '" + score2 + '"'); - } - return comp ? 1 : -1; - } - - // Compares two scores-arrays. - // Returns 0 if arrays are equal, 1 is scores2 is larger than scores1, - // -1 otherwise. - // Note: this ordering causes Array.sort() to sort from highest to lowest score. - function scoresCompare(scores1, scores2) { - var result = 0; - var i; - if (scores1.length !== scores2.length) { - throw new RangeError("cannot compare score arrays with different number of rounds"); - } - for (i = 0; i < scores1.length; i++) { - result = scoreCompare(scores1[i], scores2[i]); - if (result !== 0) - break; - } - return result; - } - - // Compare two 'team entries' (members of a scoreboard stage). - // 1 is teamEntry2 has higher scores than teamEntry1, or -if scores are - // equal- teamEntry1 has a higher team number. Returns -1 otherwise. - // Note: this ordering causes Array.sort() to sort from highest to lowest score, - // or in ascending team-id order. - function entryCompare(teamEntry1, teamEntry2) { - var result = scoresCompare(teamEntry1.sortedScores, teamEntry2.sortedScores); - if (result === 0) { - // Equal scores, ensure stable sort by introducing - // extra criterion. - // Note: team number's might be strings, so don't assume numeric - // compare is possible. - result = (teamEntry1.team.number > teamEntry2.team.number) ? 1 : -1; - } - return result; - } + Scores.prototype.acceptScores = function(res) { + this.load(res.data); + $message.send('scores:reload'); + } - function createSortedScores(teamEntry) { - teamEntry.sortedScores = teamEntry.scores.slice(0); // create a copy - teamEntry.sortedScores.sort(scoreCompare); - teamEntry.highest = teamEntry.sortedScores[0]; - } + Scores.prototype.create = function(scoresheet, score) { + var self = this; - function calculateRank(state,teamEntry) { - if (state.lastScores === null || scoresCompare(state.lastScores, teamEntry.sortedScores) !== 0) { - state.rank++; - } - state.lastScores = teamEntry.sortedScores; - teamEntry.rank = state.rank; - return state; - } + return $independence.act('scores','/scores/create',{ scoresheet: scoresheet, score: score }, function() { + self.scores.push(score); + }) + .then((res) => self.acceptScores(res)); + }; - // Sort by scores and compute rankings - for (var stageId in board) { - if (!board.hasOwnProperty(stageId)) { - continue; - } - var stage = board[stageId]; + Scores.prototype.delete = function(score) { + var self = this; + return $independence.act('scores','/scores/delete/' + score.id, {}, function() { + self.scores.splice(self.scores.findIndex(s => s.id === score.id), 1); + }).then((res) => self.acceptScores(res)); + }; - // Create sorted scores and compute highest score per team - stage.forEach(createSortedScores); + Scores.prototype.update = function(score) { + score.edited = (new Date()).toString(); + var self = this; + return $independence.act('scores','/scores/update/' + score.id, score, function() { + self.scores[self.scores.findIndex(s => s.id === score.id)] = score; + }).then((res) => self.acceptScores(res)); + }; - // Sort teams based on sorted scores - stage.sort(entryCompare); + Scores.prototype.getRankings = function(filter) { + this.validationErrors = $validation.validate(this.scores); - // Compute ranking, assigning equal rank to equal scores - stage.reduce(calculateRank,{ - rank: 0, - lastScores: null + var self = this; + if(this.validationErrors.length === 0) { + return $rankings.calculate(this.scores, filter).then(function(scoreboard) { + self.scoreboard = scoreboard; + return scoreboard; }); - } - - // Filter scores if requested - if (haveFilter) { - results.scores = results.scores.filter(function(score) { - return !score.error && stages[score.stageId] && score.round <= stages[score.stageId]; + } else { + return new Promise(function(resolve) { + resolve(self.scoreboard); }); } + }; - return results; + Scores.prototype.pendingActions = function() { + return $independence.pendingActions(); }; return new Scores(); diff --git a/src/js/services/ng-services.js b/src/js/services/ng-services.js index 8374a32b..6e91e14f 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',['ngStorage']); }); diff --git a/src/js/services/ng-session.js b/src/js/services/ng-session.js new file mode 100644 index 00000000..cbd443e0 --- /dev/null +++ b/src/js/services/ng-session.js @@ -0,0 +1,27 @@ +define('services/ng-session',[ + 'services/ng-services', +], function(module) { + + return module.service('$session', [ + '$http', + function($http) { + + var session = {}; + + return { + load: function() { + return $http.get('/session').then(function(response) { + session = response.data; + return session; + }); + }, + get: function(key) { + return session[key]; + }, + keys: function() { + return Object.keys(session); + } + }; + + }]); +}); diff --git a/src/js/services/ng-validation.js b/src/js/services/ng-validation.js new file mode 100644 index 00000000..cc193620 --- /dev/null +++ b/src/js/services/ng-validation.js @@ -0,0 +1,89 @@ +/** + * This is a service that searches for errors in the scores before ranking + */ +define('services/ng-validation',[ + 'services/ng-services', + 'services/ng-stages', + 'services/ng-teams' +],function(module) { + "use strict"; + + return module.service('$validation', + ['$stages','$teams', + function($stages, $teams) { + + const VALIDATORS = [{ + validate: (score) => $stages.get(score.stageId), + error:(score) => { + return { + name: 'UnknownStageError', + stageId: score.stageId, + message: `unknown stage '${String(score.stageId)}'` + }; + } + }, { + validate: (score) => score.round >= 1 && score.round <= $stages.get(score.stageId).rounds, + error: (score) => { + return { + name: 'UnknownRoundError', + round: score.round, + message: `unknown round '${String(score.round)}'` + }; + } + }, { + validate: (score) => isFinite(score.score), + error: (score) => { + return { + name: 'InvalidScoreError', + score: score.score, + message: `invalid score '${String(score.score)}'` + }; + } + }, { + validate: (score) => $teams.get(score.teamNumber), + error: (score) => { + return { + name: 'UnknownTeamError', + team: score.teamNumber, + message: `invalid team '${String(score.teamNumber)}'` + }; + } + }, { + validate: (score, scores) => { + for(var i = 0; i < scores.length && scores[i] !== score; i++) { + if(score.stageId === scores[i].stageId && score.round === scores[i].round && score.teamNumber === scores[i].teamNumber) { + return false; + } + } + return true; + }, + error: (score, stages) => { + return { + name: 'DuplicateScoreError', + team: score.teamNumber, + stage: score.stageId, + round: score.round, + message: `duplicate score for team #${score.teamNumber}, ${score.stageId}, round ${score.round}` + }; + } + }]; + + return { + validate: function(scores) { + var errors = []; + scores.forEach(function(score) { + validators: for(var i = 0; i < VALIDATORS.length; i++) { + var validator = VALIDATORS[i] + if(!validator.validate(score, scores)) { + score.error = validator.error(score, scores); + errors.push(score.error); + break validators; + } + } + }); + return errors; + } + }; + + }]); +}); diff --git a/src/js/services/session.js b/src/js/services/session.js deleted file mode 100644 index fcdb3682..00000000 --- a/src/js/services/session.js +++ /dev/null @@ -1,37 +0,0 @@ -define('services/session',[ - 'services/ng-services', -], function(module) { - - return module.service('session', [ - '$http', - function($http) { - - var eventListeners = []; - var session = {}; - - $http.get('/session').then(function(response) { - for(var key in response.data) { - session[key] = response.data[key]; - } - - eventListeners.forEach(function(eventListener) { - eventListener(); - }); - }); - - return { - get: function(key) { - return session[key]; - }, - keys: function() { - return Object.keys(session); - }, - onload: function(func) { - if(typeof func === 'function') { - eventListeners.push(func); - } - } - }; - - }]); -}); diff --git a/src/js/views/ranking.js b/src/js/views/ranking.js index 479a6055..93cca43a 100644 --- a/src/js/views/ranking.js +++ b/src/js/views/ranking.js @@ -20,13 +20,32 @@ define('views/ranking',[ $scope.scores = $scores; + function removeEmptyRanks(scoreboard) { + let result = {}; + for(let stageId in scoreboard) { + let stage = scoreboard[stageId]; + result[stageId] = stage.filter(rank => rank.scores.filter(score => score !== undefined).length); + } + return result; + } + + $scope.$watch(function() { + return $scores.scoreboard; + }, function () { + $scope.scoreboard = removeEmptyRanks($scores.scoreboard) + }, true); + + $scores.init().then(() => $stages.init()).then(function() { + $scope.stages = $stages.stages; + return $scores.getRankings(); + }); + $scope.exportRanking = function() { $handshake.$emit('exportRanking',{ scores: $scope.scores, stages: $scope.stages }); }; - //TODO: this is a very specific message tailored to display system. //we want less contract here $scope.broadcastRanking = function(stage) { @@ -136,7 +155,7 @@ define('views/ranking',[ entry.rank, entry.team.number, entry.team.name, - entry.highest, + entry.highest ? entry.highest.score : undefined, ].concat(entry.scores); }); var header = ["Rank", "Team Number", "Team Name", "Highest"]; @@ -153,13 +172,10 @@ define('views/ranking',[ $scope.rebuildCSV($scores.scoreboard); }, true); - $scope.stages = $stages.stages; - $scope.scoreboard = $scores.scoreboard; - $scope.getRoundLabel = function(round){ return "Round " + round; }; - + } ]); diff --git a/src/js/views/scores.js b/src/js/views/scores.js index 574ab79c..82f7b1c7 100644 --- a/src/js/views/scores.js +++ b/src/js/views/scores.js @@ -5,7 +5,7 @@ define('views/scores',[ 'angular' ],function(log) { var moduleName = 'scores'; - return angular.module(moduleName,[]).controller(moduleName+'Ctrl',[ + return angular.module(moduleName,['filters']).controller(moduleName+'Ctrl',[ '$scope', '$scores','$teams','$stages','$window', function($scope,$scores,$teams,$stages,$window) { log('init scores ctrl'); @@ -13,61 +13,66 @@ define('views/scores',[ $scope.sort = 'index'; $scope.rev = true; - $scope.scores = $scores.scores; - $scope.stages = $stages.stages; + function enrich(scores) { + return scores.map(score => { + var enrichedScore = {}; + for(var key in score) enrichedScore[key] = score[key]; + enrichedScore.team = $teams.get(score.teamNumber); + enrichedScore.stage = $stages.get(score.stageId); + return enrichedScore; + }); + } + + $scope.$watch(function() { + return $scores.scores; + }, function() { + $scope.scores = enrich($scores.scores); + }); + + $scores.init().then(() => $stages.init()).then(function() { + $scope.stages = $stages.stages; + }); $scope.doSort = function(col, defaultSort) { $scope.rev = (String($scope.sort) === String(col)) ? !$scope.rev : !!defaultSort; $scope.sort = col; }; - $scope.removeScore = function(index) { - $scores.remove(index); - return $scores.save(); + $scope.deleteScore = function(score) { + $scores.delete(score); }; - $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]; + score.$editing = false; saveScore(score); }; function saveScore(score) { try { - $scores.update(score.index, score); - $scores.save(); + $scores.update(score); } catch(e) { $window.alert("Error updating score: " + e); } } - $scope.cancelEditScore = function() { - $scores._update(); - }; - - $scope.pollSheets = function() { - return $scores.pollSheets().catch(function(err) { - log("pollSheets() failed", err); - $window.alert("failed to poll sheets: " + err); - }); + $scope.cancelEditScore = function(score) { + score.$editing = false; }; $scope.refresh = function() { diff --git a/src/js/views/scoresheet.js b/src/js/views/scoresheet.js index 1e18838a..22be4cdd 100644 --- a/src/js/views/scoresheet.js +++ b/src/js/views/scoresheet.js @@ -23,38 +23,27 @@ 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','$score','$settings','$challenge','$window','$q','$teams','$handshake', + function($scope,$fs,$stages,$scores,$score,$settings,$challenge,$window,$q,$teams,$handshake) { log('init scoresheet ctrl'); // Set up defaults $scope.settings = {}; $scope.missions = []; $scope.strings = []; - $scope.table = null; $scope.referee = null; // add teams and stages to scope for selection $scope.teams = $teams.teams; $scope.stages = $stages.stages; - $settings.init().then(function(res) { - $scope.settings = res; - return $scope.load(); - }); - - function generateId() { - var max = 0x100000000; // 8 digits - // Add current time to prevent Math.random() generating the same - // sequence if it's seeded with a constant. Not sure this is - // really needed, but better safe than sorry... - var num = (Math.floor(Math.random() * max) + Date.now()) % max; - // Convert to nice hex representation with padded zeroes, then strip that initial 1. - return (num + max).toString(16).slice(1); - } - $scope.load = function() { - return $challenge.load($scope.settings.challenge).then(function(defs) { + return $settings.init() + .then(function(res) { + $scope.settings = res; + return $challenge.load($scope.settings.challenge); + }) + .then(function(defs) { $scope.field = defs.field; $scope.missions = defs.missions; $scope.strings = defs.strings; @@ -67,6 +56,8 @@ define('views/scoresheet',[ }); }; + $scope.load(); + $scope.getString = function(key) { return $scope.strings[key]||key; }; @@ -84,7 +75,7 @@ define('views/scoresheet',[ mission.errors = []; mission.percentages = []; mission.completed = false; - //addd watcher for all dependencies + //add watcher for all dependencies $scope.$watch(function() { return deps.map(function(dep) { return $scope.objectiveIndex[dep].value; @@ -211,11 +202,10 @@ define('views/scoresheet',[ }; $scope.clear = function() { - $scope.uniqueId = generateId(); + $scope.team = undefined; + $scope.stage = undefined; + $scope.round = undefined; $scope.signature = null; - $scope.team = null; - $scope.stage = null; - $scope.round = null; $scope.missions.forEach(function(mission) { mission.objectives.forEach(function(objective) { delete objective["value"]; @@ -230,37 +220,34 @@ define('views/scoresheet',[ $window.alert('no team selected, do so first'); return $q.reject(new Error('no team selected, do so first')); } + var scoresheet = angular.copy($scope.field); + scoresheet.stage = $scope.stage; + scoresheet.round = $scope.round; + scoresheet.team = $scope.team; + scoresheet.table = $scope.table; + scoresheet.referee = $scope.referee; + scoresheet.signature = $scope.signature; - var data = angular.copy($scope.field); - data.uniqueId = $scope.uniqueId; - data.team = $scope.team; - data.stage = $scope.stage; - data.round = $scope.round; - // data.table = $scope.settings.table; - data.table = $scope.table; - data.referee = $scope.referee; - data.signature = $scope.signature; - data.score = $scope.score(); - - var fn = [ - 'score', - data.stage.id, - 'round' + data.round, - 'table' + data.table, - 'team' + data.team.number, - data.uniqueId - ].join('_')+'.json'; + scoresheet.score = $scope.score(); + var score = $score(scoresheet); + score.calcFilename(); - return $fs.write("scoresheets/" + fn,data).then(function() { + return $scores.create(scoresheet, score).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)); + message = `Thanks for submitting a score of ${scoresheet.score} points for team (${scoresheet.team.number})` + + ` ${scoresheet.team.name} in ${scoresheet.stage.name} ${scoresheet.round}.`; + $window.alert(message); + }).catch(function(err) { + log(`Error: ${err}`); + $scope.clear(); + message = `Thanks for submitting a score of ${scoresheet.score} points for team (${scoresheet.team.number})` + + ` ${scoresheet.team.name} in ${scoresheet.stage.name} ${scoresheet.round}.` + ` +Notice: the score could not be sent to the server. ` + + `This might be caused by poor network conditions. ` + + `The score is thereafore save on your device, and will be sent when it's possible.` + + `Current number of scores actions waiting to be sent: ${$scores.pendingActions()}` + $window.alert(message); throw err; }); }; diff --git a/src/views/dialogs.html b/src/views/dialogs.html index b3e34e47..2b30493d 100644 --- a/src/views/dialogs.html +++ b/src/views/dialogs.html @@ -186,20 +186,20 @@

file_download Export naar USB - +