diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..426864e --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Dependencies +node_modules/ + +# Coverage reports +coverage/ + +# Environment +.env +.envrc + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/game-logic.js b/game-logic.js new file mode 100644 index 0000000..fcb1d1f --- /dev/null +++ b/game-logic.js @@ -0,0 +1,225 @@ +/** + * GameLogic - Pure game logic for Tic-Tac-Toe + * This class handles all game state and logic without any DOM dependencies, + * making it fully testable. + */ +class GameLogic { + constructor() { + this.reset(); + } + + /** + * Reset the game to initial state + */ + reset() { + this.currentPlayer = 'X'; + this.gameBoard = ['', '', '', '', '', '', '', '', '']; + this.gameActive = true; + this.winner = null; + this.winningCombination = []; + this.moveHistory = []; + } + + /** + * Check if a move is valid + * @param {number} cellIndex - The cell index (0-8) + * @returns {boolean} - True if the move is valid + */ + isValidMove(cellIndex) { + // Check if cell index is valid + if (typeof cellIndex !== 'number' || isNaN(cellIndex)) { + return false; + } + + // Check if cell index is in range + if (cellIndex < 0 || cellIndex > 8) { + return false; + } + + // Check if game is still active + if (!this.gameActive) { + return false; + } + + // Check if cell is empty + if (this.gameBoard[cellIndex] !== '') { + return false; + } + + return true; + } + + /** + * Get the reason why a move is invalid + * @param {number} cellIndex - The cell index (0-8) + * @returns {string|null} - Error message or null if valid + */ + getInvalidMoveReason(cellIndex) { + if (typeof cellIndex !== 'number' || isNaN(cellIndex)) { + return 'Invalid cell index: must be a number'; + } + + if (cellIndex < 0 || cellIndex > 8) { + return `Invalid cell index: ${cellIndex} is out of range (0-8)`; + } + + if (!this.gameActive) { + return 'Game is already over'; + } + + if (this.gameBoard[cellIndex] !== '') { + return `Cell ${cellIndex} is already occupied by ${this.gameBoard[cellIndex]}`; + } + + return null; + } + + /** + * Make a move on the board + * @param {number} cellIndex - The cell index (0-8) + * @returns {Object} - Result of the move + */ + makeMove(cellIndex) { + // Validate the move + if (!this.isValidMove(cellIndex)) { + const reason = this.getInvalidMoveReason(cellIndex); + return { + success: false, + error: reason, + gameState: this.getGameState() + }; + } + + // Place the mark + this.gameBoard[cellIndex] = this.currentPlayer; + this.moveHistory.push({ + player: this.currentPlayer, + cellIndex: cellIndex, + moveNumber: this.moveHistory.length + 1 + }); + + // Check for win or draw + const result = this.checkResult(); + + return { + success: true, + player: this.currentPlayer, + cellIndex: cellIndex, + ...result, + gameState: this.getGameState() + }; + } + + /** + * Check the game result after a move + * @returns {Object} - Game result information + */ + checkResult() { + const winningConditions = [ + [0, 1, 2], // Top row + [3, 4, 5], // Middle row + [6, 7, 8], // Bottom row + [0, 3, 6], // Left column + [1, 4, 7], // Middle column + [2, 5, 8], // Right column + [0, 4, 8], // Diagonal top-left to bottom-right + [2, 4, 6] // Diagonal top-right to bottom-left + ]; + + // Check for win + for (const combination of winningConditions) { + const [a, b, c] = combination; + + if (this.gameBoard[a] === '' || this.gameBoard[b] === '' || this.gameBoard[c] === '') { + continue; + } + + if (this.gameBoard[a] === this.gameBoard[b] && this.gameBoard[b] === this.gameBoard[c]) { + this.gameActive = false; + this.winner = this.currentPlayer; + this.winningCombination = combination; + return { + gameOver: true, + winner: this.currentPlayer, + winningCombination: combination, + isDraw: false + }; + } + } + + // Check for draw + if (!this.gameBoard.includes('')) { + this.gameActive = false; + this.winner = null; + return { + gameOver: true, + winner: null, + winningCombination: [], + isDraw: true + }; + } + + // Game continues - switch player + this.switchPlayer(); + return { + gameOver: false, + winner: null, + winningCombination: [], + isDraw: false + }; + } + + /** + * Switch the current player + */ + switchPlayer() { + this.currentPlayer = this.currentPlayer === 'X' ? 'O' : 'X'; + } + + /** + * Get the current game state + * @returns {Object} - Current game state + */ + getGameState() { + return { + currentPlayer: this.currentPlayer, + gameBoard: [...this.gameBoard], + gameActive: this.gameActive, + winner: this.winner, + winningCombination: [...this.winningCombination], + isDraw: this.winner === null && !this.gameActive, + moveCount: this.moveHistory.length, + moveHistory: [...this.moveHistory] + }; + } + + /** + * Get all winning combinations for testing + * @returns {Array} - Array of winning combinations + */ + static getWinningCombinations() { + return [ + [0, 1, 2], // Top row + [3, 4, 5], // Middle row + [6, 7, 8], // Bottom row + [0, 3, 6], // Left column + [1, 4, 7], // Middle column + [2, 5, 8], // Right column + [0, 4, 8], // Diagonal top-left to bottom-right + [2, 4, 6] // Diagonal top-right to bottom-left + ]; + } + + /** + * Get all possible cell indices + * @returns {Array} - Array of cell indices 0-8 + */ + static getAllCellIndices() { + return [0, 1, 2, 3, 4, 5, 6, 7, 8]; + } +} + +// Export for testing (Node.js) and browser compatibility +if (typeof module !== 'undefined' && module.exports) { + module.exports = GameLogic; +} diff --git a/game-logic.test.js b/game-logic.test.js new file mode 100644 index 0000000..886bb2c --- /dev/null +++ b/game-logic.test.js @@ -0,0 +1,1030 @@ +/** + * Comprehensive Edge Case Tests for Tic-Tac-Toe Game Logic + * + * This test suite covers: + * - Invalid moves (occupied cells, out of bounds, wrong types, game over state) + * - All 8 winning combinations (3 rows, 3 columns, 2 diagonals) + * - Draw scenarios + * - Game reset functionality + * - Player switching logic + * - Initial state validation + * - Edge cases and boundary conditions + */ + +const GameLogic = require('./game-logic'); + +describe('GameLogic - Initial State', () => { + let game; + + beforeEach(() => { + game = new GameLogic(); + }); + + test('should initialize with X as current player', () => { + const state = game.getGameState(); + expect(state.currentPlayer).toBe('X'); + }); + + test('should initialize with empty board', () => { + const state = game.getGameState(); + expect(state.gameBoard).toEqual(['', '', '', '', '', '', '', '', '']); + expect(state.gameBoard.every(cell => cell === '')).toBe(true); + }); + + test('should initialize with game active', () => { + const state = game.getGameState(); + expect(state.gameActive).toBe(true); + }); + + test('should initialize with no winner', () => { + const state = game.getGameState(); + expect(state.winner).toBeNull(); + }); + + test('should initialize with empty winning combination', () => { + const state = game.getGameState(); + expect(state.winningCombination).toEqual([]); + }); + + test('should initialize with move count of 0', () => { + const state = game.getGameState(); + expect(state.moveCount).toBe(0); + }); + + test('should initialize with empty move history', () => { + const state = game.getGameState(); + expect(state.moveHistory).toEqual([]); + }); +}); + +describe('GameLogic - Invalid Moves', () => { + let game; + + beforeEach(() => { + game = new GameLogic(); + }); + + describe('Occupied Cell Attempts', () => { + test('should reject move on already occupied cell (X)', () => { + game.makeMove(0); // X moves + const result = game.makeMove(0); // Try O on same cell + + expect(result.success).toBe(false); + expect(result.error).toContain('already occupied'); + expect(game.getGameState().gameBoard[0]).toBe('X'); + }); + + test('should reject move on already occupied cell (O)', () => { + game.makeMove(0); // X moves + game.makeMove(1); // O moves + const result = game.makeMove(1); // Try X on O's cell + + expect(result.success).toBe(false); + expect(result.error).toContain('already occupied'); + expect(game.getGameState().gameBoard[1]).toBe('O'); + }); + + test('should reject multiple attempts on same occupied cell', () => { + game.makeMove(4); + + // Try multiple times + for (let i = 0; i < 5; i++) { + const result = game.makeMove(4); + expect(result.success).toBe(false); + } + + expect(game.getGameState().gameBoard[4]).toBe('X'); + }); + }); + + describe('Out of Bounds Indices', () => { + test('should reject negative indices', () => { + const result = game.makeMove(-1); + expect(result.success).toBe(false); + expect(result.error).toContain('out of range'); + }); + + test('should reject index -100', () => { + const result = game.makeMove(-100); + expect(result.success).toBe(false); + expect(result.error).toContain('out of range'); + }); + + test('should reject index 9 (just above valid range)', () => { + const result = game.makeMove(9); + expect(result.success).toBe(false); + expect(result.error).toContain('out of range'); + }); + + test('should reject index 100 (far above valid range)', () => { + const result = game.makeMove(100); + expect(result.success).toBe(false); + expect(result.error).toContain('out of range'); + }); + + test('should accept boundary index 0 (lowest valid)', () => { + const result = game.makeMove(0); + expect(result.success).toBe(true); + }); + + test('should accept boundary index 8 (highest valid)', () => { + const result = game.makeMove(8); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid Data Types', () => { + test('should reject string input', () => { + const result = game.makeMove('center'); + expect(result.success).toBe(false); + expect(result.error).toContain('must be a number'); + }); + + test('should reject null input', () => { + const result = game.makeMove(null); + expect(result.success).toBe(false); + expect(result.error).toContain('must be a number'); + }); + + test('should reject undefined input', () => { + const result = game.makeMove(undefined); + expect(result.success).toBe(false); + expect(result.error).toContain('must be a number'); + }); + + test('should reject object input', () => { + const result = game.makeMove({ row: 1, col: 1 }); + expect(result.success).toBe(false); + expect(result.error).toContain('must be a number'); + }); + + test('should reject array input', () => { + const result = game.makeMove([1, 2]); + expect(result.success).toBe(false); + expect(result.error).toContain('must be a number'); + }); + + test('should reject boolean input (true)', () => { + const result = game.makeMove(true); + expect(result.success).toBe(false); + expect(result.error).toContain('must be a number'); + }); + + test('should reject boolean input (false)', () => { + const result = game.makeMove(false); + expect(result.success).toBe(false); + expect(result.error).toContain('must be a number'); + }); + + test('should reject NaN input', () => { + const result = game.makeMove(NaN); + expect(result.success).toBe(false); + expect(result.error).toContain('must be a number'); + }); + + test('should reject Infinity', () => { + const result = game.makeMove(Infinity); + expect(result.success).toBe(false); + expect(result.error).toContain('out of range'); + }); + + test('should reject negative Infinity', () => { + const result = game.makeMove(-Infinity); + expect(result.success).toBe(false); + expect(result.error).toContain('out of range'); + }); + }); + + describe('Moves After Game Over (Win)', () => { + test('should reject moves after X wins', () => { + // X wins with top row + game.makeMove(0); // X + game.makeMove(3); // O + game.makeMove(1); // X + game.makeMove(4); // O + game.makeMove(2); // X wins + + expect(game.getGameState().gameActive).toBe(false); + expect(game.getGameState().winner).toBe('X'); + + const result = game.makeMove(5); + expect(result.success).toBe(false); + expect(result.error).toContain('Game is already over'); + }); + + test('should reject moves after O wins', () => { + // O wins + game.makeMove(0); // X + game.makeMove(3); // O + game.makeMove(1); // X + game.makeMove(4); // O + game.makeMove(6); // X + game.makeMove(5); // O wins middle row + + expect(game.getGameState().gameActive).toBe(false); + expect(game.getGameState().winner).toBe('O'); + + const result = game.makeMove(2); + expect(result.success).toBe(false); + expect(result.error).toContain('Game is already over'); + }); + + test('should reject all remaining cell moves after game is won', () => { + // X wins quickly + game.makeMove(0); + game.makeMove(3); + game.makeMove(1); + game.makeMove(4); + game.makeMove(2); // X wins + + const remainingCells = [5, 6, 7, 8]; + remainingCells.forEach(cell => { + const result = game.makeMove(cell); + expect(result.success).toBe(false); + expect(result.error).toContain('Game is already over'); + }); + }); + }); + + describe('Moves After Game Over (Draw)', () => { + test('should reject moves after draw', () => { + // Create a draw scenario + // X O X + // X X O + // O X O + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(2); // X + game.makeMove(5); // O + game.makeMove(3); // X + game.makeMove(6); // O + game.makeMove(4); // X + game.makeMove(8); // O + game.makeMove(7); // X - Draw + + expect(game.getGameState().gameActive).toBe(false); + expect(game.getGameState().isDraw).toBe(true); + + // Try to make another move + const result = game.makeMove(0); + expect(result.success).toBe(false); + expect(result.error).toContain('Game is already over'); + }); + }); +}); + +describe('GameLogic - Win Conditions (All 8 Combinations)', () => { + let game; + + beforeEach(() => { + game = new GameLogic(); + }); + + describe('Horizontal Wins (Rows)', () => { + test('should detect win on top row (0, 1, 2) for X', () => { + game.makeMove(0); // X + game.makeMove(3); // O + game.makeMove(1); // X + game.makeMove(4); // O + const result = game.makeMove(2); // X wins + + expect(result.success).toBe(true); + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + expect(result.winningCombination).toEqual([0, 1, 2]); + }); + + test('should detect win on top row (0, 1, 2) for O', () => { + game.makeMove(3); // X + game.makeMove(0); // O + game.makeMove(4); // X + game.makeMove(1); // O + game.makeMove(6); // X + const result = game.makeMove(2); // O wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('O'); + expect(result.winningCombination).toEqual([0, 1, 2]); + }); + + test('should detect win on middle row (3, 4, 5) for X', () => { + game.makeMove(3); // X + game.makeMove(0); // O + game.makeMove(4); // X + game.makeMove(1); // O + const result = game.makeMove(5); // X wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + expect(result.winningCombination).toEqual([3, 4, 5]); + }); + + test('should detect win on middle row (3, 4, 5) for O', () => { + game.makeMove(0); // X + game.makeMove(3); // O + game.makeMove(1); // X + game.makeMove(4); // O + game.makeMove(8); // X + const result = game.makeMove(5); // O wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('O'); + expect(result.winningCombination).toEqual([3, 4, 5]); + }); + + test('should detect win on bottom row (6, 7, 8) for X', () => { + game.makeMove(6); // X + game.makeMove(0); // O + game.makeMove(7); // X + game.makeMove(1); // O + const result = game.makeMove(8); // X wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + expect(result.winningCombination).toEqual([6, 7, 8]); + }); + + test('should detect win on bottom row (6, 7, 8) for O', () => { + game.makeMove(0); // X + game.makeMove(6); // O + game.makeMove(1); // X + game.makeMove(7); // O + game.makeMove(4); // X + const result = game.makeMove(8); // O wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('O'); + expect(result.winningCombination).toEqual([6, 7, 8]); + }); + }); + + describe('Vertical Wins (Columns)', () => { + test('should detect win on left column (0, 3, 6) for X', () => { + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(3); // X + game.makeMove(2); // O + const result = game.makeMove(6); // X wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + expect(result.winningCombination).toEqual([0, 3, 6]); + }); + + test('should detect win on left column (0, 3, 6) for O', () => { + game.makeMove(1); // X + game.makeMove(0); // O + game.makeMove(2); // X + game.makeMove(3); // O + game.makeMove(4); // X + const result = game.makeMove(6); // O wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('O'); + expect(result.winningCombination).toEqual([0, 3, 6]); + }); + + test('should detect win on middle column (1, 4, 7) for X', () => { + game.makeMove(1); // X + game.makeMove(0); // O + game.makeMove(4); // X + game.makeMove(2); // O + const result = game.makeMove(7); // X wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + expect(result.winningCombination).toEqual([1, 4, 7]); + }); + + test('should detect win on middle column (1, 4, 7) for O', () => { + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(2); // X + game.makeMove(4); // O + game.makeMove(6); // X + const result = game.makeMove(7); // O wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('O'); + expect(result.winningCombination).toEqual([1, 4, 7]); + }); + + test('should detect win on right column (2, 5, 8) for X', () => { + game.makeMove(2); // X + game.makeMove(0); // O + game.makeMove(5); // X + game.makeMove(1); // O + const result = game.makeMove(8); // X wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + expect(result.winningCombination).toEqual([2, 5, 8]); + }); + + test('should detect win on right column (2, 5, 8) for O', () => { + game.makeMove(0); // X + game.makeMove(2); // O + game.makeMove(1); // X + game.makeMove(5); // O + game.makeMove(3); // X + const result = game.makeMove(8); // O wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('O'); + expect(result.winningCombination).toEqual([2, 5, 8]); + }); + }); + + describe('Diagonal Wins', () => { + test('should detect win on main diagonal (0, 4, 8) for X', () => { + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(4); // X + game.makeMove(2); // O + const result = game.makeMove(8); // X wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + expect(result.winningCombination).toEqual([0, 4, 8]); + }); + + test('should detect win on main diagonal (0, 4, 8) for O', () => { + game.makeMove(1); // X + game.makeMove(0); // O + game.makeMove(2); // X + game.makeMove(4); // O + game.makeMove(3); // X + const result = game.makeMove(8); // O wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('O'); + expect(result.winningCombination).toEqual([0, 4, 8]); + }); + + test('should detect win on anti-diagonal (2, 4, 6) for X', () => { + game.makeMove(2); // X + game.makeMove(0); // O + game.makeMove(4); // X + game.makeMove(1); // O + const result = game.makeMove(6); // X wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + expect(result.winningCombination).toEqual([2, 4, 6]); + }); + + test('should detect win on anti-diagonal (2, 4, 6) for O', () => { + game.makeMove(0); // X + game.makeMove(2); // O + game.makeMove(1); // X + game.makeMove(4); // O + game.makeMove(8); // X + const result = game.makeMove(6); // O wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('O'); + expect(result.winningCombination).toEqual([2, 4, 6]); + }); + }); + + describe('Immediate Win Conditions', () => { + test('should detect win in minimum moves (5 total moves)', () => { + // X can win in 3 moves (5 total including O's moves) + game.makeMove(0); // X + game.makeMove(3); // O + game.makeMove(1); // X + game.makeMove(4); // O + const result = game.makeMove(2); // X wins + + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + expect(game.getGameState().moveCount).toBe(5); + }); + + test('should detect win on first possible opportunity', () => { + // X: 0, 4, 8 (main diagonal) + // O: 1, 2 (blocking attempts) + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(4); // X + game.makeMove(2); // O + const result = game.makeMove(8); // X wins immediately + + expect(result.gameOver).toBe(true); + expect(game.getGameState().moveCount).toBe(5); + }); + }); + + describe('Win Detection Edge Cases', () => { + test('should not detect false win with only 2 in a row', () => { + game.makeMove(0); // X + game.makeMove(3); // O + game.makeMove(1); // X (2 in a row, not 3) + game.makeMove(4); // O + + const state = game.getGameState(); + expect(state.gameActive).toBe(true); + expect(state.winner).toBeNull(); + }); + + test('should not detect win with mixed marks in line', () => { + game.makeMove(0); // X + game.makeMove(1); // O (in same row) + game.makeMove(2); // X (in same row) + + const state = game.getGameState(); + expect(state.gameActive).toBe(true); + expect(state.winner).toBeNull(); + }); + + test('should correctly identify winning combination when multiple lines possible', () => { + // X creates two potential wins, completes one + // X X . + // O O . + // . . . + game.makeMove(0); // X + game.makeMove(3); // O + game.makeMove(1); // X + game.makeMove(4); // O + const result = game.makeMove(2); // X wins with top row + + expect(result.winningCombination).toEqual([0, 1, 2]); + }); + }); +}); + +describe('GameLogic - Draw Scenarios', () => { + let game; + + beforeEach(() => { + game = new GameLogic(); + }); + + test('should detect draw when board is full with no winner', () => { + // X O X + // X X O + // O X O + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(2); // X + game.makeMove(5); // O + game.makeMove(3); // X + game.makeMove(6); // O + game.makeMove(4); // X + game.makeMove(8); // O + const result = game.makeMove(7); // X - Draw + + expect(result.success).toBe(true); + expect(result.gameOver).toBe(true); + expect(result.isDraw).toBe(true); + expect(result.winner).toBeNull(); + }); + + test('should detect draw in alternate pattern', () => { + // X X O + // O O X + // X O X + game.makeMove(0); // X + game.makeMove(3); // O + game.makeMove(1); // X + game.makeMove(4); // O + game.makeMove(6); // X + game.makeMove(2); // O + game.makeMove(5); // X + game.makeMove(7); // O + const result = game.makeMove(8); // X - Draw + + expect(result.isDraw).toBe(true); + }); + + test('should detect draw in another pattern', () => { + // O X O + // X O X + // X O X + game.makeMove(1); // X + game.makeMove(0); // O + game.makeMove(3); // X + game.makeMove(2); // O + game.makeMove(5); // X + game.makeMove(4); // O + game.makeMove(6); // X + game.makeMove(7); // O + const result = game.makeMove(8); // X - Draw + + expect(result.isDraw).toBe(true); + }); + + test('should fill all 9 cells in a draw', () => { + // X O X + // O X X + // O X O + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(2); // X + game.makeMove(3); // O + game.makeMove(4); // X + game.makeMove(6); // O + game.makeMove(5); // X + game.makeMove(8); // O + game.makeMove(7); // X - Draw + + const state = game.getGameState(); + expect(state.moveCount).toBe(9); + expect(state.gameBoard.filter(cell => cell !== '').length).toBe(9); + }); + + test('should mark game as inactive after draw', () => { + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(2); // X + game.makeMove(5); // O + game.makeMove(3); // X + game.makeMove(6); // O + game.makeMove(4); // X + game.makeMove(8); // O + game.makeMove(7); // X - Draw + + const state = game.getGameState(); + expect(state.gameActive).toBe(false); + }); +}); + +describe('GameLogic - Player Switching', () => { + let game; + + beforeEach(() => { + game = new GameLogic(); + }); + + test('should start with player X', () => { + expect(game.getGameState().currentPlayer).toBe('X'); + }); + + test('should switch to O after X moves', () => { + game.makeMove(0); + expect(game.getGameState().currentPlayer).toBe('O'); + }); + + test('should switch back to X after O moves', () => { + game.makeMove(0); // X + game.makeMove(1); // O + expect(game.getGameState().currentPlayer).toBe('X'); + }); + + test('should alternate players correctly', () => { + // After X moves at 0, O at 1 -> current should be X + game.makeMove(0); // X + game.makeMove(1); // O + expect(game.getGameState().currentPlayer).toBe('X'); + + // After X moves at 2, O at 3 -> current should be X + game.makeMove(2); // X + game.makeMove(3); // O + expect(game.getGameState().currentPlayer).toBe('X'); + + // After X moves at 4, O at 5 -> current should be X + game.makeMove(4); // X + game.makeMove(5); // O + expect(game.getGameState().currentPlayer).toBe('X'); + }); + + test('should not switch player on invalid move', () => { + game.makeMove(0); // X moves + const stateBefore = game.getGameState().currentPlayer; + game.makeMove(0); // Invalid - already occupied + expect(game.getGameState().currentPlayer).toBe(stateBefore); + }); + + test('should not switch player when game ends in win', () => { + game.makeMove(0); // X + game.makeMove(3); // O + game.makeMove(1); // X + game.makeMove(4); // O + game.makeMove(2); // X wins + + // Winner stays as current player + expect(game.getGameState().currentPlayer).toBe('X'); + }); + + test('should not switch player when game ends in draw', () => { + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(2); // X + game.makeMove(5); // O + game.makeMove(3); // X + game.makeMove(6); // O + game.makeMove(4); // X + game.makeMove(8); // O + game.makeMove(7); // X - Draw + + expect(game.getGameState().currentPlayer).toBe('X'); + }); +}); + +describe('GameLogic - Game Reset', () => { + let game; + + beforeEach(() => { + game = new GameLogic(); + }); + + test('should reset game after win', () => { + // X wins + game.makeMove(0); + game.makeMove(3); + game.makeMove(1); + game.makeMove(4); + game.makeMove(2); + + expect(game.getGameState().gameActive).toBe(false); + + game.reset(); + const state = game.getGameState(); + + expect(state.gameActive).toBe(true); + expect(state.currentPlayer).toBe('X'); + expect(state.gameBoard).toEqual(['', '', '', '', '', '', '', '', '']); + expect(state.winner).toBeNull(); + expect(state.winningCombination).toEqual([]); + }); + + test('should reset game after draw', () => { + // Create draw + game.makeMove(0); + game.makeMove(1); + game.makeMove(2); + game.makeMove(5); + game.makeMove(3); + game.makeMove(6); + game.makeMove(4); + game.makeMove(8); + game.makeMove(7); + + expect(game.getGameState().isDraw).toBe(true); + + game.reset(); + expect(game.getGameState().isDraw).toBe(false); + expect(game.getGameState().gameActive).toBe(true); + }); + + test('should reset game during active game', () => { + game.makeMove(0); + game.makeMove(1); + + expect(game.getGameState().moveCount).toBe(2); + + game.reset(); + expect(game.getGameState().moveCount).toBe(0); + expect(game.getGameState().gameBoard[0]).toBe(''); + expect(game.getGameState().gameBoard[1]).toBe(''); + }); + + test('should clear move history on reset', () => { + game.makeMove(0); + game.makeMove(1); + game.makeMove(2); + + expect(game.getGameState().moveHistory.length).toBe(3); + + game.reset(); + expect(game.getGameState().moveHistory).toEqual([]); + }); + + test('should allow moves after reset', () => { + game.makeMove(0); + game.makeMove(1); + game.makeMove(2); + game.reset(); + + const result = game.makeMove(0); + expect(result.success).toBe(true); + }); +}); + +describe('GameLogic - Move History Tracking', () => { + let game; + + beforeEach(() => { + game = new GameLogic(); + }); + + test('should track all moves in history', () => { + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(2); // X + + const history = game.getGameState().moveHistory; + expect(history.length).toBe(3); + }); + + test('should track correct player for each move', () => { + game.makeMove(0); // X + game.makeMove(1); // O + game.makeMove(2); // X + + const history = game.getGameState().moveHistory; + expect(history[0].player).toBe('X'); + expect(history[1].player).toBe('O'); + expect(history[2].player).toBe('X'); + }); + + test('should track correct cell index for each move', () => { + game.makeMove(4); + game.makeMove(2); + game.makeMove(6); + + const history = game.getGameState().moveHistory; + expect(history[0].cellIndex).toBe(4); + expect(history[1].cellIndex).toBe(2); + expect(history[2].cellIndex).toBe(6); + }); + + test('should track move numbers sequentially', () => { + game.makeMove(0); + game.makeMove(1); + game.makeMove(2); + + const history = game.getGameState().moveHistory; + expect(history[0].moveNumber).toBe(1); + expect(history[1].moveNumber).toBe(2); + expect(history[2].moveNumber).toBe(3); + }); + + test('should not track invalid moves in history', () => { + game.makeMove(0); + game.makeMove(0); // Invalid - already occupied + + const history = game.getGameState().moveHistory; + expect(history.length).toBe(1); + }); +}); + +describe('GameLogic - isValidMove Helper', () => { + let game; + + beforeEach(() => { + game = new GameLogic(); + }); + + test('should return true for valid empty cell', () => { + expect(game.isValidMove(0)).toBe(true); + expect(game.isValidMove(4)).toBe(true); + expect(game.isValidMove(8)).toBe(true); + }); + + test('should return false for occupied cell', () => { + game.makeMove(4); + expect(game.isValidMove(4)).toBe(false); + }); + + test('should return false for out of bounds', () => { + expect(game.isValidMove(-1)).toBe(false); + expect(game.isValidMove(9)).toBe(false); + }); + + test('should return false for invalid types', () => { + expect(game.isValidMove('4')).toBe(false); + expect(game.isValidMove(null)).toBe(false); + expect(game.isValidMove(undefined)).toBe(false); + }); + + test('should return false when game is over', () => { + game.makeMove(0); + game.makeMove(3); + game.makeMove(1); + game.makeMove(4); + game.makeMove(2); // X wins + + expect(game.isValidMove(5)).toBe(false); + }); +}); + +describe('GameLogic - getInvalidMoveReason Helper', () => { + let game; + + beforeEach(() => { + game = new GameLogic(); + }); + + test('should return null for valid move', () => { + expect(game.getInvalidMoveReason(0)).toBeNull(); + }); + + test('should return specific reason for occupied cell', () => { + game.makeMove(4); + const reason = game.getInvalidMoveReason(4); + expect(reason).toContain('already occupied'); + expect(reason).toContain('X'); + }); + + test('should return specific reason for out of bounds', () => { + expect(game.getInvalidMoveReason(-1)).toContain('out of range'); + expect(game.getInvalidMoveReason(9)).toContain('out of range'); + }); + + test('should return specific reason for invalid type', () => { + expect(game.getInvalidMoveReason('5')).toContain('must be a number'); + expect(game.getInvalidMoveReason(null)).toContain('must be a number'); + }); + + test('should return specific reason when game is over', () => { + game.makeMove(0); + game.makeMove(3); + game.makeMove(1); + game.makeMove(4); + game.makeMove(2); // Win + + expect(game.getInvalidMoveReason(5)).toContain('Game is already over'); + }); +}); + +describe('GameLogic - Static Methods', () => { + test('should return all 8 winning combinations', () => { + const combinations = GameLogic.getWinningCombinations(); + expect(combinations).toHaveLength(8); + expect(combinations).toContainEqual([0, 1, 2]); + expect(combinations).toContainEqual([3, 4, 5]); + expect(combinations).toContainEqual([6, 7, 8]); + expect(combinations).toContainEqual([0, 3, 6]); + expect(combinations).toContainEqual([1, 4, 7]); + expect(combinations).toContainEqual([2, 5, 8]); + expect(combinations).toContainEqual([0, 4, 8]); + expect(combinations).toContainEqual([2, 4, 6]); + }); + + test('should return all 9 cell indices', () => { + const indices = GameLogic.getAllCellIndices(); + expect(indices).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + }); +}); + +describe('GameLogic - Complete Game Scenarios', () => { + test('should handle complete X victory game', () => { + const game = new GameLogic(); + + // X O X + // X O . + // X . . + const moves = [ + { cell: 0, expectedPlayer: 'X' }, + { cell: 1, expectedPlayer: 'O' }, + { cell: 2, expectedPlayer: 'X' }, + { cell: 4, expectedPlayer: 'O' }, + { cell: 3, expectedPlayer: 'X' }, + { cell: 5, expectedPlayer: 'O' }, + { cell: 6, expectedPlayer: 'X', shouldWin: true } + ]; + + moves.forEach(move => { + const result = game.makeMove(move.cell); + expect(result.success).toBe(true); + if (move.shouldWin) { + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('X'); + } + }); + }); + + test('should handle complete O victory game', () => { + const game = new GameLogic(); + + // O X X + // O X . + // O . . + const moves = [ + { cell: 1, player: 'X' }, + { cell: 0, player: 'O' }, + { cell: 2, player: 'X' }, + { cell: 3, player: 'O' }, + { cell: 4, player: 'X' }, + { cell: 6, player: 'O', shouldWin: true } + ]; + + moves.forEach(move => { + const result = game.makeMove(move.cell); + expect(result.success).toBe(true); + if (move.shouldWin) { + expect(result.gameOver).toBe(true); + expect(result.winner).toBe('O'); + } + }); + }); + + test('should handle complete draw game', () => { + const game = new GameLogic(); + + const moves = [0, 1, 2, 5, 3, 6, 4, 8, 7]; + + moves.forEach((cell, index) => { + const result = game.makeMove(cell); + expect(result.success).toBe(true); + + if (index === moves.length - 1) { + expect(result.isDraw).toBe(true); + expect(result.gameOver).toBe(true); + } else { + expect(result.gameOver).toBe(false); + } + }); + }); +}); diff --git a/index.html b/index.html index a42dfd7..150e67e 100644 --- a/index.html +++ b/index.html @@ -26,6 +26,7 @@