bensaufley/code-words-api

View on GitHub
test/models/game-test.js

Summary

Maintainability
C
7 hrs
Test Coverage
'use strict';

const helper = require('../test-helper'),
      expect = helper.expect,
      sinon = helper.sinon,
      Game = require('../../models/game'),
      Player = require('../../models/player'),
      User = require('../../models/user'),
      GameBoard = require('../../lib/game-board'),
      gameHelpers = require('../support/game-helpers');

describe('Game', () => {
  let game;

  beforeEach(() => {
    return Game.create().then((g) => { game = g; });
  });

  afterEach(helper.cleanDatabase);

  describe('initialization', () => {
    it('creates a new game board', () => {
      let board = game.getDataValue('board');

      expect(board).to.have.lengthOf(25);
      board.forEach((tile) => {
        expect(tile.revealed).to.be.false;
        expect(tile.type).to.be.oneOf(['a', 'b', null, 'x']);
        expect(tile.word).to.be.a('string').with.length.above(2);
      });
    });
  });

  it('returns a GameBoard object for its board property', () => {
    expect(game.board).to.be.instanceOf(GameBoard);
    expect(game.board.tiles).to.eql(game.getDataValue('board'));
  });

  describe('serializeFor', () => {
    let transmitter, decoder, game;

    beforeEach(() => {
      return gameHelpers.prepareGame()
        .then((response) => {
          ({ aTransmitterPlayer: transmitter, aDecoderPlayer: decoder, game } = response);
        });
    });

    it('includes a redacted grid for transmitters before the game is started', () => {
      let serialized = game.serializeFor(transmitter);

      expect(serialized.board.map((t) => t.type)).to.eql(new Array(25).fill('redacted'));
    });

    it('includes the full grid for transmitter players', () => {
      return game.start()
        .then(() => {
          let serialized = game.serializeFor(transmitter);

          expect(serialized.board).to.eq(game.getDataValue('board'));
        });
    });

    it('includes a redacted grid for decoder players', () => {
      let serialized = game.serializeFor(decoder);

      expect(serialized.board).not.to.eq(game.getDataValue('board'));
      expect(serialized.board.map((t) => t.type)).to.eql(new Array(25).fill('redacted'));
    });

    it('returns an array of turns in order', () => {
      let turn1 = { event: 'transmission', playerId: transmitter, word: 'sunlight', number: 3 },
          turn2 = { event: 'decoding', playerId: decoder, tile: 12 };
      return game.start()
        .then(() => game.update({ turns: [turn1, turn2] }))
        .then(() => {
          let serialized = game.serializeFor(transmitter);

          expect(serialized.turns[0]).to.eql(turn1);
          expect(serialized.turns[1]).to.eql(turn2);
        });
    });
  });

  context('gameplay', () => {
    let aTransmitterPlayer, aDecoderPlayer, bTransmitterPlayer, bDecoderPlayer, game;

    beforeEach(() => {
      return gameHelpers.prepareGame()
        .then((response) => {
          ({ aTransmitterPlayer, aDecoderPlayer, bTransmitterPlayer, bDecoderPlayer, game } = response);
        });
    });

    describe('start', () => {
      context('not enough players', () => {
        beforeEach(() => {
          return Promise.all([
            aTransmitterPlayer.destroy(),
            bDecoderPlayer.destroy()
          ]);
        });

        it('rejects when not enough players', () => {
          return game.start()
            .then(() => { return Promise.reject(new Error('Should not have been successful')); })
            .catch((err) => {
              expect(err.message).to.eq('Not enough players');
              expect(game.activePlayerId).to.be.null;
            });
        });
      });

      context('ready to start', () => {
        it('sets the active player to the starting team\'s transmitter', () => {
          return game.start()
            .then(() => {
              let startingTeam = game.board.startingTeam(),
                  startingPlayer = eval(`${startingTeam}TransmitterPlayer`);
              if (!startingTeam || !startingPlayer) return Promise.reject(new Error('Could not determine starting player'));

              expect(game.activePlayerId).to.eq(startingPlayer.id);
            });
        });
      });
    });

    describe('transmit', () => {
      context('invalid', () => {
        it('rejects if game not started', () => {
          return game.transmit('word', 2)
            .then(() => Promise.reject(new Error('Should not have accepted transmission')))
            .catch((err) => {
              expect(err.message).to.eq('Game has not begun');
            });
        });

        context('started game', () => {
          beforeEach(() => game.start());

          it('rejects if transmission includes spaces', () => {
            return game.transmit('not valid', 2)
              .then(() => Promise.reject(new Error('Should not have accepted transmission')))
              .catch((err) => {
                expect(err.message).to.eq('Transmission must be one single word');
              });
          });

          it('rejects if transmission is empty', () => {
            return game.transmit('', 2)
              .then(() => Promise.reject(new Error('Should not have accepted transmission')))
              .catch((err) => {
                expect(err.message).to.eq('Transmission must be one single word');
              });
          });

          it('rejects number zero', () => {
            return game.transmit('valid-enough', 0)
              .then(() => Promise.reject(new Error('Should not have accepted transmission')))
              .catch((err) => {
                expect(err.message).to.eq('Number must be whole number greater than zero');
              });
          });

          it('rejects non-whole numbers', () => {
            return game.transmit('valid-enough', 1.45)
              .then(() => Promise.reject(new Error('Should not have accepted transmission')))
              .catch((err) => {
                expect(err.message).to.eq('Number must be whole number greater than zero');
              });
          });

          it('rejects if transmission is more than remaining words for team', () => {
            let gameBoard = game.getDataValue('board'),
                startingTeam = game.board.startingTeam(),
                board = gameBoard.map((tile) => Object.assign({}, tile, { revealed: tile.type === startingTeam }));

            return game.update({ board })
              .then((g) => g.transmit('valid', 3))
              .then(() => Promise.reject(new Error('Should not have accepted transmission')))
              .catch((err) => {
                expect(err.message).to.eq('Number exceeds remaining tiles for team.');
              });
          });
        });
      });

      context('valid', () => {
        beforeEach(() => game.start());

        it('creates Turn with transmission', () => {
          return game.transmit('transmission', 2)
            .then(() => {
              expect(game.turns).to.have.lengthOf(1);
            });
        });

        it('adds to turns and triggers nextTurn', () => {
          let activePlayer = [aTransmitterPlayer, bTransmitterPlayer].find((p) => p.id === game.activePlayerId);
          sinon.stub(game, 'nextTurn');

          return game.transmit('encoded', 2)
            .then(() => {
              let turn = game.turns[0];

              expect(game.turns).to.have.lengthOf(1);
              expect(turn.event).to.eq('transmission');
              expect(turn.playerId).to.eq(activePlayer.id);
              expect(turn.number).to.eq(2);
              expect(turn.word).to.eq('encoded');
              expect(game.nextTurn).to.have.been.called;

              game.nextTurn.restore();
            });
        });
      });
    });

    describe('decode', () => {
      beforeEach(() => game.start());

      context('invalid', () => {
        it('rejects invalid tile coordinates', () => {
          return game.decode(-1, 15)
            .then(() => Promise.reject(new Error('Coordinates should not have been accepted')))
            .catch((err) => {
              expect(err.message).to.eq('No such tile');
            });
        });

        it('rejects already-revealed tiles', () => {
          let i = Math.floor(Math.random() * 25),
              board = game.getDataValue('board');
          board[i].revealed = true;
          return game.update({ board })
            .then(() => game.decode(i))
            .then(() => Promise.reject(new Error('Submission should not have been accepted')))
            .catch((err) => {
              expect(err.message).to.eq('Tile already revealed');
            });
        });

        it('rejects for non-decoder players', () => {
          let i = Math.floor(Math.random() * 25);
          return game.update({ activePlayerId: aTransmitterPlayer.id })
            .then(() => game.decode(i))
            .then(() => Promise.reject('Player should not have been allowed to decoding'))
            .catch((err) => {
              expect(err.message).to.eq('Active Player cannot make guesses');
            });
        });
      });

      context('valid', () => {
        beforeEach(() => {
          let index = Math.round(Math.random());
          return game.update({ activePlayerId: [aDecoderPlayer, bDecoderPlayer][index].id });
        });

        it('changes active player if tile is not of same team', () => {
          let board = game.getDataValue('board'),
              activePlayer = gameHelpers.activePlayer([aTransmitterPlayer, aDecoderPlayer, bTransmitterPlayer, bDecoderPlayer], game),
              index = board.findIndex((tile) => tile.type !== activePlayer.team && tile.type !== 'x');

          return game.decode(index)
            .then(() => {
              expect(game.activePlayerId).not.to.eq(activePlayer.id);
            });
        });

        it('does not change active player if decoding is correct', () => {
          let board = game.getDataValue('board'),
              activePlayer = gameHelpers.activePlayer([aTransmitterPlayer, aDecoderPlayer, bTransmitterPlayer, bDecoderPlayer], game),
              index = board.findIndex((tile) => tile.type === activePlayer.team);

          return game.decode(index)
            .then(() => {
              expect(game.activePlayerId).to.eq(activePlayer.id);
            });
        });

        it('creates a turn for incorrect guesses', () => {
          let board = game.getDataValue('board'),
              activePlayer = gameHelpers.activePlayer([aTransmitterPlayer, aDecoderPlayer, bTransmitterPlayer, bDecoderPlayer], game),
              index = board.findIndex((tile) => tile.type !== activePlayer.team && tile.type !== 'x');

          return game.decode(index)
            .then(() => {
              let turn = game.turns[game.turns.length - 1];

              expect(turn).to.have.property('event', 'decoding');
              expect(turn).to.have.property('playerId', activePlayer.id);
              expect(turn.tile).to.eql(index);
            });
        });

        it('creates a turn for correct guesses', () => {
          let board = game.getDataValue('board'),
              activePlayer = gameHelpers.activePlayer([aTransmitterPlayer, aDecoderPlayer, bTransmitterPlayer, bDecoderPlayer], game),
              index = board.findIndex((tile) => tile.type === activePlayer.team);

          return game.decode(index)
            .then(() => {
              let turn = game.turns[game.turns.length - 1];

              expect(turn).to.have.property('event', 'decoding');
              expect(turn).to.have.property('playerId', activePlayer.id);
              expect(turn.tile).to.eql(index);
            });
        });

        context('victory', () => {
          it('triggers end if tile is x type', () => {
            let board = game.getDataValue('board'),
                activePlayer = gameHelpers.activePlayer([aTransmitterPlayer, aDecoderPlayer, bTransmitterPlayer, bDecoderPlayer], game),
                index = board.findIndex((tile) => tile.type === 'x');
            sinon.spy(game, 'end');

            return game.decode(index)
              .then(() => {
                expect(game.end).to.have.been.calledWith(sinon.match({
                  event: 'decoding',
                  playerId: activePlayer.id,
                  tile: index,
                  correct: false
                }), sinon.match.instanceOf(Player).and(sinon.match.has('id', activePlayer.id)));
                game.end.restore();
              });
          });

          it('triggers end if tile is last for other team', () => {
            let board = game.getDataValue('board'),
                activePlayer = gameHelpers.activePlayer([aTransmitterPlayer, aDecoderPlayer, bTransmitterPlayer, bDecoderPlayer], game),
                otherTeam = activePlayer.team === 'a' ? 'b' : 'a',
                index = board.findIndex((tile) => tile.type === otherTeam);
            sinon.spy(game, 'end');

            board.forEach((tile, i) => {
              if (i === index) return;
              if (tile.type === otherTeam) tile.revealed = true;
            });

            return game.update({ board })
              .then(() => game.decode(index))
              .then(() => {
                expect(game.end).to.have.been.calledWith(sinon.match({
                  event: 'decoding',
                  playerId: activePlayer.id,
                  tile: index,
                  correct: false
                }), sinon.match.instanceOf(Player).and(sinon.match.has('id', activePlayer.id)));
                game.end.restore();
              });
          });

          it('triggers end if tile is last for active player\'s team', () => {
            let board = game.getDataValue('board'),
                activePlayer = gameHelpers.activePlayer([aTransmitterPlayer, aDecoderPlayer, bTransmitterPlayer, bDecoderPlayer], game),
                index = board.findIndex((tile) => tile.type === activePlayer.team);
            sinon.spy(game, 'end');

            board.forEach((tile, i) => {
              if (i === index) return;
              if (tile.type === activePlayer.team) tile.revealed = true;
            });

            return game.update({ board })
              .then(() => game.decode(index))
              .then(() => {
                expect(game.end).to.have.been.calledWith(sinon.match({
                  event: 'decoding',
                  playerId: activePlayer.id,
                  tile: index,
                  correct: true
                }), sinon.match.instanceOf(Player).and(sinon.match.has('id', activePlayer.id)));
                game.end.restore();
              });
          });
        });
      });
    });

    describe('nextTurn', () => {
      context('game not started', () => {
        it('is rejected', () => {
          game.nextTurn()
            .then(() => Promise.reject(new Error('Should not have been accepted')))
            .catch((err) => {
              expect(err.message).to.eq('Game is not started');
            });
        });
      });

      context('game started', () => {
        beforeEach(() => game.start());

        context('from team a', () => {
          it('switches from transmitter to decoder on a', () => {
            return game.update({ activePlayerId: aTransmitterPlayer.id })
              .then(() => game.nextTurn())
              .then(() => {
                expect(game.activePlayerId).to.eq(aDecoderPlayer.id);
              });
          });

          it('switches from decoder to transmitter on b', () => {
            return game.update({ activePlayerId: aDecoderPlayer.id })
              .then(() => game.nextTurn())
              .then(() => {
                expect(game.activePlayerId).to.eq(bTransmitterPlayer.id);
              });
          });
        });

        context('from team b', () => {
          it('switches from transmitter to decoder on b', () => {
            return game.update({ activePlayerId: bTransmitterPlayer.id })
              .then(() => game.nextTurn())
              .then(() => {
                expect(game.activePlayerId).to.eq(bDecoderPlayer.id);
              });
          });

          it('switches from decoder to transmitter on a', () => {
            return game.update({ activePlayerId: bDecoderPlayer.id })
              .then(() => game.nextTurn())
              .then(() => {
                expect(game.activePlayerId).to.eq(aTransmitterPlayer.id);
              });
          });
        });
      });
    });

    describe('end', () => {
      it('creates an end event', () => {
        return game.end({ event: 'turn', playerId: bDecoderPlayer.id, correct: false }, bDecoderPlayer)
          .then(() => {
            let endTurn = game.turns[game.turns.length - 1];
            expect(endTurn).to.have.property('event', 'end');
          });
      });

      it('sets the winner to the active player if the last decoding was correct', () => {
        return game.end({ event: 'turn', playerId: aDecoderPlayer.id, correct: true }, aDecoderPlayer)
          .then(() => {
            let endTurn = game.turns[game.turns.length - 1];
            expect(endTurn).to.have.property('winner', 'a');
          });
      });

      it('sets the winner to the other team if the last decoding was not correct', () => {
        return game.end({ event: 'turn', playerId: aDecoderPlayer.id, correct: false }, aDecoderPlayer)
          .then(() => {
            let endTurn = game.turns[game.turns.length - 1];
            expect(endTurn).to.have.property('winner', 'b');
          });
      });
    });

    describe('completed', () => {
      it('returns false for non-started games', () => {
        expect(game.completed()).to.be.false;
      });

      it('returns false for games in progress', () => {
        return game.update({ turns: [
          { event: 'transmission', playerId: aTransmitterPlayer.id, transmission: { number: 2, word: 'transmission' } },
          { event: 'decoding', playerId: aDecoderPlayer.id, tile: 7, correct: false },
          { event: 'transmission', playerId: bTransmitterPlayer.id, transmission: { number: 2, word: 'flarb' } }
        ], activePlayerId: bDecoderPlayer.id }).then(() => {
          expect(game.completed()).to.be.false;
        });
      });

      it('returns true for finished games', () => {
        return game.update({ turns: [
          { event: 'end', winner: 'a' }
        ]}).then(() => {
          expect(game.completed()).to.be.true;
        });
      });
    });
  });

  describe('delete', () => {
    context('deleting', () => {
      let game, user, player;

      beforeEach(() => {
        return Promise.all([
          Game.create(),
          User.create({ username: 'my-user', password: 'my-password' })
        ])
          .then(([g, u]) => {
            game = g;
            user = u;
            return Player.create({ userId: user.id, gameId: game.id });
          })
          .then((p) => { player = p; });
      });

      afterEach(() => helper.cleanDatabase());

      it('marks the record as deleted without destroying it', () => {
        return game.delete()
          .then(() => {
            return Game.unscoped().findAll({ where: { id: game.id } });
          })
          .then((g) => {
            expect(g).not.to.be.null;
            expect(g.deleted_at).not.to.be.null;
          });
      });

      it('marks associated players as deleted', () => {
        return game.delete()
          .then(() => {
            return Player.unscoped().findAll({ where: { gameId: game.id } });
          })
          .then((players) => {
            expect(players.length).to.eq(1);
            expect(players[0].id).to.eq(player.id);
            expect(players[0].deletedAt).not.to.be.null;
          });
      });
    });

    context('deleted behavior', () => {
      let game1, game2, game3;

      beforeEach(() => {
        return Promise.all([
          Game.create(),
          Game.create(),
          Game.create()
        ])
          .then((games) => {
            [game1, game2, game3] = games;

            return game2.delete();
          });
      });

      afterEach(() => helper.cleanDatabase());

      it('does not return deleted games', () => {
        return Game.findAll()
          .then((games) => {
            let gameIds = games.map((g) => g.id);
            expect(gameIds).not.to.include(game2.id);
            expect(gameIds).to.have.members([game.id, game1.id, game3.id]);
          });
      });

      it('is available through deleted scope', () => {
        return Game.scope('deleted').findAll()
          .then((games) => {
            expect(games[0].id).to.eq(game2.id);
          });
      });
    });
  });
});