diff --git a/package.json b/package.json index 590c6ae..0002717 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.1.0", "private": true, "dependencies": { - "@types/jest": "24.0.18", "@types/node": "12.7.12", "@types/react": "16.9.5", "@types/react-dom": "16.9.1", @@ -19,8 +18,15 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", - "eject": "react-scripts eject" + "test-nowatch": "CI=true react-scripts test --env=jsdom", + "eject": "react-scripts eject", + "lint": "eslint --ext .js,.tsx,.ts,.tsx src/" }, + "pre-commit": [ + "lint", + "test-nowatch", + "build" + ], "eslintConfig": { "extends": "react-app" }, @@ -37,6 +43,7 @@ ] }, "devDependencies": { + "@types/jest": "^24.0.18", "@types/react-redux": "^7.1.4", "@types/redux": "^3.6.0", "@types/styled-components": "^4.1.19", @@ -48,6 +55,7 @@ "eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-react": "^7.16.0", "eslint-plugin-react-hooks": "^1.7.0", - "jest": "^24.9.0" + "jest": "^24.9.0", + "pre-commit": "^1.2.2" } } diff --git a/src/App.tsx b/src/App.tsx index f1aeb30..8b7cdaf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ const StyledApp = styled.div` interface StateProps { currentPlayer: Player; + winner: Winner; } interface ActionProps { @@ -29,10 +30,12 @@ interface ActionProps { type Props = StateProps & ActionProps; -const App: React.FC = ({ currentPlayer, startGame }) => ( +const App: React.FC = ({ currentPlayer, startGame, winner }) => ( -

{currentPlayer}

+

+ {currentPlayer} - {winner} +

@@ -42,6 +45,7 @@ const App: React.FC = ({ currentPlayer, startGame }) => ( const mapStateToProps = (state: GameState): StateProps => ({ currentPlayer: state.currentPlayer, + winner: state.winner, }); const mapActionsToProps = { diff --git a/src/components/Board.spec.tsx b/src/components/Board.spec.tsx new file mode 100644 index 0000000..7861f7f --- /dev/null +++ b/src/components/Board.spec.tsx @@ -0,0 +1,6 @@ +describe('My Connected React-Redux Component', () => { + it('should render with given state from Redux store', () => { + }); + it('should dispatch an action on button click', () => { + }); +}); diff --git a/src/components/Cell.spec.tsx b/src/components/Cell.spec.tsx new file mode 100644 index 0000000..dc0f00a --- /dev/null +++ b/src/components/Cell.spec.tsx @@ -0,0 +1,4 @@ +describe('My Connected React-Redux Component', () => { + it('should render with given state from Redux store', () => {}); + it('should dispatch an action on button click', () => {}); +}); diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts index b19423d..6bb2a2e 100644 --- a/src/lib/scoring.ts +++ b/src/lib/scoring.ts @@ -1,7 +1,7 @@ import { Board, Cell, Cells, empty, Rows } from './Board'; import { Player, Winner, tie, undetermined } from './Player'; -export interface IBoardSummary { +export interface BoardSummary { horizontal: Rows; vertical: Rows; diagonal: Rows; @@ -52,8 +52,8 @@ export const getWinnerFromLines = (lines: Rows): Winner => { return undetermined; }; -export const getBoardSummary = (board: Board): IBoardSummary => { - const summary: IBoardSummary = { +export const getBoardSummary = (board: Board): BoardSummary => { + const summary: BoardSummary = { horizontal: [[], [], []], vertical: [[], [], []], diagonal: [[], []], diff --git a/src/redux/game.spec.ts b/src/redux/game.spec.ts new file mode 100644 index 0000000..80d304c --- /dev/null +++ b/src/redux/game.spec.ts @@ -0,0 +1,129 @@ +import { reducer, GameState, actions } from './game'; +import { Player, tie, undetermined } from '../lib/Player'; +import { empty, createEmptyBoard } from '../lib/Board'; +import { getBoardSummary, BoardSummary } from '../lib/scoring'; + +jest.mock('../lib/scoring'); + +const mockedGetBoardSummary = >getBoardSummary; + +describe('reducer with name', () => { + describe('START_GAME', () => { + it('must reset the game state', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: false, + board: [], + winner: tie, + }; + const action = actions.startGame(); + const updatedState = reducer(state, action); + expect(updatedState).toEqual({ + board: [[empty, empty, empty], [empty, empty, empty], [empty, empty, empty]], + winner: undetermined, + activeGame: true, + currentPlayer: Player.X, + }); + }); + }); + describe('END_GAME', () => { + it('must set the activeGame flag to false', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: true, + board: [], + winner: tie, + }; + const action = actions.endGame(); + const updatedState = reducer(state, action); + expect(updatedState).toEqual({ ...state, activeGame: false }); + }); + }); + describe('NEXT_PLAYER', () => { + it('must toggle the player everytime NEXT_PLAYER is called', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: true, + board: [], + winner: tie, + }; + const action = actions.nextPlayer(); + let updatedState = reducer(state, action); + expect(updatedState.currentPlayer).toEqual(Player.X); + updatedState = reducer(updatedState, action); + expect(updatedState.currentPlayer).toEqual(Player.O); + updatedState = reducer(updatedState, action); + expect(updatedState.currentPlayer).toEqual(Player.X); + }); + it('must not toggle the player when the game is not active anymore', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: false, + board: [], + winner: tie, + }; + const action = actions.nextPlayer(); + let updatedState = reducer(state, action); + expect(updatedState.currentPlayer).toEqual(Player.O); + updatedState = reducer(updatedState, action); + expect(updatedState.currentPlayer).toEqual(Player.O); + updatedState = reducer(updatedState, action); + expect(updatedState.currentPlayer).toEqual(Player.O); + }); + }); + describe('UPDATE_BOARD', () => { + it('must mark an empty cell with the specified player', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: true, + board: createEmptyBoard(), + winner: tie, + }; + const action = actions.updateBoard(2, 1, Player.O); + const updatedState = reducer(state, action); + expect(updatedState).toEqual({ + ...state, + board: [[empty, empty, empty], [empty, empty, empty], [empty, Player.O, empty]], + }); + }); + it('must NOT mark a cell when it is not empty', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: true, + board: [[empty, empty, empty], [empty, empty, empty], [empty, Player.O, empty]], + winner: tie, + }; + const action = actions.updateBoard(2, 1, Player.X); + const updatedState = reducer(state, action); + expect(updatedState).toEqual({ + ...state, + board: [[empty, empty, empty], [empty, empty, empty], [empty, Player.O, empty]], + }); + }); + it('must NOT mark a cell when the game is not active anymore', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: false, + board: [[empty, empty, empty], [empty, empty, empty], [empty, Player.O, empty]], + winner: tie, + }; + const action = actions.updateBoard(2, 1, Player.X); + const updatedState = reducer(state, action); + expect(updatedState).toEqual(state); + }); + }); + describe('DETERMINE_WINNER', () => { + it('must use the external scroring logic to decide if there is a winner', () => { + const state: GameState = { + currentPlayer: Player.O, + activeGame: true, + board: [], + winner: undetermined, + }; + mockedGetBoardSummary.mockReturnValueOnce({ horizontal: [], vertical: [], diagonal: [], winner: Player.X }); + const action = actions.determineWinner(); + const updatedState = reducer(state, action); + expect(updatedState).toEqual({ ...state, winner: Player.X }); + }); + }); +}); diff --git a/src/redux/game.ts b/src/redux/game.ts index 4f99dc8..fcc1e19 100644 --- a/src/redux/game.ts +++ b/src/redux/game.ts @@ -61,7 +61,7 @@ const defaultState: IGameState = { export const reducer: Reducer = (state: IGameState = defaultState, action: Action): IGameState => { if (action.type === START_GAME) { - return { ...state, board: createEmptyBoard(), activeGame: true }; + return { ...state, board: createEmptyBoard(), activeGame: true, winner: undetermined, currentPlayer: Player.X }; } if (action.type === END_GAME) { return { ...state, activeGame: false };