С логикой игры закончили. Теперь перейдём к представлению и работе с DOM. Тесты для этого напишем в другом файле.
Нам потребуется JSDOM, чтобы создать окружение без браузера. Настроим структуру страницы по умолчанию и объявим глобально window и document.
Затем создадим класс DomController, метод createTable которого будет создавать пустую таблицу. Существование этой таблицы мы и будем проверять. При создании укажем, к какой ноде должен привязываться этот класс.
Сейчас тест будет красным, так как таблица не создаётся. Приступаем к реализации.
test/dom.jsimport { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals'; import jsdom from 'jsdom' import Game from '../src/Game' const {JSDOM} = jsdom const dom = new JSDOM('<html><body id="root"></body></html>') global.window = dom.window global.document = dom.window.document describe('DOM controller', () => { test('Creates empty table', () => { const domController = new DomController('#root') domController.createTable() expect(document.querySelectorAll('table').length).toBe(1) }) }) class DomController { constructor(root) {} createTable() {} }
В конструкторе привяжем класс к переданной ноде и сохраним её в rootNode. Всю работу ограничим внутри неё.
Внутри createTable создадим пустую таблицу и добавим её внутрь rootNode.
Проверим, что тест проходит, и начнём рефакторить.
test/dom.jsclass DomController { constructor(root) { + this.rootNode = document.querySelector(root) } createTable() { + const child = document.createElement('table') + this.rootNode.appendChild(child) } }
Вынесем класс DomController в отдельный файл.
Добавим импорт в файл тестов и вынесем создание экземпляра DomController в функцию createInstance.
src/DomController.jsclass DomController { constructor(root) { this.rootNode = document.querySelector(root) } createTable() { const child = document.createElement('table') this.rootNode.appendChild(child) } } export default DomController
test/dom.js+ const createInstance = () => new DomController('#root') describe('DOM controller', () => { test('Creates empty table', () => { - const domController = new DomController('#root') + const domController = createInstance() domController.createTable() expect(document.querySelectorAll('table').length).toBe(1) }) })
Добавим в метод количество строк и столбцов, которое должно быть в таблице.
По задумке тест должен сказать, что у нас вместо трёх строк не найдено ни одной. Но он скажет, что у нас вместо одной таблицы — две.
Таблица, созданная в первом тесте, никуда не пропала. Мы помним, что тесты должны быть независимыми, поэтому после каждого теста в afterEach мы будем очищать страницу от всех созданных элементов.
Теперь тест падает с задуманной причиной, и можно переходить к реализации.
test/dom.jsafterEach(() => { document.body.innerHTML = '' }) // ... test('Creates table with 3 rows and 3 columns', () => { const domController = createInstance() domController.createTable(3, 3) expect(document.querySelectorAll('table').length).toBe(1) expect(document.querySelectorAll('tr').length).toBe(3) expect(document.querySelectorAll('td').length).toBe(9) })
Воспользуемся методами insertRow и insertCell, чтобы наполнить таблицу нужным количеством строк и ячеек.
Количество будем брать из аргументов. По умолчанию будем считать, что количество равно 0.
Тест проходит. Смотрим, нужно ли что‑то отрефакторить. Пока что всё выглядит хорошо, поэтому рефакторинг можем пропустить.
src/DomController.jscreateTable(rows=0, cols=0) { const child = document.createElement('table') this.rootNode.appendChild(child) const table = this.rootNode.querySelector('table') for (let i = 0; i < rows; i++) { const row = table.insertRow(i) for (let j = 0; j < cols; j++) { const cell = row.insertCell(j) } } }
Мы хотим, чтобы при клике на клетку там появлялся крестик. Это объёмная задача, поэтому сперва нам нужно научиться просто отлавливать клики по клетке.
Как нам проверить, что пользователь кликнул на первую клетку, а контроллер правильно это обработал? Так как мы разрабатываем сперва тест, мы можем использовать особые поля специально для проверки.
В нашем случае у контроллера будет поле lastClickedIndices, которое будет меняться при клике на клетку. В это поле мы будем записывать адрес последней клетки, по которой кликнул пользователь. В тесте останется сравнить значение с ожидаемым.
test/dom.jstest('Remembers indices of last clicked cell', () => { const domController = createInstance() domController.createTable(3, 3) document.querySelector('table td').click() expect(domController.lastClickedIndices).toEqual([0, 0]) })
src/DomController.jsfor (let j = 0; j < cols; j++) { const cell = row.insertCell(j) + cell.addEventListener('click', () => { + this.lastClickedIndices = [i, j] + }) }
Вынесем обработку клика в отдельный метод _handleCellClick.
Проверяем, что после рефакторинга тесты не сломались.
src/DomController.jsfor (let j = 0; j < cols; j++) { const cell = row.insertCell(j) - cell.addEventListener('click', () => { - this.lastClickedIndices = [i, j] - }) + cell.addEventListener('click', this._handleCellClick.bind(this, i, j)) } } } + + _handleCellClick(row, col) { + this.lastClickedIndices = [row, col] + }
Теперь привяжем клик к игре. Сделаем, чтобы при клике на клетку в игре проставлялся крестик в соответствующей клетке доски.
У нас есть метод acceptUserMove у класса Game, который отвечает за обработку хода игрока. Проверим, что при клике на ячейку таблицы вызывается этот метод.
Подменим метод acceptUserMove в модели игры на фиктивную функцию. Далее свяжем контроллер с моделью и проверим, был ли вызван подменённый метод.
Для этого изменим конструктор контроллера, чтобы он принимал не только селектор, но ещё и модель игры, к которой мы его привязываем.
При клике будем вызывать метод acceptUserMove, передавая в него индексы строки и колонки, по которым кликнули.
test/dom.js// ... const createInstance = (game={}) => { return new DomController({ game: game, root: '#root' }) } // ... test('Makes user move in game on cell click', () => { const gameMock = { acceptUserMove: jest.fn() } const domController = createInstance(gameMock) domController.createTable(3, 3) document.querySelector('table td').click() expect(domController.game.acceptUserMove).toHaveBeenCalled() })
src/DomController.jsclass DomController { - constructor(root) { + constructor({root, game}) { + this.game = game this.rootNode = document.querySelector(root) this.lastClickedIndices = [-1, -1] } } // ... _handleCellClick(row, col) { this.lastClickedIndices = [row, col] + this.game.acceptUserMove(row, col) }
Конкретную реализацию обработки хода, которая зависит от модели игры, вынесем во внутренний метод _makeUserMove.
Вспоминаем, что при клике на занятую клетку у нас появится ошибка. Поэтому используем try-catch, чтобы ловить их.
src/DomController.js_handleCellClick(row, col) { this.lastClickedIndices = [row, col] - this.game.acceptUserMove(row, col) + try { + this._makeUserMove(row, col) + } + catch(e) { + window.alert(e.message) + } + } + + _makeUserMove(row, col) { + this.game.acceptUserMove(row, col) + }