├── .gitignore ├── entries.json ├── index.js ├── package.json ├── src ├── core.js ├── reducer.js ├── server.js └── store.js └── test ├── core_spec.js ├── immutable_spec.js ├── reducer_spec.js ├── store_spec.js └── test_helper.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /entries.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Shallow Grave", 3 | "Trainspotting", 4 | "A Life Less Ordinary", 5 | "The Beach", 6 | "28 Days Later", 7 | "Millions", 8 | "Sunshine", 9 | "Slumdog Millionaire", 10 | "127 Hours", 11 | "Trance", 12 | "Steve Jobs" 13 | ] 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import makeStore from './src/store'; 2 | import {startServer} from './src/server'; 3 | 4 | export const store = makeStore(); 5 | startServer(store); 6 | 7 | store.dispatch({ 8 | type: 'SET_ENTRIES', 9 | entries: require('./entries.json') 10 | }); 11 | store.dispatch({type: 'NEXT'}); 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voting-server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "babel-node index.js", 8 | "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js --recursive", 9 | "test:watch": "npm run test -- --watch" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "babel-core": "6.5.1", 15 | "babel-cli": "6.5.1", 16 | "babel-preset-es2015": "6.5.0", 17 | "chai": "3.5.0", 18 | "chai-immutable": "1.5.3", 19 | "mocha": "2.4.5" 20 | }, 21 | "dependencies": { 22 | "immutable": "3.7.6", 23 | "redux": "3.3.1", 24 | "socket.io": "1.4.5" 25 | }, 26 | "babel": { 27 | "presets": ["es2015"] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/core.js: -------------------------------------------------------------------------------- 1 | import {List, Map} from 'immutable'; 2 | 3 | export const INITIAL_STATE = Map(); 4 | 5 | export function setEntries(state, entries) { 6 | const list = List(entries); 7 | return state.set('entries', list) 8 | .set('initialEntries', list); 9 | } 10 | 11 | function getWinners(vote) { 12 | if (!vote) return []; 13 | const [one, two] = vote.get('pair'); 14 | const oneVotes = vote.getIn(['tally', one], 0); 15 | const twoVotes = vote.getIn(['tally', two], 0); 16 | if (oneVotes > twoVotes) return [one]; 17 | else if (oneVotes < twoVotes) return [two]; 18 | else return [one, two]; 19 | } 20 | 21 | export function next(state, round = state.getIn(['vote', 'round'], 0)) { 22 | const entries = state.get('entries') 23 | .concat(getWinners(state.get('vote'))); 24 | if (entries.size === 1) { 25 | return state.remove('vote') 26 | .remove('entries') 27 | .set('winner', entries.first()); 28 | } else { 29 | return state.merge({ 30 | vote: Map({ 31 | round: round + 1, 32 | pair: entries.take(2) 33 | }), 34 | entries: entries.skip(2) 35 | }); 36 | } 37 | } 38 | 39 | export function restart(state) { 40 | const round = state.getIn(['vote', 'round'], 0); 41 | return next( 42 | state.set('entries', state.get('initialEntries')) 43 | .remove('vote') 44 | .remove('winner'), 45 | round 46 | ); 47 | } 48 | 49 | function removePreviousVote(voteState, voter) { 50 | const previousVote = voteState.getIn(['votes', voter]); 51 | if (previousVote) { 52 | return voteState.updateIn(['tally', previousVote], t => t - 1) 53 | .removeIn(['votes', voter]); 54 | } else { 55 | return voteState; 56 | } 57 | } 58 | 59 | function addVote(voteState, entry, voter) { 60 | if (voteState.get('pair').includes(entry)) { 61 | return voteState.updateIn(['tally', entry], 0, t => t + 1) 62 | .setIn(['votes', voter], entry); 63 | } else { 64 | return voteState; 65 | } 66 | } 67 | 68 | export function vote(voteState, entry, voter) { 69 | return addVote( 70 | removePreviousVote(voteState, voter), 71 | entry, 72 | voter 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import {setEntries, next, restart, vote, INITIAL_STATE} from './core'; 2 | 3 | export default function reducer(state = INITIAL_STATE, action) { 4 | switch (action.type) { 5 | case 'SET_ENTRIES': 6 | return setEntries(state, action.entries); 7 | case 'NEXT': 8 | return next(state); 9 | case 'RESTART': 10 | return restart(state); 11 | case 'VOTE': 12 | return state.update('vote', 13 | voteState => vote(voteState, action.entry, action.clientId)); 14 | } 15 | return state; 16 | } 17 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import Server from 'socket.io'; 2 | 3 | export function startServer(store) { 4 | const io = new Server().attach(8090); 5 | 6 | store.subscribe( 7 | () => io.emit('state', store.getState().toJS()) 8 | ); 9 | 10 | io.on('connection', (socket) => { 11 | socket.emit('state', store.getState().toJS()); 12 | socket.on('action', store.dispatch.bind(store)); 13 | }); 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import {createStore} from 'redux'; 2 | import reducer from './reducer'; 3 | 4 | export default function makeStore() { 5 | return createStore(reducer); 6 | } 7 | -------------------------------------------------------------------------------- /test/core_spec.js: -------------------------------------------------------------------------------- 1 | import {List, Map} from 'immutable'; 2 | import {expect} from 'chai'; 3 | 4 | import {setEntries, next, vote, restart} from '../src/core'; 5 | 6 | describe('application logic', () => { 7 | 8 | describe('setEntries', () => { 9 | 10 | it('adds the entries to the state', () => { 11 | const state = Map(); 12 | const entries = List.of('Trainspotting', '28 Days Later'); 13 | const nextState = setEntries(state, entries); 14 | expect(nextState).to.equal(Map({ 15 | entries: List.of('Trainspotting', '28 Days Later'), 16 | initialEntries: List.of('Trainspotting', '28 Days Later') 17 | })); 18 | }); 19 | 20 | it('converts to immutable', () => { 21 | const state = Map(); 22 | const entries = ['Trainspotting', '28 Days Later']; 23 | const nextState = setEntries(state, entries); 24 | expect(nextState).to.equal(Map({ 25 | entries: List.of('Trainspotting', '28 Days Later'), 26 | initialEntries: List.of('Trainspotting', '28 Days Later') 27 | })); 28 | }); 29 | 30 | }); 31 | 32 | describe('next', () => { 33 | 34 | it('takes the next two entries under vote', () => { 35 | const state = Map({ 36 | entries: List.of('Trainspotting', '28 Days Later', 'Sunshine') 37 | }); 38 | const nextState = next(state); 39 | expect(nextState).to.equal(Map({ 40 | vote: Map({ 41 | round: 1, 42 | pair: List.of('Trainspotting', '28 Days Later') 43 | }), 44 | entries: List.of('Sunshine') 45 | })); 46 | }); 47 | 48 | it('puts winner of current vote back to entries', () => { 49 | expect( 50 | next(Map({ 51 | vote: Map({ 52 | round: 1, 53 | pair: List.of('Trainspotting', '28 Days Later'), 54 | tally: Map({ 55 | 'Trainspotting': 4, 56 | '28 Days Later': 2 57 | }) 58 | }), 59 | entries: List.of('Sunshine', 'Millions', '127 Hours') 60 | })) 61 | ).to.equal( 62 | Map({ 63 | vote: Map({ 64 | round: 2, 65 | pair: List.of('Sunshine', 'Millions') 66 | }), 67 | entries: List.of('127 Hours', 'Trainspotting') 68 | }) 69 | ); 70 | }); 71 | 72 | it('puts both from tied vote back to entries', () => { 73 | expect( 74 | next(Map({ 75 | vote: Map({ 76 | round: 1, 77 | pair: List.of('Trainspotting', '28 Days Later'), 78 | tally: Map({ 79 | 'Trainspotting': 3, 80 | '28 Days Later': 3 81 | }) 82 | }), 83 | entries: List.of('Sunshine', 'Millions', '127 Hours') 84 | })) 85 | ).to.equal( 86 | Map({ 87 | vote: Map({ 88 | round: 2, 89 | pair: List.of('Sunshine', 'Millions') 90 | }), 91 | entries: List.of('127 Hours', 'Trainspotting', '28 Days Later') 92 | }) 93 | ); 94 | }); 95 | 96 | it('marks winner when just one entry left', () => { 97 | expect( 98 | next(Map({ 99 | vote: Map({ 100 | round: 1, 101 | pair: List.of('Trainspotting', '28 Days Later'), 102 | tally: Map({ 103 | 'Trainspotting': 4, 104 | '28 Days Later': 2 105 | }) 106 | }), 107 | entries: List() 108 | })) 109 | ).to.equal( 110 | Map({ 111 | winner: 'Trainspotting' 112 | }) 113 | ); 114 | }); 115 | 116 | }); 117 | 118 | describe('restart', () => { 119 | 120 | it('returns to initial entries and takes the first two entries under vote', () => { 121 | expect( 122 | restart(Map({ 123 | vote: Map({ 124 | round: 1, 125 | pair: List.of('Trainspotting', 'Sunshine') 126 | }), 127 | entries: List(), 128 | initialEntries: List.of('Trainspotting', '28 Days Later', 'Sunshine') 129 | })) 130 | ).to.equal( 131 | Map({ 132 | vote: Map({ 133 | round: 2, 134 | pair: List.of('Trainspotting', '28 Days Later') 135 | }), 136 | entries: List.of('Sunshine'), 137 | initialEntries: List.of('Trainspotting', '28 Days Later', 'Sunshine') 138 | }) 139 | ); 140 | }); 141 | 142 | }); 143 | 144 | describe('vote', () => { 145 | 146 | it('creates a tally for the voted entry', () => { 147 | expect( 148 | vote(Map({ 149 | round: 1, 150 | pair: List.of('Trainspotting', '28 Days Later') 151 | }), 'Trainspotting', 'voter1') 152 | ).to.equal( 153 | Map({ 154 | round: 1, 155 | pair: List.of('Trainspotting', '28 Days Later'), 156 | tally: Map({ 157 | 'Trainspotting': 1 158 | }), 159 | votes: Map({ 160 | voter1: 'Trainspotting' 161 | }) 162 | }) 163 | ); 164 | }); 165 | 166 | it('adds to existing tally for the voted entry', () => { 167 | expect( 168 | vote(Map({ 169 | round: 1, 170 | pair: List.of('Trainspotting', '28 Days Later'), 171 | tally: Map({ 172 | 'Trainspotting': 3, 173 | '28 Days Later': 2 174 | }), 175 | votes: Map() 176 | }), 'Trainspotting', 'voter1') 177 | ).to.equal( 178 | Map({ 179 | round: 1, 180 | pair: List.of('Trainspotting', '28 Days Later'), 181 | tally: Map({ 182 | 'Trainspotting': 4, 183 | '28 Days Later': 2 184 | }), 185 | votes: Map({ 186 | voter1: 'Trainspotting' 187 | }) 188 | }) 189 | ); 190 | }); 191 | 192 | it('nullifies previous vote for the same voter', () => { 193 | expect( 194 | vote(Map({ 195 | round: 1, 196 | pair: List.of('Trainspotting', '28 Days Later'), 197 | tally: Map({ 198 | 'Trainspotting': 3, 199 | '28 Days Later': 2 200 | }), 201 | votes: Map({ 202 | voter1: '28 Days Later' 203 | }) 204 | }), 'Trainspotting', 'voter1') 205 | ).to.equal( 206 | Map({ 207 | round: 1, 208 | pair: List.of('Trainspotting', '28 Days Later'), 209 | tally: Map({ 210 | 'Trainspotting': 4, 211 | '28 Days Later': 1 212 | }), 213 | votes: Map({ 214 | voter1: 'Trainspotting' 215 | }) 216 | }) 217 | ); 218 | }); 219 | 220 | it('ignores the vote if for an invalid entry', () => { 221 | expect( 222 | vote(Map({ 223 | round: 1, 224 | pair: List.of('Trainspotting', '28 Days Later') 225 | }), 'Sunshine') 226 | ).to.equal( 227 | Map({ 228 | round: 1, 229 | pair: List.of('Trainspotting', '28 Days Later') 230 | }) 231 | ); 232 | }); 233 | 234 | }); 235 | 236 | }); 237 | -------------------------------------------------------------------------------- /test/immutable_spec.js: -------------------------------------------------------------------------------- 1 | import {List, Map} from 'immutable'; 2 | import {expect} from 'chai'; 3 | 4 | describe('immutability', () => { 5 | 6 | describe('numbers', () => { 7 | 8 | function increment(currentState) { 9 | return currentState + 1; 10 | } 11 | 12 | it('are immutable', () => { 13 | let state = 42; 14 | let nextState = increment(state); 15 | 16 | expect(nextState).to.equal(43); 17 | expect(state).to.equal(42); 18 | }); 19 | 20 | }); 21 | 22 | describe('Lists', () => { 23 | 24 | function addMovie(currentState, movie) { 25 | return currentState.push(movie); 26 | } 27 | 28 | it('are immutable', () => { 29 | let state = List.of('Trainspotting', '28 Days Later'); 30 | let nextState = addMovie(state, 'Sunshine'); 31 | 32 | expect(nextState).to.equal(List.of( 33 | 'Trainspotting', 34 | '28 Days Later', 35 | 'Sunshine' 36 | )); 37 | expect(state).to.equal(List.of( 38 | 'Trainspotting', 39 | '28 Days Later' 40 | )); 41 | }); 42 | 43 | }); 44 | 45 | describe('trees', () => { 46 | 47 | function addMovie(currentState, movie) { 48 | return currentState.update('movies', movies => movies.push(movie)); 49 | } 50 | 51 | it('are immutable', () => { 52 | let state = Map({ 53 | movies: List.of('Trainspotting', '28 Days Later') 54 | }); 55 | let nextState = addMovie(state, 'Sunshine'); 56 | 57 | expect(nextState).to.equal(Map({ 58 | movies: List.of( 59 | 'Trainspotting', 60 | '28 Days Later', 61 | 'Sunshine' 62 | ) 63 | })); 64 | expect(state).to.equal(Map({ 65 | movies: List.of( 66 | 'Trainspotting', 67 | '28 Days Later' 68 | ) 69 | })); 70 | }); 71 | 72 | }); 73 | 74 | }); 75 | -------------------------------------------------------------------------------- /test/reducer_spec.js: -------------------------------------------------------------------------------- 1 | import {Map, fromJS} from 'immutable'; 2 | import {expect} from 'chai'; 3 | 4 | import reducer from '../src/reducer'; 5 | 6 | describe('reducer', () => { 7 | 8 | it('handles SET_ENTRIES', () => { 9 | const initialState = Map(); 10 | const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']}; 11 | const nextState = reducer(initialState, action); 12 | 13 | expect(nextState).to.equal(fromJS({ 14 | entries: ['Trainspotting'], 15 | initialEntries: ['Trainspotting'] 16 | })); 17 | }); 18 | 19 | it('handles NEXT', () => { 20 | const initialState = fromJS({ 21 | entries: ['Trainspotting', '28 Days Later'] 22 | }); 23 | const action = {type: 'NEXT'}; 24 | const nextState = reducer(initialState, action); 25 | 26 | expect(nextState).to.equal(fromJS({ 27 | vote: { 28 | round: 1, 29 | pair: ['Trainspotting', '28 Days Later'] 30 | }, 31 | entries: [] 32 | })); 33 | }); 34 | 35 | it('handles VOTE', () => { 36 | const initialState = fromJS({ 37 | vote: { 38 | round: 1, 39 | pair: ['Trainspotting', '28 Days Later'] 40 | }, 41 | entries: [] 42 | }); 43 | const action = {type: 'VOTE', entry: 'Trainspotting', clientId: 'voter1'}; 44 | const nextState = reducer(initialState, action); 45 | 46 | expect(nextState).to.equal(fromJS({ 47 | vote: { 48 | round: 1, 49 | pair: ['Trainspotting', '28 Days Later'], 50 | tally: {Trainspotting: 1}, 51 | votes: { 52 | voter1: 'Trainspotting' 53 | } 54 | }, 55 | entries: [] 56 | })); 57 | }); 58 | 59 | it('has an initial state', () => { 60 | const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']}; 61 | const nextState = reducer(undefined, action); 62 | expect(nextState).to.equal(fromJS({ 63 | entries: ['Trainspotting'], 64 | initialEntries: ['Trainspotting'] 65 | })); 66 | }); 67 | 68 | it('can be used with reduce', () => { 69 | const actions = [ 70 | {type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']}, 71 | {type: 'NEXT'}, 72 | {type: 'VOTE', entry: 'Trainspotting', clientId: 'voter1'}, 73 | {type: 'VOTE', entry: '28 Days Later', clientId: 'voter2'}, 74 | {type: 'VOTE', entry: 'Trainspotting', clientId: 'voter3'}, 75 | {type: 'NEXT'} 76 | ]; 77 | const finalState = actions.reduce(reducer, Map()); 78 | 79 | expect(finalState).to.equal(fromJS({ 80 | winner: 'Trainspotting', 81 | initialEntries: ['Trainspotting', '28 Days Later'] 82 | })); 83 | }); 84 | 85 | }); 86 | -------------------------------------------------------------------------------- /test/store_spec.js: -------------------------------------------------------------------------------- 1 | import {Map, fromJS} from 'immutable'; 2 | import {expect} from 'chai'; 3 | 4 | import makeStore from '../src/store'; 5 | 6 | describe('store', () => { 7 | 8 | it('is a Redux store configured with the correct reducer', () => { 9 | const store = makeStore(); 10 | expect(store.getState()).to.equal(Map()); 11 | 12 | store.dispatch({ 13 | type: 'SET_ENTRIES', 14 | entries: ['Trainspotting', '28 Days Later'] 15 | }); 16 | expect(store.getState()).to.equal(fromJS({ 17 | entries: ['Trainspotting', '28 Days Later'], 18 | initialEntries: ['Trainspotting', '28 Days Later'] 19 | })); 20 | }); 21 | 22 | }); 23 | -------------------------------------------------------------------------------- /test/test_helper.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | 4 | chai.use(chaiImmutable); 5 | --------------------------------------------------------------------------------