Я пропущу создание и тестирование метода init у контроллера. Этот метод запрашивает у модели игры размер доски и строит таблицу по нему. Исходный код и тесты этого метода и сопутствующих ему, можно посмотреть на гитхабе.
Сейчас перейдём к проверке ситуации, когда пользователь кликнул на занятую клетку. Пусть в этот момент будет выскакивать alert с сообщением, что клетка уже занята.
Проверить это можно с помощью шпиона (spy), который будет следить за вызовами alert. Если alert был вызван, то метод шпиона toHaveBeenCalled вернёт истину.
Так как мы использовали try-catch ошибка, полученная от acceptUserMove вызывает alert внутри _handleCellClick класса DomController. Тест автоматически пройден.
Также сразу позаботимся об очистке шпиона. В начале каждого теста он должен быть свежим, будто его ни разу не вызывали. Для этого будем сбрасывать его состояние после каждого теста в afterEach. После прохождения же всех тестов в afterAll восстановим метод window.alert, полностью очистив его от шпиона.
test/dom.jsconst createGame = (board) => new Game(board) // ... beforeEach(() => { window.alert = jest.fn() }) afterEach(() => { // ... window.alert.mockReset() }) afterAll(() => { window.alert.mockRestore() }) // ... test('Gets an alert when user makes move in taken cell', () => { const game = createGame() const domController = createInstance(game) domController.init() document.querySelector('table td').click() document.querySelector('table td').click() expect(window.alert).toHaveBeenCalled() })
Мы научились передавать модели событие клика. Теперь нужно научиться отображать изменения в таблице.
После обновления модели будем спрашивать у неё новое состояние и перерисовывать таблицу.
test/dom.jstest('Redraws table on cell click', () => { const game = createGame() const domController = createInstance(game) domController.init() document.querySelector('table td').click() const text = document.querySelector('table td').textContent expect(text).toEqual('×') })
scr/DomController.js_makeUserMove(row, col) { this.game.acceptUserMove(row, col) + const board = this.game.getState() + const table = this.rootNode.querySelector('table') + + board.forEach((row, i) => { + row.forEach((col, j) => { + table + .querySelector(`tr:nth-child(${i+1}) td:nth-child(${j+1})`) + .innerHTML = col + }) + }) }
Так как перерисовка таблицы может нам понадобиться и в других ситуациях, вынесем её в метод _redraw.
scr/DomController.js_makeUserMove(row, col) { this.game.acceptUserMove(row, col) + this._redraw() + } + + _redraw() { const board = this.game.getState() const table = this.rootNode.querySelector('table')
Пусть компьютер ходит сразу после хода пользователя.
Даже если хочется, чтобы компьютер «подумал» перед ходом, нам следует двигаться небольшими шагами и разбивать задачи на подзадачи. Не забываем, что цикл разработки в TDD — 10–15 минут.
test/dom.jstest('Makes computer move right after users move', () => { const game = createGame() const domController = createInstance(game) domController.init() document.querySelector('table td').click() const text = document.querySelector('table').textContent expect(text.indexOf('o') > -1).toBe(true) })
Обновим метод _handleCellClick и добавим в него запуск хода компьютера.
Запускаем тесты, чтобы убедиться, что всё работает.
src/DomController.jstry { this._makeUserMove(row, col) + + this.game.createComputerMove() + this._redraw() }
Вынесем его так же в отдельный метод _makeComputerMove и будем вызывать его.
После рефакторинга снова запускаем тесты.
src/DomController.jstry { this._makeUserMove(row, col) - - this.game.createComputerMove() - this._redraw() + this._makeComputerMove() } + _makeComputerMove() { + this.game.createComputerMove() + this._redraw() + }
Если пользователь или компьютер побеждает в игре, покажем это.
Нам нужно сымитировать ситуацию, когда пользователь находится в шаге от победы и ходит в выигрышную клетку.
Создадим игру, в которую передадим указанную доску. Для этого подправим класс Game так, чтобы конструктор принимал параметр board. Если он указан, то будет использоваться как начальное состояние.
test/dom.jstest('Creates status text below table if someone wins', () => { const game = createGame([ ['×', '×', ''], ['', '', ''], ['', '', ''] ]) const domController = createInstance(game) domController.init() document.querySelector('table tr:nth-child(1) td:nth-child(3)').click() const status = document.querySelector('#status') expect(status.textContent).toEqual('user won!') })
src/DomController.jsthis._makeUserMove(row, col) + const state = this.game.checkGame() + if (state !== 'continue') { + const node = document.createElement('div') + const txt = document.createTextNode(state) + node.id = 'status' + node.appendChild(txt) + this.rootNode.appendChild(node) + } this._makeComputerMove()
Вынесем создание элемента в метод _createNode, а проверку состояния игры — в метод _checkContinue.
После хода пользователя или компьютера будем проверять, продолжается ли игра в методе _checkContinue. Если нет, то создаём поздравление победителю в методе _createNode и добавляем его в корневую ноду.
src/DomController.js_handleCellClick(row, col) { this.lastClickedIndices = [row, col] try { this._makeUserMove(row, col) const continues = this._checkContinue() if (!continues) return this._makeComputerMove() this._checkContinue() } catch(e) { window.alert(e.message) } } // ... _checkContinue() { const state = this.game.checkGame() if (state !== 'continue') { const status = this._createNode('div', { text: state, id: 'status' }) this.rootNode.appendChild(status) return false } return true } _createNode(tag, config={}) { const {text, id} = config const node = document.createElement(tag) const txt = document.createTextNode(text) node.appendChild(txt) if (!!id) node.id = id return node }
Теперь осталось добавить проверку на победу компьютера и ничью, затем написать метод для перезапуска игры. Проверить тесты на запахи и отрефакторить код, написать тесты на крайние ситуации и отказные случаи, вынести повторяющиеся константы (initialGameBoard, userMoveSymbol и т. д.) в отдельный файл, из которого потом импортировать их по мере надобности.
Подробно останавливаться на этом сейчас не будем, результат изменений можно посмотреть в исходном коде.
Перейдём к проверке игры в браузере. Создадим ХТМЛ‑страницу с подключением скрипта, из которого запустим игру.
Здесь пригодится сборка, которую мы настроили ранее. Запускаем в терминале npm run build. Вебпак соберёт игру в один бандл, который можно подключать к странице.
src/index.jsimport DomController from './DomController.js' import Game from './Game.js' const game = new Game() const dom = new DomController({ root: 'body', game }) dom.init()