Возвращаемся к ходу компьютера 🔗

Допустим, текущая реализация истории нас устраивает. Можем вернуться к разработке хода компьютера.

Компьютер должен ходить в случайно выбранную клетку. Но как протестировать случайность?

Мы можем изменить поведение Math.random так, чтобы внутри теста он возвращал заранее известное значение. Также позаботимся, чтобы после завершения теста Math.random работал как обычно.

Подберём такое число, чтобы компьютер ходил в центр доски.

Тест падает, так как компьютер ходит в левую верхнюю клетку.

test/game.js
test('Computer moves in randomly chosen cell', () => { const userMoveSymbol = '×' const computerMoveSymbol = 'o' // ... const mock = jest.spyOn(global.Math, 'random').mockReturnValue(0.5) game.createComputerMove() const board = game.getState() expect(board[1][1]).toEqual(computerMoveSymbol) mock.mockRestore() })

Так как наше поле имеет размер 3×3, случайная координата должна быть ≥0 и ≤2. Пишем получение случайной координаты с использованием Math.random.

Новый тест проходит, но посыпались два других.

Первый упавший тест проверял, что компьютер должен сходить в левую верхнюю клетку. Этот тест устарел, поэтому его можно удалить.

Второй же тест проверяет, что игра записывает ход компьютера в историю. Здесь у нас есть два варианта действий. Мы можем так же заменить Math.random на фиктивную функцию и тогда клетка будет всегда такой, которую мы укажем.

Либо мы можем проверять только поле turn, чтобы оно соответствовало имени компьютера.

Воспользуемся первым вариантом.

src/Game.js
createComputerMove() { + const x = Math.floor(Math.random() * (3 - 0)) + const y = Math.floor(Math.random() * (3 - 0)) + - this._updateHistory(this._computerName, 0, 0) - this._updateBoard(0, 0, { + this._updateHistory(this._computerName, x, y) + this._updateBoard(x, y, { symbol: this._computerMoveSymbol }) }
test/game.js
- test('Computer moves in top left cell', () => { - game.createComputerMove() - const board = game.getState() - - expect(board[0][0]).toEqual(computerMoveSymbol) - }) // ... test('Game saves computer\'s move in history', () => { + const mock = jest.spyOn(global.Math, 'random').mockReturnValue(0.5) game.createComputerMove() const history = game.getMoveHistory() - expect(history).toEqual([{turn: computerName, x: 0, y: 0}]) + expect(history).toEqual([{turn: computerName, x: 1, y: 1}]) + mock.mockRestore() })

Рефакторинг начнём с выноса повторяющегося кода.

Создадим метод для получения случайной координаты _getRandomCoordinate. Размер поля вынесем в константу _fieldSize.

Запускаем тесты и проверяем.

src/Game.js
this._computerMoveSymbol = 'o' + this._fieldSize = 3 // ... createComputerMove() { - const x = Math.floor(Math.random() * (3 - 0)) - const y = Math.floor(Math.random() * (3 - 0)) + const x = this._getRandomCoordinate() + const y = this._getRandomCoordinate() + this._updateHistory(this._computerName, x, y) // ... + _getRandomCoordinate() { + return Math.floor(Math.random() * (this._fieldSize - 0)) + }

Заставляем компьютер пойти в последнюю клетку 🔗

Так как мы хотим, чтобы компьютер ходил случайно, проверим, чтобы он не мог пойти в уже занятую клетку.

Напишем тест, который вынудит компьютер занять заранее определённую клетку.

test/game.js
test('Computer moves in cell that is not taken', () => { // fill all the cells with user's symbol except last for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (i !== 2 || j !== 2) game.acceptUserMove(i, j) } } game.createComputerMove() const board = game.getState() const userCount = board.reduce((result, row) => { return row.reduce((count, el) => { return el === userMoveSymbol ? ++count : count }, result) }, 0) const computerCount = board.reduce((result, row) => { return row.reduce((count, el) => { return el === computerMoveSymbol ? ++count : count }, result) }, 0) expect(userCount).toBe(8) expect(computerCount).toBe(1) expect(board[2][2]).toEqual(computerMoveSymbol) })

Чтобы компьютер точно занял только свободную клетку, нам нужно отсеивать все занятые.

Так как ходы компьютера случайны, будем использовать цикл while, чтобы рано или поздно найти свободную клетку.

Чтобы цикл не стал бесконечным, проверим, есть ли свободные клетки вообще.

src/Game.js
createComputerMove() { - const x = this._getRandomCoordinate() - const y = this._getRandomCoordinate() + const freeCells = this._board.reduce((total, row) => + row.reduce((count, el) => + el === '' ? ++count : count, total), 0) + + if (!freeCells) return + + let x = this._getRandomCoordinate() + let y = this._getRandomCoordinate() + + while (!!this._board[x][y]) { + x = this._getRandomCoordinate() + y = this._getRandomCoordinate() + }

Рефакторить придётся много. В тестах повторяется подсчёт крестиков и ноликов на доске. Стоит вынести эти действия в функцию.

Наполнение доски крестиками тоже вынесем в функцию, чтобы сделать код теста более читаемым.

test/game.js
const fillCells = game => { for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (i !== 2 || j !== 2) game.acceptUserMove(i, j) } } } const count = (arr, symbol) => arr.reduce((result, row) => { return row.reduce((count, el) => { return el === symbol ? ++count : count }, result) }, 0) // ... test('Computer moves in cell that is not taken', () => { fillCells(game) game.createComputerMove() const board = game.getState() expect(count(board, userMoveSymbol)).toBe(8) expect(count(board, computerMoveSymbol)).toBe(1) expect(board[2][2]).toEqual(computerMoveSymbol) })

Подсчёт количества пустых клеток и получение случайных координат вынесем в методы _getFreeRandomCoordinates и _getFreeCellsCount. Они могут нам пригодиться дальше.

src/Game.js
createComputerMove() { if (this._getFreeCellsCount() === 0) return false const [x, y] = this._getFreeRandomCoordinates() this._updateHistory(this._computerName, x, y) this._updateBoard(x, y, { symbol: this._computerMoveSymbol }) } // ... _getFreeRandomCoordinates() { let x = this._getRandomCoordinate() let y = this._getRandomCoordinate() while (!!this._board[x][y]) { x = this._getRandomCoordinate() y = this._getRandomCoordinate() } return [x, y] } _getFreeCellsCount() { return this._board.reduce((total, row) => row.reduce((count, el) => el === '' ? ++count : count, total), 0) }

Заставляем компьютер говорить об ошибке 🔗

Мы учли ситуацию с бесконечным циклом, но просто умолчать о ней было бы плохо. Пусть компьютер выбрасывает исключение, если во время его хода не осталось ни одной свободной клетки.

Возможно, в будущем мы поменяем это поведение. Но сейчас такая ситуация кажется нестандартной, поэтому лучше обработать её как ошибку.

test/game.js
test('If there are no free cells computer throws an exception', () => { // fill all the cells for (let i = 0; i < 3; ++i) { for (let j = 0; j < 3; ++j) { game.acceptUserMove(i, j) } } const func = game.createComputerMove.bind(game) expect(func).toThrow('no cells available') })

Здесь пригодится метод _throwException, который мы определяли ранее.

Допишем условие, которое определяет отсутствие пустых клеток, добавив туда передачу ошибок.

src/Game.js
createComputerMove() { - if (this._getFreeCellsCount() === 0) return false + if (this._getFreeCellsCount() === 0) { + return this._throwException('no cells available') + }

В новом тесте есть повторяющийся кусок с наполнением доски крестиками. Мы изменим функцию fillCells, добавив конфиг с координатой клетки, которую надо оставить пустой. Если конфиг не будет передан, то будем считать, что надо заполнить все клетки.

Не забываем обновить тест, который уже использовал fillCells. Добавляем ему конфиг с координатами клетки, которую хотим оставить пустой.

В новом тесте заполняем все клетки, поэтому вызываем функцию без конфига.

После изменений проверяем, не покраснели ли тесты.

test/game.js
- const fillCells = game => { + const fillCells = (game, config={}) => { + const { x=-1, y=-1 } = config // ... for (let j = 0; j < 3; j++) { - if (i !== 2 || j !== 2) game.acceptUserMove(i, j) + if (i !== x || j !== y) game.acceptUserMove(i, j) } // ... test('Computer moves in cell that is not taken', () => { - fillCells(game) + fillCells(game, {x: 2, y: 2}) // ... test('If there are no free cells computer throws an exception', () => { - // fill all the cells - for (let i = 0; i < 3; ++i) { - for (let j = 0; j < 3; ++j) { - game.acceptUserMove(i, j) - } - } + fillCells(game)