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);
+ });
+});