diff --git a/.eslintrc.js b/.eslintrc.js index 8c32e38..78d9178 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -48,6 +48,10 @@ module.exports = { 'no-underscore-dangle': 0, 'no-unused-vars': 0, 'object-curly-newline': 0, + 'import/no-extraneous-dependencies': [ + 'error', + { devDependencies: ['**/*.spec.ts', '**/*.spec.tsx', '**/*.spec.js', '**/*.spec.jsx'] }, + ], '@typescript-eslint/no-unused-vars': [ 'error', { diff --git a/package.json b/package.json index 0002717..8df4b05 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "devDependencies": { "@types/jest": "^24.0.18", "@types/react-redux": "^7.1.4", + "@types/react-test-renderer": "^16.9.1", "@types/redux": "^3.6.0", + "@types/redux-mock-store": "^1.0.1", "@types/styled-components": "^4.1.19", "@typescript-eslint/eslint-plugin": "^2.3.3", "@typescript-eslint/parser": "^2.3.3", @@ -56,6 +58,8 @@ "eslint-plugin-react": "^7.16.0", "eslint-plugin-react-hooks": "^1.7.0", "jest": "^24.9.0", - "pre-commit": "^1.2.2" + "pre-commit": "^1.2.2", + "react-test-renderer": "^16.10.2", + "redux-mock-store": "^1.5.3" } } diff --git a/src/App.spec.tsx b/src/App.spec.tsx new file mode 100644 index 0000000..b5cb38f --- /dev/null +++ b/src/App.spec.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import configureStore from 'redux-mock-store'; +import { Middleware, Store } from 'redux'; +import { Provider } from 'react-redux'; +import renderer from 'react-test-renderer'; +import { GameState, actions } from './redux/game'; +import { Player, undetermined, tie } from './lib/Player'; +import { createEmptyBoard } from './lib/Board'; +import App from './App'; + +const middlewares: Middleware[] = []; +const mockStore = configureStore(middlewares); + +const WrappedApp = (store: Store): React.ReactElement => ( + + + +); + +describe('App component', () => { + it('The header must show the current player and winner', () => { + const initialState: GameState = { + board: createEmptyBoard(), + activeGame: false, + winner: undetermined, + currentPlayer: Player.X, + }; + + /* Test with first player / winner combination */ + let store = mockStore(initialState); + let component = renderer.create(WrappedApp(store)); + let h1 = component.root.findByType('h1'); + expect(h1.children.join('')).toEqual('X - undetermined'); + + /* Test with second player / winner combination */ + store = mockStore({ ...initialState, winner: tie, currentPlayer: Player.O }); + component = renderer.create(WrappedApp(store)); + h1 = component.root.findByType('h1'); + expect(h1.children.join('')).toEqual('O - tie'); + }); + it('must call the new game action when the start game button is clicked', () => { + const initialState: GameState = { + board: createEmptyBoard(), + activeGame: false, + winner: undetermined, + currentPlayer: Player.X, + }; + const store = mockStore(initialState); + const component = renderer.create(WrappedApp(store)); + + /* Click the new game button */ + component.root.findByType('button').props.onClick(); + + const calledActions = store.getActions(); + expect(calledActions.length).toEqual(1); + expect(calledActions[0]).toEqual(actions.startGame()); + }); +}); diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index e642fd5..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; - -it.skip('renders without crashing', () => { - const div = document.createElement('div'); - ReactDOM.render(, div); - ReactDOM.unmountComponentAtNode(div); -}); diff --git a/src/components/Board.spec.tsx b/src/components/Board.spec.tsx index 7861f7f..cb3e05e 100644 --- a/src/components/Board.spec.tsx +++ b/src/components/Board.spec.tsx @@ -1,6 +1,59 @@ -describe('My Connected React-Redux Component', () => { - it('should render with given state from Redux store', () => { +import React from 'react'; +import configureStore from 'redux-mock-store'; +import { Middleware, Store } from 'redux'; +import { Provider } from 'react-redux'; +import renderer from 'react-test-renderer'; +import { GameState, actions } from '../redux/game'; +import { Player, undetermined } from '../lib/Player'; +import { createEmptyBoard, empty } from '../lib/Board'; +import Board from './Board'; + +const middlewares: Middleware[] = []; +const mockStore = configureStore(middlewares); + +const WrappedBoard = (store: Store): React.ReactElement => ( + + + +); + +describe('Board component', () => { + it('must call the MAKE_MOVE action when a cell is clicked and the game is active', () => { + const initialState: GameState = { + board: createEmptyBoard(), + activeGame: false, + winner: undetermined, + currentPlayer: Player.X, + }; + const store = mockStore(initialState); + const component = renderer.create(WrappedBoard(store)); + + /* Click on cell 8 (row 2, cell 2) */ + component.root.findAllByType('td')[8].props.onClick(); + + const calledActions = store.getActions(); + expect(calledActions.length).toEqual(1); + expect(calledActions[0]).toEqual(actions.makeMove(2, 2, Player.X)); }); - it('should dispatch an action on button click', () => { + it('must render the board according to the redux state', () => { + const board = [[Player.X, Player.O, Player.X], [Player.O, Player.X, Player.O], [empty, Player.X, Player.O]]; + const initialState: GameState = { + board, + activeGame: false, + winner: undetermined, + currentPlayer: Player.X, + }; + const store = mockStore(initialState); + const component = renderer.create(WrappedBoard(store)); + + /* Get all the table cells */ + const tds = component.root.findAllByType('td'); + + /* Check all the table cells against the values of the board */ + board + .reduce((cells, row) => cells.concat(row), []) + .forEach((cell, index) => { + expect(tds[index].children[0]).toEqual(cell === empty ? '' : cell); + }); }); }); diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts index 6bb2a2e..c993fbb 100644 --- a/src/lib/scoring.ts +++ b/src/lib/scoring.ts @@ -87,7 +87,7 @@ export const getBoardSummary = (board: Board): BoardSummary => { } else if (result.filter((lineResult) => lineResult === Player.O).length > 0) { summary.winner = Player.O; } else { - summary.winner = undetermined; + throw new Error('Unsupported scoring case'); } return summary; }; diff --git a/src/redux/game.spec.ts b/src/redux/game.spec.ts index 80d304c..b2d5a5d 100644 --- a/src/redux/game.spec.ts +++ b/src/redux/game.spec.ts @@ -1,4 +1,5 @@ -import { reducer, GameState, actions } from './game'; +import { Action, Dispatch, MiddlewareAPI } from 'redux'; +import { reducer, GameState, actions, middleware } from './game'; import { Player, tie, undetermined } from '../lib/Player'; import { empty, createEmptyBoard } from '../lib/Board'; import { getBoardSummary, BoardSummary } from '../lib/scoring'; @@ -126,4 +127,69 @@ describe('reducer with name', () => { expect(updatedState).toEqual({ ...state, winner: Player.X }); }); }); + describe('MAKE_MOVE', () => { + it('should update the board, check if there is a winner and switch to the next player', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: true, + board: [], + winner: undetermined, + }; + const makeMoveAction = actions.makeMove(1, 2, Player.O); + const dispatch = jest.fn(); + + const middlewareAPI: MiddlewareAPI = { + dispatch, + getState: (): GameState => state, + }; + const next = ((action: Action): Action => action) as Dispatch; + middleware(middlewareAPI)(next)(makeMoveAction); + + expect(dispatch.mock.calls.length).toEqual(3); + expect(dispatch.mock.calls[0][0]).toEqual(actions.updateBoard(1, 2, Player.O)); + expect(dispatch.mock.calls[1][0]).toEqual(actions.determineWinner()); + expect(dispatch.mock.calls[2][0]).toEqual(actions.nextPlayer()); + }); + it('should update the board, check if there is a winner and if there is a winner end the game', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: true, + board: [], + winner: Player.O, + }; + const makeMoveAction = actions.makeMove(1, 2, Player.O); + const dispatch = jest.fn(); + + const middlewareAPI: MiddlewareAPI = { + dispatch, + getState: (): GameState => state, + }; + const next = ((action: Action): Action => action) as Dispatch; + middleware(middlewareAPI)(next)(makeMoveAction); + + expect(dispatch.mock.calls.length).toEqual(3); + expect(dispatch.mock.calls[0][0]).toEqual(actions.updateBoard(1, 2, Player.O)); + expect(dispatch.mock.calls[1][0]).toEqual(actions.determineWinner()); + expect(dispatch.mock.calls[2][0]).toEqual(actions.endGame()); + }); + it('should not do anything if the game is not active', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: false, + board: [], + winner: undetermined, + }; + const makeMoveAction = actions.makeMove(1, 2, Player.O); + const dispatch = jest.fn(); + + const middlewareAPI: MiddlewareAPI = { + dispatch, + getState: (): GameState => state, + }; + const next = ((action: Action): Action => action) as Dispatch; + middleware(middlewareAPI)(next)(makeMoveAction); + + expect(dispatch.mock.calls.length).toEqual(0); + }); + }); }); diff --git a/src/redux/game.ts b/src/redux/game.ts index fcc1e19..3631700 100644 --- a/src/redux/game.ts +++ b/src/redux/game.ts @@ -52,14 +52,14 @@ export interface GameState { export type IGameState = GameState; -const defaultState: IGameState = { +export const initialState: IGameState = { currentPlayer: Player.X, activeGame: false, board: createEmptyBoard(), winner: undetermined, }; -export const reducer: Reducer = (state: IGameState = defaultState, action: Action): IGameState => { +export const reducer: Reducer = (state: IGameState = initialState, action: Action): IGameState => { if (action.type === START_GAME) { return { ...state, board: createEmptyBoard(), activeGame: true, winner: undetermined, currentPlayer: Player.X }; } @@ -109,11 +109,12 @@ export const middleware = ({ dispatch, getState }: MiddlewareAPI { + it('must return the correct initial state', () => { + const store = createStore(); + expect(store.getState()).toEqual(initialState); + }); +});