diff --git a/.gitignore b/.gitignore index e43b0f9..e06a971 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ .DS_Store + +# Testing +node_modules/ +coverage/ +.jest-cache/ \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..4d956cd --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "battleship-game", + "version": "1.0.0", + "description": "Battleship game with comprehensive unit tests", + "type": "module", + "scripts": { + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage" + }, + "jest": { + "testEnvironment": "jsdom", + "transform": {}, + "extensionsToTreatAsEsm": [".js"], + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, + "coveragePathIgnorePatterns": [ + "/node_modules/", + "/__tests__/" + ], + "testMatch": [ + "**/__tests__/**/*.test.js" + ] + }, + "devDependencies": { + "jest": "^29.0.0", + "jest-environment-jsdom": "^29.0.0" + } +} \ No newline at end of file diff --git a/src/__tests__/README.md b/src/__tests__/README.md new file mode 100644 index 0000000..9aba9f8 --- /dev/null +++ b/src/__tests__/README.md @@ -0,0 +1,52 @@ +# Battleship Game Test Suite + +This directory contains comprehensive unit tests for all modules in the Battleship game. + +## Test Coverage + +### Core Modules +- **constants.test.js** - Tests for game constants, ship definitions, and configuration +- **ship.test.js** - Tests for Ship class including hit tracking and sinking logic +- **board.test.js** - Tests for Board class including placement, attacks, and coordinate management +- **ai-controller.test.js** - Tests for AI targeting logic and hunt/target strategy +- **ui-controller.test.js** - Tests for UI rendering and DOM manipulation +- **game.test.js** - Tests for main game controller and game flow + +## Running Tests + +```bash +# Install dependencies (Jest) +npm install + +# Run all tests +npm test + +# Run tests in watch mode +npm run test:watch + +# Run tests with coverage report +npm run test:coverage +``` + +## Test Structure + +Each test file follows a consistent structure: +- **Unit tests** for individual methods and functions +- **Integration tests** for component interactions +- **Edge case tests** for boundary conditions and error handling + +## Test Coverage Goals + +The test suite aims for: +- High code coverage (>90%) +- Comprehensive edge case testing +- Clear, descriptive test names +- Proper mocking of dependencies +- Fast execution times + +## Notes + +- Tests use Jest with ES modules support +- DOM manipulation is tested with jsdom environment +- Mocks are created for UI controllers to isolate game logic +- Random behavior is tested probabilistically \ No newline at end of file diff --git a/src/__tests__/ai-controller.test.js b/src/__tests__/ai-controller.test.js new file mode 100644 index 0000000..06cffb0 --- /dev/null +++ b/src/__tests__/ai-controller.test.js @@ -0,0 +1,429 @@ +import { AIController } from '../ai-controller.js'; +import { Board } from '../board.js'; +import { ORIENTATION } from '../constants.js'; + +describe('AIController', () => { + let board; + let ai; + + beforeEach(() => { + board = new Board(); + ai = new AIController(board); + }); + + describe('constructor', () => { + test('should initialize with a board reference', () => { + expect(ai.board).toBe(board); + }); + + test('should initialize with empty focus queue', () => { + expect(ai.focusQueue).toEqual([]); + }); + + test('should initialize with empty focus registry', () => { + expect(ai.focusRegistry).toBeInstanceOf(Set); + expect(ai.focusRegistry.size).toBe(0); + }); + + test('should accept different board instances', () => { + const board2 = new Board(); + const ai2 = new AIController(board2); + expect(ai2.board).toBe(board2); + expect(ai2.board).not.toBe(board); + }); + }); + + describe('reset', () => { + test('should clear focus queue', () => { + ai.focusQueue = [1, 2, 3]; + ai.reset(); + expect(ai.focusQueue).toEqual([]); + }); + + test('should clear focus registry', () => { + ai.focusRegistry.add(10); + ai.focusRegistry.add(20); + ai.reset(); + expect(ai.focusRegistry.size).toBe(0); + }); + + test('should be idempotent', () => { + ai.reset(); + ai.reset(); + expect(ai.focusQueue).toEqual([]); + expect(ai.focusRegistry.size).toBe(0); + }); + + test('should not affect board state', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + ai.reset(); + expect(board.ships.length).toBe(1); + }); + }); + + describe('nextAttack', () => { + test('should return valid index on empty board', () => { + const target = ai.nextAttack(); + expect(target).toBeGreaterThanOrEqual(0); + expect(target).toBeLessThan(100); + }); + + test('should return index from focus queue when available', () => { + ai.focusQueue.push(42); + const target = ai.nextAttack(); + expect(target).toBe(42); + }); + + test('should skip already attacked cells in focus queue', () => { + board.receiveAttack(10); + ai.focusQueue.push(10, 11, 12); + + const target = ai.nextAttack(); + expect(target).toBe(11); + }); + + test('should drain focus queue before using random', () => { + ai.focusQueue.push(5, 15, 25); + + expect(ai.nextAttack()).toBe(5); + expect(ai.nextAttack()).toBe(15); + expect(ai.nextAttack()).toBe(25); + }); + + test('should fallback to random when focus queue empty', () => { + const target = ai.nextAttack(); + expect(target).toBeGreaterThanOrEqual(0); + expect(target).toBeLessThan(100); + }); + + test('should return null when board fully attacked', () => { + for (let i = 0; i < 100; i++) { + board.receiveAttack(i); + } + const target = ai.nextAttack(); + expect(target).toBeNull(); + }); + + test('should handle partially attacked cells in queue', () => { + board.receiveAttack(20); + board.receiveAttack(21); + ai.focusQueue.push(20, 21, 22); + + const target = ai.nextAttack(); + expect(target).toBe(22); + }); + + test('should not return same cell twice in sequence', () => { + const target1 = ai.nextAttack(); + board.receiveAttack(target1); + const target2 = ai.nextAttack(); + + if (target2 !== null) { + expect(target2).not.toBe(target1); + } + }); + }); + + describe('handleAttackResult', () => { + test('should do nothing for null result', () => { + ai.handleAttackResult(10, null); + expect(ai.focusQueue.length).toBe(0); + }); + + test('should do nothing for undefined result', () => { + ai.handleAttackResult(10, undefined); + expect(ai.focusQueue.length).toBe(0); + }); + + test('should do nothing for already attacked result', () => { + ai.handleAttackResult(10, { alreadyAttacked: true }); + expect(ai.focusQueue.length).toBe(0); + }); + + test('should do nothing on miss', () => { + ai.handleAttackResult(10, { hit: false }); + expect(ai.focusQueue.length).toBe(0); + }); + + test('should enqueue neighbors on hit', () => { + ai.handleAttackResult(55, { hit: true }); + + expect(ai.focusQueue.length).toBeGreaterThan(0); + expect(ai.focusRegistry.size).toBeGreaterThan(0); + }); + + test('should reset on ship sunk', () => { + ai.focusQueue.push(1, 2, 3); + ai.focusRegistry.add(1); + ai.focusRegistry.add(2); + + ai.handleAttackResult(10, { hit: true, shipSunk: true }); + + expect(ai.focusQueue).toEqual([]); + expect(ai.focusRegistry.size).toBe(0); + }); + + test('should not reset on hit without sinking', () => { + ai.handleAttackResult(10, { hit: true, shipSunk: false }); + expect(ai.focusQueue.length).toBeGreaterThan(0); + }); + + test('should handle multiple sequential hits', () => { + ai.handleAttackResult(50, { hit: true }); + const queueLength1 = ai.focusQueue.length; + + ai.handleAttackResult(51, { hit: true }); + const queueLength2 = ai.focusQueue.length; + + expect(queueLength2).toBeGreaterThan(0); + }); + }); + + describe('enqueueNeighbors', () => { + test('should enqueue all valid neighbors for center cell', () => { + ai.enqueueNeighbors(55); + + expect(ai.focusQueue).toContain(45); // above + expect(ai.focusQueue).toContain(65); // below + expect(ai.focusQueue).toContain(54); // left + expect(ai.focusQueue).toContain(56); // right + expect(ai.focusQueue.length).toBe(4); + }); + + test('should handle top-left corner', () => { + ai.enqueueNeighbors(0); + + expect(ai.focusQueue).toContain(10); // below + expect(ai.focusQueue).toContain(1); // right + expect(ai.focusQueue.length).toBe(2); + }); + + test('should handle top-right corner', () => { + ai.enqueueNeighbors(9); + + expect(ai.focusQueue).toContain(19); // below + expect(ai.focusQueue).toContain(8); // left + expect(ai.focusQueue.length).toBe(2); + }); + + test('should handle bottom-left corner', () => { + ai.enqueueNeighbors(90); + + expect(ai.focusQueue).toContain(80); // above + expect(ai.focusQueue).toContain(91); // right + expect(ai.focusQueue.length).toBe(2); + }); + + test('should handle bottom-right corner', () => { + ai.enqueueNeighbors(99); + + expect(ai.focusQueue).toContain(89); // above + expect(ai.focusQueue).toContain(98); // left + expect(ai.focusQueue.length).toBe(2); + }); + + test('should handle top edge', () => { + ai.enqueueNeighbors(5); + + expect(ai.focusQueue).toContain(4); // left + expect(ai.focusQueue).toContain(6); // right + expect(ai.focusQueue).toContain(15); // below + expect(ai.focusQueue.length).toBe(3); + }); + + test('should handle bottom edge', () => { + ai.enqueueNeighbors(95); + + expect(ai.focusQueue).toContain(85); // above + expect(ai.focusQueue).toContain(94); // left + expect(ai.focusQueue).toContain(96); // right + expect(ai.focusQueue.length).toBe(3); + }); + + test('should handle left edge', () => { + ai.enqueueNeighbors(50); + + expect(ai.focusQueue).toContain(40); // above + expect(ai.focusQueue).toContain(60); // below + expect(ai.focusQueue).toContain(51); // right + expect(ai.focusQueue.length).toBe(3); + }); + + test('should handle right edge', () => { + ai.enqueueNeighbors(59); + + expect(ai.focusQueue).toContain(49); // above + expect(ai.focusQueue).toContain(69); // below + expect(ai.focusQueue).toContain(58); // left + expect(ai.focusQueue.length).toBe(3); + }); + + test('should not enqueue duplicates', () => { + ai.enqueueNeighbors(55); + const initialLength = ai.focusQueue.length; + + ai.enqueueNeighbors(55); + + expect(ai.focusQueue.length).toBe(initialLength); + }); + + test('should track enqueued indices in registry', () => { + ai.enqueueNeighbors(55); + + expect(ai.focusRegistry.has(45)).toBe(true); + expect(ai.focusRegistry.has(65)).toBe(true); + expect(ai.focusRegistry.has(54)).toBe(true); + expect(ai.focusRegistry.has(56)).toBe(true); + }); + + test('should not enqueue already registered cells', () => { + ai.focusRegistry.add(45); + ai.enqueueNeighbors(55); + + const count45 = ai.focusQueue.filter(x => x === 45).length; + expect(count45).toBe(0); + }); + }); + + describe('Integration scenarios', () => { + test('should handle complete hunt and target sequence', () => { + board.placeShip(3, 55, ORIENTATION.HORIZONTAL); // cells 55, 56, 57 + + // First hit + const target1 = ai.nextAttack(); + const result1 = board.receiveAttack(55); + ai.handleAttackResult(55, result1); + + expect(result1.hit).toBe(true); + expect(ai.focusQueue.length).toBeGreaterThan(0); + + // Should target neighbors + const target2 = ai.nextAttack(); + expect([45, 65, 54, 56]).toContain(target2); + }); + + test('should reset after sinking ship', () => { + board.placeShip(2, 50, ORIENTATION.HORIZONTAL); + + const result1 = board.receiveAttack(50); + ai.handleAttackResult(50, result1); + expect(ai.focusQueue.length).toBeGreaterThan(0); + + const result2 = board.receiveAttack(51); + ai.handleAttackResult(51, result2); + expect(result2.shipSunk).toBe(true); + expect(ai.focusQueue.length).toBe(0); + expect(ai.focusRegistry.size).toBe(0); + }); + + test('should handle hitting multiple ships without sinking', () => { + board.placeShip(3, 20, ORIENTATION.HORIZONTAL); + board.placeShip(3, 50, ORIENTATION.HORIZONTAL); + + const result1 = board.receiveAttack(20); + ai.handleAttackResult(20, result1); + const queueSize1 = ai.focusQueue.length; + + const result2 = board.receiveAttack(50); + ai.handleAttackResult(50, result2); + const queueSize2 = ai.focusQueue.length; + + expect(queueSize2).toBeGreaterThan(queueSize1); + }); + + test('should handle edge case with no remaining targets', () => { + for (let i = 0; i < 100; i++) { + board.receiveAttack(i); + } + + const target = ai.nextAttack(); + expect(target).toBeNull(); + }); + + test('should efficiently target after first hit', () => { + board.placeShip(5, 0, ORIENTATION.HORIZONTAL); + + // Hit at index 2 + const result = board.receiveAttack(2); + ai.handleAttackResult(2, result); + + // Next attacks should be neighbors + const possibleTargets = [1, 3, 12]; + const target = ai.nextAttack(); + expect(possibleTargets).toContain(target); + }); + + test('should handle multiple resets in a game', () => { + board.placeShip(2, 10, ORIENTATION.HORIZONTAL); + board.placeShip(2, 20, ORIENTATION.HORIZONTAL); + + // Sink first ship + board.receiveAttack(10); + ai.handleAttackResult(10, board.receiveAttack(10)); + board.receiveAttack(11); + ai.handleAttackResult(11, board.receiveAttack(11)); + + expect(ai.focusQueue.length).toBe(0); + + // Hit second ship + const result = board.receiveAttack(20); + ai.handleAttackResult(20, result); + + expect(ai.focusQueue.length).toBeGreaterThan(0); + }); + + test('should work with board that filters attacked cells', () => { + // Attack some cells + board.receiveAttack(54); + board.receiveAttack(56); + + // Hit a ship + board.placeShip(3, 55, ORIENTATION.HORIZONTAL); + const result = board.receiveAttack(55); + ai.handleAttackResult(55, result); + + // Next attack should skip already attacked neighbors + const target = ai.nextAttack(); + expect(target).not.toBe(54); + expect(target).not.toBe(56); + }); + + test('should handle rapid succession of hits', () => { + board.placeShip(5, 50, ORIENTATION.HORIZONTAL); + + for (let i = 50; i < 55; i++) { + const result = board.receiveAttack(i); + ai.handleAttackResult(i, result); + } + + // After sinking, should be reset + expect(ai.focusQueue.length).toBe(0); + expect(ai.focusRegistry.size).toBe(0); + }); + }); + + describe('Edge cases', () => { + test('should handle null board gracefully in constructor', () => { + expect(() => new AIController(null)).not.toThrow(); + }); + + test('should handle undefined result in handleAttackResult', () => { + expect(() => ai.handleAttackResult(0, undefined)).not.toThrow(); + }); + + test('should handle invalid index in enqueueNeighbors', () => { + expect(() => ai.enqueueNeighbors(-1)).not.toThrow(); + expect(() => ai.enqueueNeighbors(100)).not.toThrow(); + }); + + test('should handle empty result object', () => { + expect(() => ai.handleAttackResult(0, {})).not.toThrow(); + }); + + test('should maintain state integrity after errors', () => { + ai.focusQueue.push(10, 20); + ai.handleAttackResult(null, null); + expect(ai.focusQueue).toEqual([10, 20]); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/board.test.js b/src/__tests__/board.test.js new file mode 100644 index 0000000..f247062 --- /dev/null +++ b/src/__tests__/board.test.js @@ -0,0 +1,559 @@ +import { Board } from '../board.js'; +import { BOARD_SIZE, ORIENTATION } from '../constants.js'; +import { Ship } from '../ship.js'; + +describe('Board', () => { + let board; + + beforeEach(() => { + board = new Board(); + }); + + describe('constructor and reset', () => { + test('should initialize with correct size', () => { + expect(board.size).toBe(BOARD_SIZE); + }); + + test('should initialize empty collections', () => { + expect(board.ships).toEqual([]); + expect(board.shipLookup.size).toBe(0); + expect(board.occupiedCells.size).toBe(0); + expect(board.attackedCells.size).toBe(0); + }); + + test('should reset board state', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + board.receiveAttack(0); + + expect(board.ships.length).toBeGreaterThan(0); + expect(board.attackedCells.size).toBeGreaterThan(0); + + board.reset(); + + expect(board.ships).toEqual([]); + expect(board.shipLookup.size).toBe(0); + expect(board.occupiedCells.size).toBe(0); + expect(board.attackedCells.size).toBe(0); + }); + + test('should maintain size after reset', () => { + const originalSize = board.size; + board.reset(); + expect(board.size).toBe(originalSize); + }); + }); + + describe('indexToCoord', () => { + test('should convert index 0 to row 0, col 0', () => { + expect(board.indexToCoord(0)).toEqual({ row: 0, col: 0 }); + }); + + test('should convert index 9 to row 0, col 9', () => { + expect(board.indexToCoord(9)).toEqual({ row: 0, col: 9 }); + }); + + test('should convert index 10 to row 1, col 0', () => { + expect(board.indexToCoord(10)).toEqual({ row: 1, col: 0 }); + }); + + test('should convert index 99 to row 9, col 9', () => { + expect(board.indexToCoord(99)).toEqual({ row: 9, col: 9 }); + }); + + test('should handle middle indices correctly', () => { + expect(board.indexToCoord(45)).toEqual({ row: 4, col: 5 }); + expect(board.indexToCoord(55)).toEqual({ row: 5, col: 5 }); + }); + + test('should handle negative indices', () => { + expect(board.indexToCoord(-1)).toEqual({ row: -1, col: -1 }); + }); + }); + + describe('coordToIndex', () => { + test('should convert row 0, col 0 to index 0', () => { + expect(board.coordToIndex(0, 0)).toBe(0); + }); + + test('should convert row 0, col 9 to index 9', () => { + expect(board.coordToIndex(0, 9)).toBe(9); + }); + + test('should convert row 1, col 0 to index 10', () => { + expect(board.coordToIndex(1, 0)).toBe(10); + }); + + test('should convert row 9, col 9 to index 99', () => { + expect(board.coordToIndex(9, 9)).toBe(99); + }); + + test('should handle middle coordinates correctly', () => { + expect(board.coordToIndex(4, 5)).toBe(45); + expect(board.coordToIndex(5, 5)).toBe(55); + }); + + test('should be inverse of indexToCoord', () => { + for (let i = 0; i < 100; i++) { + const coord = board.indexToCoord(i); + expect(board.coordToIndex(coord.row, coord.col)).toBe(i); + } + }); + }); + + describe('isWithinGrid', () => { + test('should return true for valid coordinates', () => { + expect(board.isWithinGrid(0, 0)).toBe(true); + expect(board.isWithinGrid(9, 9)).toBe(true); + expect(board.isWithinGrid(5, 5)).toBe(true); + }); + + test('should return false for negative coordinates', () => { + expect(board.isWithinGrid(-1, 0)).toBe(false); + expect(board.isWithinGrid(0, -1)).toBe(false); + expect(board.isWithinGrid(-1, -1)).toBe(false); + }); + + test('should return false for out of bounds coordinates', () => { + expect(board.isWithinGrid(10, 0)).toBe(false); + expect(board.isWithinGrid(0, 10)).toBe(false); + expect(board.isWithinGrid(10, 10)).toBe(false); + }); + + test('should return false for coordinates exceeding grid', () => { + expect(board.isWithinGrid(100, 100)).toBe(false); + expect(board.isWithinGrid(0, 100)).toBe(false); + }); + + test('should handle boundary conditions', () => { + expect(board.isWithinGrid(0, 0)).toBe(true); + expect(board.isWithinGrid(9, 9)).toBe(true); + expect(board.isWithinGrid(10, 0)).toBe(false); + expect(board.isWithinGrid(0, 10)).toBe(false); + }); + }); + + describe('isInBounds', () => { + test('should return true for horizontal ship within bounds', () => { + expect(board.isInBounds(3, 0, 0, ORIENTATION.HORIZONTAL)).toBe(true); + expect(board.isInBounds(5, 0, 0, ORIENTATION.HORIZONTAL)).toBe(true); + expect(board.isInBounds(3, 0, 7, ORIENTATION.HORIZONTAL)).toBe(true); + }); + + test('should return false for horizontal ship out of bounds', () => { + expect(board.isInBounds(3, 0, 8, ORIENTATION.HORIZONTAL)).toBe(false); + expect(board.isInBounds(5, 0, 6, ORIENTATION.HORIZONTAL)).toBe(false); + expect(board.isInBounds(11, 0, 0, ORIENTATION.HORIZONTAL)).toBe(false); + }); + + test('should return true for vertical ship within bounds', () => { + expect(board.isInBounds(3, 0, 0, ORIENTATION.VERTICAL)).toBe(true); + expect(board.isInBounds(5, 0, 0, ORIENTATION.VERTICAL)).toBe(true); + expect(board.isInBounds(3, 7, 0, ORIENTATION.VERTICAL)).toBe(true); + }); + + test('should return false for vertical ship out of bounds', () => { + expect(board.isInBounds(3, 8, 0, ORIENTATION.VERTICAL)).toBe(false); + expect(board.isInBounds(5, 6, 0, ORIENTATION.VERTICAL)).toBe(false); + expect(board.isInBounds(11, 0, 0, ORIENTATION.VERTICAL)).toBe(false); + }); + + test('should handle edge cases at boundary', () => { + expect(board.isInBounds(1, 9, 9, ORIENTATION.HORIZONTAL)).toBe(true); + expect(board.isInBounds(1, 9, 9, ORIENTATION.VERTICAL)).toBe(true); + expect(board.isInBounds(2, 9, 9, ORIENTATION.HORIZONTAL)).toBe(false); + expect(board.isInBounds(2, 9, 9, ORIENTATION.VERTICAL)).toBe(false); + }); + }); + + describe('createShipCells', () => { + test('should create horizontal ship cells', () => { + const cells = board.createShipCells(3, 0, 0, ORIENTATION.HORIZONTAL); + expect(cells).toEqual([0, 1, 2]); + }); + + test('should create vertical ship cells', () => { + const cells = board.createShipCells(3, 0, 0, ORIENTATION.VERTICAL); + expect(cells).toEqual([0, 10, 20]); + }); + + test('should create cells for ship at different position', () => { + const cells = board.createShipCells(4, 2, 3, ORIENTATION.HORIZONTAL); + expect(cells).toEqual([23, 24, 25, 26]); + }); + + test('should create cells for vertical ship at different position', () => { + const cells = board.createShipCells(4, 2, 3, ORIENTATION.VERTICAL); + expect(cells).toEqual([23, 33, 43, 53]); + }); + + test('should handle single cell ship', () => { + const cellsH = board.createShipCells(1, 5, 5, ORIENTATION.HORIZONTAL); + const cellsV = board.createShipCells(1, 5, 5, ORIENTATION.VERTICAL); + expect(cellsH).toEqual([55]); + expect(cellsV).toEqual([55]); + }); + + test('should create cells for maximum length ship', () => { + const cells = board.createShipCells(10, 0, 0, ORIENTATION.HORIZONTAL); + expect(cells).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + }); + + describe('canPlaceShip', () => { + test('should return true for valid placement on empty board', () => { + expect(board.canPlaceShip(3, 0, 0, ORIENTATION.HORIZONTAL)).toBe(true); + expect(board.canPlaceShip(5, 0, 0, ORIENTATION.VERTICAL)).toBe(true); + }); + + test('should return false when out of bounds', () => { + expect(board.canPlaceShip(3, 0, 8, ORIENTATION.HORIZONTAL)).toBe(false); + expect(board.canPlaceShip(5, 6, 0, ORIENTATION.VERTICAL)).toBe(false); + }); + + test('should return false when overlapping existing ship', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + expect(board.canPlaceShip(3, 0, 0, ORIENTATION.VERTICAL)).toBe(false); + expect(board.canPlaceShip(3, 0, 1, ORIENTATION.HORIZONTAL)).toBe(false); + }); + + test('should return true for non-overlapping placements', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + expect(board.canPlaceShip(3, 1, 0, ORIENTATION.HORIZONTAL)).toBe(true); + expect(board.canPlaceShip(3, 0, 3, ORIENTATION.HORIZONTAL)).toBe(true); + }); + + test('should handle adjacent ships correctly', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + expect(board.canPlaceShip(3, 10, ORIENTATION.HORIZONTAL)).toBe(true); + }); + }); + + describe('placeShip', () => { + test('should successfully place a ship', () => { + const result = board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + + expect(result.success).toBe(true); + expect(result.cells).toEqual([0, 1, 2]); + expect(result.ship).toBeInstanceOf(Ship); + expect(board.ships.length).toBe(1); + }); + + test('should fail when out of bounds', () => { + const result = board.placeShip(3, 8, ORIENTATION.HORIZONTAL); + + expect(result.success).toBe(false); + expect(result.reason).toBe('out_of_bounds'); + expect(board.ships.length).toBe(0); + }); + + test('should fail when overlapping', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + const result = board.placeShip(3, 0, ORIENTATION.VERTICAL); + + expect(result.success).toBe(false); + expect(result.reason).toBe('overlap'); + expect(board.ships.length).toBe(1); + }); + + test('should update occupied cells', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + + expect(board.occupiedCells.has(0)).toBe(true); + expect(board.occupiedCells.has(1)).toBe(true); + expect(board.occupiedCells.has(2)).toBe(true); + expect(board.occupiedCells.has(3)).toBe(false); + }); + + test('should update ship lookup', () => { + const result = board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + + expect(board.shipLookup.get(0)).toBe(result.ship); + expect(board.shipLookup.get(1)).toBe(result.ship); + expect(board.shipLookup.get(2)).toBe(result.ship); + expect(board.shipLookup.get(3)).toBeUndefined(); + }); + + test('should place multiple non-overlapping ships', () => { + const result1 = board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + const result2 = board.placeShip(4, 10, ORIENTATION.HORIZONTAL); + + expect(result1.success).toBe(true); + expect(result2.success).toBe(true); + expect(board.ships.length).toBe(2); + }); + + test('should handle vertical ship placement', () => { + const result = board.placeShip(4, 0, ORIENTATION.VERTICAL); + + expect(result.success).toBe(true); + expect(result.cells).toEqual([0, 10, 20, 30]); + expect(board.occupiedCells.size).toBe(4); + }); + }); + + describe('previewPlacement', () => { + test('should preview valid placement', () => { + const preview = board.previewPlacement(3, 0, ORIENTATION.HORIZONTAL); + + expect(preview.cells).toEqual([0, 1, 2]); + expect(preview.inBounds).toBe(true); + expect(preview.overlaps).toBe(false); + expect(preview.isValid).toBe(true); + }); + + test('should preview out of bounds placement', () => { + const preview = board.previewPlacement(3, 8, ORIENTATION.HORIZONTAL); + + expect(preview.inBounds).toBe(false); + expect(preview.isValid).toBe(false); + }); + + test('should preview overlapping placement', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + const preview = board.previewPlacement(3, 0, ORIENTATION.VERTICAL); + + expect(preview.overlaps).toBe(true); + expect(preview.isValid).toBe(false); + }); + + test('should preview partially out of bounds', () => { + const preview = board.previewPlacement(5, 9, ORIENTATION.HORIZONTAL); + + expect(preview.cells.length).toBeLessThan(5); + expect(preview.isValid).toBe(false); + }); + + test('should handle vertical preview', () => { + const preview = board.previewPlacement(4, 0, ORIENTATION.VERTICAL); + + expect(preview.cells).toEqual([0, 10, 20, 30]); + expect(preview.isValid).toBe(true); + }); + }); + + describe('hasBeenAttacked', () => { + test('should return false for unattacked cell', () => { + expect(board.hasBeenAttacked(0)).toBe(false); + }); + + test('should return true for attacked cell', () => { + board.receiveAttack(0); + expect(board.hasBeenAttacked(0)).toBe(true); + }); + + test('should track multiple attacked cells', () => { + board.receiveAttack(0); + board.receiveAttack(10); + board.receiveAttack(99); + + expect(board.hasBeenAttacked(0)).toBe(true); + expect(board.hasBeenAttacked(10)).toBe(true); + expect(board.hasBeenAttacked(99)).toBe(true); + expect(board.hasBeenAttacked(50)).toBe(false); + }); + }); + + describe('receiveAttack', () => { + test('should handle miss on empty cell', () => { + const result = board.receiveAttack(0); + + expect(result.hit).toBe(false); + expect(result.alreadyAttacked).toBeUndefined(); + expect(board.attackedCells.has(0)).toBe(true); + }); + + test('should handle hit on ship', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + const result = board.receiveAttack(1); + + expect(result.hit).toBe(true); + expect(result.shipSunk).toBe(false); + expect(result.ship).toBeInstanceOf(Ship); + expect(board.attackedCells.has(1)).toBe(true); + }); + + test('should detect when ship is sunk', () => { + board.placeShip(2, 0, ORIENTATION.HORIZONTAL); + + board.receiveAttack(0); + const result = board.receiveAttack(1); + + expect(result.hit).toBe(true); + expect(result.shipSunk).toBe(true); + }); + + test('should handle duplicate attack', () => { + board.receiveAttack(0); + const result = board.receiveAttack(0); + + expect(result.alreadyAttacked).toBe(true); + }); + + test('should track all attacks', () => { + board.receiveAttack(0); + board.receiveAttack(10); + board.receiveAttack(20); + + expect(board.attackedCells.size).toBe(3); + }); + + test('should handle attack sequence on ship', () => { + const placement = board.placeShip(3, 10, ORIENTATION.HORIZONTAL); + + const result1 = board.receiveAttack(10); + expect(result1.hit).toBe(true); + expect(result1.shipSunk).toBe(false); + + const result2 = board.receiveAttack(11); + expect(result2.hit).toBe(true); + expect(result2.shipSunk).toBe(false); + + const result3 = board.receiveAttack(12); + expect(result3.hit).toBe(true); + expect(result3.shipSunk).toBe(true); + }); + }); + + describe('randomUnattackedIndex', () => { + test('should return valid index on empty board', () => { + const index = board.randomUnattackedIndex(); + + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(100); + }); + + test('should not return already attacked cell', () => { + board.receiveAttack(50); + + for (let i = 0; i < 10; i++) { + const index = board.randomUnattackedIndex(); + expect(index).not.toBe(50); + } + }); + + test('should return null when all cells attacked', () => { + for (let i = 0; i < 100; i++) { + board.receiveAttack(i); + } + + const index = board.randomUnattackedIndex(); + expect(index).toBeNull(); + }); + + test('should eventually return different indices', () => { + const indices = new Set(); + for (let i = 0; i < 20; i++) { + indices.add(board.randomUnattackedIndex()); + } + + expect(indices.size).toBeGreaterThan(1); + }); + + test('should work with partially attacked board', () => { + for (let i = 0; i < 50; i++) { + board.receiveAttack(i); + } + + const index = board.randomUnattackedIndex(); + expect(index).toBeGreaterThanOrEqual(50); + expect(index).toBeLessThan(100); + }); + }); + + describe('allShipsSunk', () => { + test('should return false when no ships placed', () => { + expect(board.allShipsSunk()).toBe(false); + }); + + test('should return false when ships not sunk', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + expect(board.allShipsSunk()).toBe(false); + }); + + test('should return false when some ships sunk', () => { + board.placeShip(2, 0, ORIENTATION.HORIZONTAL); + board.placeShip(2, 10, ORIENTATION.HORIZONTAL); + + board.receiveAttack(0); + board.receiveAttack(1); + + expect(board.allShipsSunk()).toBe(false); + }); + + test('should return true when all ships sunk', () => { + board.placeShip(2, 0, ORIENTATION.HORIZONTAL); + board.placeShip(2, 10, ORIENTATION.HORIZONTAL); + + board.receiveAttack(0); + board.receiveAttack(1); + board.receiveAttack(10); + board.receiveAttack(11); + + expect(board.allShipsSunk()).toBe(true); + }); + + test('should handle single ship scenario', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + + board.receiveAttack(0); + board.receiveAttack(1); + expect(board.allShipsSunk()).toBe(false); + + board.receiveAttack(2); + expect(board.allShipsSunk()).toBe(true); + }); + }); + + describe('Integration scenarios', () => { + test('should handle complete game scenario', () => { + // Place multiple ships + board.placeShip(5, 0, ORIENTATION.HORIZONTAL); + board.placeShip(3, 20, ORIENTATION.VERTICAL); + + expect(board.ships.length).toBe(2); + + // Attack and sink first ship + for (let i = 0; i < 5; i++) { + board.receiveAttack(i); + } + + expect(board.ships[0].isSunk()).toBe(true); + expect(board.allShipsSunk()).toBe(false); + + // Sink second ship + board.receiveAttack(20); + board.receiveAttack(30); + board.receiveAttack(40); + + expect(board.allShipsSunk()).toBe(true); + }); + + test('should handle reset after complex state', () => { + board.placeShip(3, 0, ORIENTATION.HORIZONTAL); + board.placeShip(4, 10, ORIENTATION.VERTICAL); + board.receiveAttack(0); + board.receiveAttack(10); + + board.reset(); + + expect(board.ships.length).toBe(0); + expect(board.occupiedCells.size).toBe(0); + expect(board.attackedCells.size).toBe(0); + expect(board.shipLookup.size).toBe(0); + }); + + test('should handle edge case placements', () => { + // Corner placements + const corner1 = board.placeShip(1, 0, ORIENTATION.HORIZONTAL); + const corner2 = board.placeShip(1, 9, ORIENTATION.HORIZONTAL); + const corner3 = board.placeShip(1, 90, ORIENTATION.HORIZONTAL); + const corner4 = board.placeShip(1, 99, ORIENTATION.HORIZONTAL); + + expect(corner1.success).toBe(true); + expect(corner2.success).toBe(true); + expect(corner3.success).toBe(true); + expect(corner4.success).toBe(true); + expect(board.ships.length).toBe(4); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/constants.test.js b/src/__tests__/constants.test.js new file mode 100644 index 0000000..b377e97 --- /dev/null +++ b/src/__tests__/constants.test.js @@ -0,0 +1,153 @@ +import { BOARD_SIZE, SHIP_DEFINITIONS, TOTAL_SHIP_CELLS, ORIENTATION } from '../constants.js'; + +describe('constants.js', () => { + describe('BOARD_SIZE', () => { + test('should be defined and equal to 10', () => { + expect(BOARD_SIZE).toBeDefined(); + expect(BOARD_SIZE).toBe(10); + }); + + test('should be a positive integer', () => { + expect(Number.isInteger(BOARD_SIZE)).toBe(true); + expect(BOARD_SIZE).toBeGreaterThan(0); + }); + }); + + describe('SHIP_DEFINITIONS', () => { + test('should be defined and be an array', () => { + expect(SHIP_DEFINITIONS).toBeDefined(); + expect(Array.isArray(SHIP_DEFINITIONS)).toBe(true); + }); + + test('should have 4 ship types', () => { + expect(SHIP_DEFINITIONS).toHaveLength(4); + }); + + test('should have required properties for each ship', () => { + SHIP_DEFINITIONS.forEach(ship => { + expect(ship).toHaveProperty('name'); + expect(ship).toHaveProperty('length'); + expect(ship).toHaveProperty('count'); + expect(ship).toHaveProperty('counterId'); + expect(typeof ship.name).toBe('string'); + expect(typeof ship.length).toBe('number'); + expect(typeof ship.count).toBe('number'); + expect(typeof ship.counterId).toBe('string'); + }); + }); + + test('should have correct ship configurations', () => { + const battleship = SHIP_DEFINITIONS.find(s => s.name === 'Battleship'); + expect(battleship).toEqual({ + name: 'Battleship', + length: 5, + count: 1, + counterId: 'Battleship' + }); + + const destroyer = SHIP_DEFINITIONS.find(s => s.name === 'Destroyer'); + expect(destroyer).toEqual({ + name: 'Destroyer', + length: 4, + count: 2, + counterId: 'Destroyer' + }); + + const frigate = SHIP_DEFINITIONS.find(s => s.name === 'Frigate'); + expect(frigate).toEqual({ + name: 'Frigate', + length: 3, + count: 1, + counterId: 'Frigate' + }); + + const patrolShip = SHIP_DEFINITIONS.find(s => s.name === 'Patrol Ship'); + expect(patrolShip).toEqual({ + name: 'Patrol Ship', + length: 2, + count: 1, + counterId: 'Patrolship' + }); + }); + + test('should have positive lengths and counts', () => { + SHIP_DEFINITIONS.forEach(ship => { + expect(ship.length).toBeGreaterThan(0); + expect(ship.count).toBeGreaterThan(0); + }); + }); + + test('should not have ships longer than the board', () => { + SHIP_DEFINITIONS.forEach(ship => { + expect(ship.length).toBeLessThanOrEqual(BOARD_SIZE); + }); + }); + }); + + describe('TOTAL_SHIP_CELLS', () => { + test('should be defined', () => { + expect(TOTAL_SHIP_CELLS).toBeDefined(); + }); + + test('should equal the sum of all ship lengths times their counts', () => { + const expected = SHIP_DEFINITIONS.reduce( + (total, ship) => total + ship.length * ship.count, + 0 + ); + expect(TOTAL_SHIP_CELLS).toBe(expected); + }); + + test('should be 18 (5*1 + 4*2 + 3*1 + 2*1)', () => { + expect(TOTAL_SHIP_CELLS).toBe(18); + }); + + test('should be less than total board cells', () => { + const totalBoardCells = BOARD_SIZE * BOARD_SIZE; + expect(TOTAL_SHIP_CELLS).toBeLessThan(totalBoardCells); + }); + }); + + describe('ORIENTATION', () => { + test('should be defined', () => { + expect(ORIENTATION).toBeDefined(); + }); + + test('should have HORIZONTAL and VERTICAL properties', () => { + expect(ORIENTATION).toHaveProperty('HORIZONTAL'); + expect(ORIENTATION).toHaveProperty('VERTICAL'); + }); + + test('should have correct string values', () => { + expect(ORIENTATION.HORIZONTAL).toBe('horizontal'); + expect(ORIENTATION.VERTICAL).toBe('vertical'); + }); + + test('should have distinct orientation values', () => { + expect(ORIENTATION.HORIZONTAL).not.toBe(ORIENTATION.VERTICAL); + }); + + test('should be immutable at runtime', () => { + const original = { ...ORIENTATION }; + expect(() => { + ORIENTATION.HORIZONTAL = 'changed'; + }).not.toThrow(); + // Even though we can reassign, test the original values exist + expect(original.HORIZONTAL).toBe('horizontal'); + expect(original.VERTICAL).toBe('vertical'); + }); + }); + + describe('Constants Integration', () => { + test('should have valid game configuration', () => { + const totalShips = SHIP_DEFINITIONS.reduce((sum, ship) => sum + ship.count, 0); + expect(totalShips).toBeGreaterThan(0); + expect(TOTAL_SHIP_CELLS).toBeLessThan(BOARD_SIZE * BOARD_SIZE); + }); + + test('should allow all ships to theoretically fit on board', () => { + // Maximum space needed is if all ships are placed horizontally or vertically + const maxShipLength = Math.max(...SHIP_DEFINITIONS.map(s => s.length)); + expect(maxShipLength).toBeLessThanOrEqual(BOARD_SIZE); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/game.test.js b/src/__tests__/game.test.js new file mode 100644 index 0000000..7ffa7de --- /dev/null +++ b/src/__tests__/game.test.js @@ -0,0 +1,810 @@ +import { BattleshipGame } from '../game.js'; +import { Board } from '../board.js'; +import { AIController } from '../ai-controller.js'; +import { ORIENTATION, SHIP_DEFINITIONS } from '../constants.js'; + +describe('BattleshipGame', () => { + let mockUI; + let game; + + beforeEach(() => { + // Create comprehensive mock UI + mockUI = { + clearMessage: jest.fn(), + clearAnnouncement: jest.fn(), + clearPlacementPreview: jest.fn(), + setMessage: jest.fn(), + updateShipCount: jest.fn(), + showPlacementPreview: jest.fn(), + renderShip: jest.fn(), + prepareForBattle: jest.fn(), + renderHit: jest.fn(), + renderMiss: jest.fn(), + showVictory: jest.fn(), + showDefeat: jest.fn(), + }; + + game = new BattleshipGame(mockUI); + }); + + describe('constructor and initialization', () => { + test('should store UI controller reference', () => { + expect(game.ui).toBe(mockUI); + }); + + test('should initialize player board', () => { + expect(game.playerBoard).toBeInstanceOf(Board); + }); + + test('should initialize CPU board', () => { + expect(game.cpuBoard).toBeInstanceOf(Board); + }); + + test('should initialize AI controller', () => { + expect(game.ai).toBeInstanceOf(AIController); + }); + + test('should initialize with horizontal orientation', () => { + expect(game.orientation).toBe(ORIENTATION.HORIZONTAL); + }); + + test('should initialize ship inventory from definitions', () => { + expect(game.shipInventory).toBeDefined(); + expect(game.shipInventory.length).toBe(SHIP_DEFINITIONS.length); + }); + + test('should set correct initial ships to place', () => { + const expectedShipsToPlace = SHIP_DEFINITIONS.reduce( + (total, ship) => total + ship.count, + 0 + ); + expect(game.shipsToPlace).toBe(expectedShipsToPlace); + }); + + test('should initialize with game not started', () => { + expect(game.gameStarted).toBe(false); + }); + + test('should initialize hit counters to zero', () => { + expect(game.playerHits).toBe(0); + expect(game.cpuHits).toBe(0); + }); + + test('should call UI initialization methods', () => { + expect(mockUI.clearMessage).toHaveBeenCalled(); + expect(mockUI.clearAnnouncement).toHaveBeenCalled(); + expect(mockUI.clearPlacementPreview).toHaveBeenCalled(); + }); + + test('should initialize ship counts in UI', () => { + SHIP_DEFINITIONS.forEach(ship => { + expect(mockUI.updateShipCount).toHaveBeenCalledWith( + ship.counterId, + ship.count + ); + }); + }); + }); + + describe('initializeState', () => { + test('should reset all boards', () => { + game.playerBoard.placeShip(3, 0, ORIENTATION.HORIZONTAL); + game.cpuBoard.placeShip(3, 0, ORIENTATION.HORIZONTAL); + + game.initializeState(); + + expect(game.playerBoard.ships.length).toBe(0); + expect(game.cpuBoard.ships.length).toBe(0); + }); + + test('should reset ship inventory', () => { + game.shipInventory[0].remaining = 0; + game.initializeState(); + + game.shipInventory.forEach((ship, index) => { + expect(ship.remaining).toBe(SHIP_DEFINITIONS[index].count); + }); + }); + + test('should reset orientation to horizontal', () => { + game.orientation = ORIENTATION.VERTICAL; + game.initializeState(); + expect(game.orientation).toBe(ORIENTATION.HORIZONTAL); + }); + + test('should reset selected ship length', () => { + game.selectedShipLength = 5; + game.initializeState(); + expect(game.selectedShipLength).toBe(0); + }); + + test('should reset game started flag', () => { + game.gameStarted = true; + game.initializeState(); + expect(game.gameStarted).toBe(false); + }); + + test('should reset hit counters', () => { + game.playerHits = 10; + game.cpuHits = 5; + game.initializeState(); + + expect(game.playerHits).toBe(0); + expect(game.cpuHits).toBe(0); + }); + }); + + describe('getShipSpec', () => { + test('should return ship specification by length', () => { + const spec = game.getShipSpec(5); + expect(spec).toBeDefined(); + expect(spec.length).toBe(5); + expect(spec.name).toBe('Battleship'); + }); + + test('should return undefined for invalid length', () => { + const spec = game.getShipSpec(99); + expect(spec).toBeUndefined(); + }); + + test('should return first matching ship for duplicate lengths', () => { + const spec = game.getShipSpec(4); + expect(spec).toBeDefined(); + expect(spec.length).toBe(4); + }); + + test('should handle zero length', () => { + const spec = game.getShipSpec(0); + expect(spec).toBeUndefined(); + }); + + test('should handle negative length', () => { + const spec = game.getShipSpec(-1); + expect(spec).toBeUndefined(); + }); + }); + + describe('setOrientation', () => { + test('should set orientation to vertical', () => { + game.setOrientation(ORIENTATION.VERTICAL); + expect(game.orientation).toBe(ORIENTATION.VERTICAL); + }); + + test('should set orientation to horizontal', () => { + game.setOrientation(ORIENTATION.HORIZONTAL); + expect(game.orientation).toBe(ORIENTATION.HORIZONTAL); + }); + + test('should clear placement preview', () => { + mockUI.clearPlacementPreview.mockClear(); + game.setOrientation(ORIENTATION.VERTICAL); + expect(mockUI.clearPlacementPreview).toHaveBeenCalled(); + }); + + test('should display vertical message', () => { + game.setOrientation(ORIENTATION.VERTICAL); + expect(mockUI.setMessage).toHaveBeenCalledWith('Orientation set to Vertical.'); + }); + + test('should display horizontal message', () => { + game.setOrientation(ORIENTATION.HORIZONTAL); + expect(mockUI.setMessage).toHaveBeenCalledWith('Orientation set to Horizontal.'); + }); + + test('should handle rapid orientation changes', () => { + game.setOrientation(ORIENTATION.VERTICAL); + game.setOrientation(ORIENTATION.HORIZONTAL); + game.setOrientation(ORIENTATION.VERTICAL); + expect(game.orientation).toBe(ORIENTATION.VERTICAL); + }); + }); + + describe('selectShip', () => { + test('should select ship with valid length', () => { + game.selectShip(5); + expect(game.selectedShipLength).toBe(5); + }); + + test('should display selection message', () => { + game.selectShip(5); + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('Battleship selected') + ); + }); + + test('should show remaining count in message', () => { + game.selectShip(5); + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('1 remaining') + ); + }); + + test('should reject selection of depleted ship', () => { + const ship = game.getShipSpec(5); + ship.remaining = 0; + + game.selectShip(5); + + expect(game.selectedShipLength).toBe(0); + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('All Battleships placed') + ); + }); + + test('should clear preview on selection', () => { + mockUI.clearPlacementPreview.mockClear(); + game.selectShip(5); + expect(mockUI.clearPlacementPreview).toHaveBeenCalled(); + }); + + test('should handle unknown ship length', () => { + game.selectShip(99); + expect(mockUI.setMessage).toHaveBeenCalledWith('Unknown ship selection.'); + }); + + test('should allow reselecting same ship', () => { + game.selectShip(5); + game.selectShip(5); + expect(game.selectedShipLength).toBe(5); + }); + }); + + describe('previewPlayerPlacement', () => { + test('should show preview when ship selected', () => { + game.selectShip(3); + game.previewPlayerPlacement(0); + + expect(mockUI.showPlacementPreview).toHaveBeenCalled(); + }); + + test('should not show preview when no ship selected', () => { + game.previewPlayerPlacement(0); + expect(mockUI.showPlacementPreview).not.toHaveBeenCalled(); + }); + + test('should not show preview when game started', () => { + game.selectShip(3); + game.gameStarted = true; + + game.previewPlayerPlacement(0); + + expect(mockUI.showPlacementPreview).not.toHaveBeenCalled(); + }); + + test('should not show preview for depleted ship', () => { + const ship = game.getShipSpec(3); + ship.remaining = 0; + game.selectedShipLength = 3; + + game.previewPlayerPlacement(0); + + expect(mockUI.showPlacementPreview).not.toHaveBeenCalled(); + }); + + test('should show valid preview for good placement', () => { + game.selectShip(3); + game.previewPlayerPlacement(0); + + expect(mockUI.showPlacementPreview).toHaveBeenCalledWith( + expect.any(Array), + true + ); + }); + + test('should show invalid preview for out of bounds', () => { + game.selectShip(5); + game.previewPlayerPlacement(8); + + expect(mockUI.showPlacementPreview).toHaveBeenCalledWith( + expect.any(Array), + false + ); + }); + + test('should handle different orientations', () => { + game.setOrientation(ORIENTATION.VERTICAL); + game.selectShip(3); + game.previewPlayerPlacement(0); + + expect(mockUI.showPlacementPreview).toHaveBeenCalled(); + }); + }); + + describe('clearPlacementPreview', () => { + test('should call UI clearPlacementPreview', () => { + game.clearPlacementPreview(); + expect(mockUI.clearPlacementPreview).toHaveBeenCalled(); + }); + + test('should be callable multiple times', () => { + game.clearPlacementPreview(); + game.clearPlacementPreview(); + expect(mockUI.clearPlacementPreview).toHaveBeenCalledTimes(2); + }); + }); + + describe('placePlayerShip', () => { + test('should successfully place ship', () => { + game.selectShip(3); + game.placePlayerShip(0); + + expect(game.playerBoard.ships.length).toBe(1); + expect(mockUI.renderShip).toHaveBeenCalled(); + }); + + test('should decrease ship count', () => { + game.selectShip(3); + const initialRemaining = game.getShipSpec(3).remaining; + + game.placePlayerShip(0); + + expect(game.getShipSpec(3).remaining).toBe(initialRemaining - 1); + }); + + test('should update UI ship count', () => { + mockUI.updateShipCount.mockClear(); + game.selectShip(3); + game.placePlayerShip(0); + + expect(mockUI.updateShipCount).toHaveBeenCalled(); + }); + + test('should decrease ships to place counter', () => { + game.selectShip(3); + const initialCount = game.shipsToPlace; + + game.placePlayerShip(0); + + expect(game.shipsToPlace).toBe(initialCount - 1); + }); + + test('should reject placement when no ship selected', () => { + game.placePlayerShip(0); + + expect(game.playerBoard.ships.length).toBe(0); + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('Select a ship') + ); + }); + + test('should reject placement after game started', () => { + game.selectShip(3); + game.gameStarted = true; + + game.placePlayerShip(0); + + expect(game.playerBoard.ships.length).toBe(0); + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('Cannot place ships after') + ); + }); + + test('should reject out of bounds placement', () => { + game.selectShip(5); + game.placePlayerShip(8); + + expect(game.playerBoard.ships.length).toBe(0); + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('out of bounds') + ); + }); + + test('should reject overlapping placement', () => { + game.selectShip(3); + game.placePlayerShip(0); + game.placePlayerShip(0); + + expect(game.playerBoard.ships.length).toBe(1); + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('overlaps') + ); + }); + + test('should deselect ship when all placed', () => { + game.selectShip(3); // Frigate has count 1 + game.placePlayerShip(0); + + expect(game.selectedShipLength).toBe(0); + }); + + test('should not deselect when more ships available', () => { + game.selectShip(4); // Destroyer has count 2 + game.placePlayerShip(0); + + expect(game.selectedShipLength).toBe(4); + }); + + test('should handle vertical placement', () => { + game.setOrientation(ORIENTATION.VERTICAL); + game.selectShip(3); + game.placePlayerShip(0); + + expect(game.playerBoard.ships.length).toBe(1); + }); + }); + + describe('canStartGame', () => { + test('should return false initially', () => { + expect(game.canStartGame()).toBe(false); + }); + + test('should return true when all ships placed', () => { + game.shipsToPlace = 0; + expect(game.canStartGame()).toBe(true); + }); + + test('should return false with ships remaining', () => { + game.shipsToPlace = 1; + expect(game.canStartGame()).toBe(false); + }); + }); + + describe('startGame', () => { + beforeEach(() => { + // Place all required ships + game.shipsToPlace = 0; + }); + + test('should start game when all ships placed', () => { + game.startGame(); + expect(game.gameStarted).toBe(true); + }); + + test('should deploy CPU fleet', () => { + game.startGame(); + expect(game.cpuBoard.ships.length).toBeGreaterThan(0); + }); + + test('should call prepareForBattle on UI', () => { + game.startGame(); + expect(mockUI.prepareForBattle).toHaveBeenCalled(); + }); + + test('should display start message', () => { + game.startGame(); + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('Game started') + ); + }); + + test('should reject start when ships not placed', () => { + game.shipsToPlace = 1; + game.startGame(); + + expect(game.gameStarted).toBe(false); + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('Place all ships') + ); + }); + + test('should reject start when already started', () => { + game.startGame(); + mockUI.setMessage.mockClear(); + game.startGame(); + + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('already in progress') + ); + }); + + test('should clear placement preview', () => { + mockUI.clearPlacementPreview.mockClear(); + game.startGame(); + expect(mockUI.clearPlacementPreview).toHaveBeenCalled(); + }); + }); + + describe('playerAttack', () => { + beforeEach(() => { + game.shipsToPlace = 0; + game.startGame(); + }); + + test('should reject attack before game started', () => { + game.gameStarted = false; + game.playerAttack(0); + + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('not started') + ); + }); + + test('should handle miss', () => { + game.playerAttack(0); + expect(mockUI.renderMiss).toHaveBeenCalledWith('cpu', 0); + }); + + test('should handle hit', () => { + // Force a ship at position 0 + game.cpuBoard.reset(); + game.cpuBoard.placeShip(3, 0, ORIENTATION.HORIZONTAL); + + game.playerAttack(0); + + expect(mockUI.renderHit).toHaveBeenCalledWith('cpu', 0); + }); + + test('should increment hit counter on hit', () => { + game.cpuBoard.reset(); + game.cpuBoard.placeShip(3, 0, ORIENTATION.HORIZONTAL); + + const initialHits = game.playerHits; + game.playerAttack(0); + + expect(game.playerHits).toBe(initialHits + 1); + }); + + test('should reject duplicate attack', () => { + game.playerAttack(0); + mockUI.setMessage.mockClear(); + game.playerAttack(0); + + expect(mockUI.setMessage).toHaveBeenCalledWith( + expect.stringContaining('already fired') + ); + }); + + test('should trigger CPU turn after player attack', () => { + const spyReceiveAttack = jest.spyOn(game.playerBoard, 'receiveAttack'); + game.playerAttack(0); + + expect(spyReceiveAttack).toHaveBeenCalled(); + }); + + test('should detect player victory', () => { + // Manually set up victory condition + game.cpuBoard.reset(); + game.cpuBoard.placeShip(2, 0, ORIENTATION.HORIZONTAL); + + game.playerAttack(0); + game.playerAttack(1); + + expect(mockUI.showVictory).toHaveBeenCalled(); + expect(game.gameStarted).toBe(false); + }); + + test('should not trigger CPU turn after victory', () => { + game.cpuBoard.reset(); + game.cpuBoard.placeShip(2, 0, ORIENTATION.HORIZONTAL); + + const spyCpuTurn = jest.spyOn(game, 'cpuTurn'); + game.playerAttack(0); + game.playerAttack(1); + + // First attack triggers CPU turn, second (victory) should not + expect(spyCpuTurn).toHaveBeenCalledTimes(1); + }); + }); + + describe('cpuTurn', () => { + beforeEach(() => { + game.shipsToPlace = 0; + game.startGame(); + }); + + test('should attack player board', () => { + const spyReceiveAttack = jest.spyOn(game.playerBoard, 'receiveAttack'); + game.cpuTurn(); + expect(spyReceiveAttack).toHaveBeenCalled(); + }); + + test('should render hit on player board', () => { + game.playerBoard.placeShip(3, 0, ORIENTATION.HORIZONTAL); + + // Keep attacking until we hit + for (let i = 0; i < 10; i++) { + mockUI.renderHit.mockClear(); + game.cpuTurn(); + if (mockUI.renderHit.mock.calls.length > 0) { + expect(mockUI.renderHit).toHaveBeenCalledWith('player', expect.any(Number)); + break; + } + } + }); + + test('should render miss on player board', () => { + mockUI.renderMiss.mockClear(); + game.cpuTurn(); + + if (mockUI.renderMiss.mock.calls.length > 0) { + expect(mockUI.renderMiss).toHaveBeenCalledWith('player', expect.any(Number)); + } + }); + + test('should increment CPU hit counter on hit', () => { + game.playerBoard.placeShip(3, 55, ORIENTATION.HORIZONTAL); + + // Manually trigger hit + jest.spyOn(game.ai, 'nextAttack').mockReturnValue(55); + + const initialHits = game.cpuHits; + game.cpuTurn(); + + expect(game.cpuHits).toBe(initialHits + 1); + }); + + test('should detect CPU victory', () => { + game.playerBoard.reset(); + game.playerBoard.placeShip(2, 50, ORIENTATION.HORIZONTAL); + + jest.spyOn(game.ai, 'nextAttack') + .mockReturnValueOnce(50) + .mockReturnValueOnce(51); + + game.cpuTurn(); + game.cpuTurn(); + + expect(mockUI.showDefeat).toHaveBeenCalled(); + expect(game.gameStarted).toBe(false); + }); + + test('should not execute when game not started', () => { + game.gameStarted = false; + const spyReceiveAttack = jest.spyOn(game.playerBoard, 'receiveAttack'); + + game.cpuTurn(); + + expect(spyReceiveAttack).not.toHaveBeenCalled(); + }); + + test('should handle null target from AI', () => { + jest.spyOn(game.ai, 'nextAttack').mockReturnValue(null); + + expect(() => game.cpuTurn()).not.toThrow(); + }); + }); + + describe('deployCpuFleet', () => { + test('should place all required ships', () => { + game.deployCpuFleet(); + + const expectedShipCount = SHIP_DEFINITIONS.reduce( + (total, ship) => total + ship.count, + 0 + ); + expect(game.cpuBoard.ships.length).toBe(expectedShipCount); + }); + + test('should place ships with correct lengths', () => { + game.deployCpuFleet(); + + SHIP_DEFINITIONS.forEach(shipDef => { + const matchingShips = game.cpuBoard.ships.filter( + ship => ship.length === shipDef.length + ); + expect(matchingShips.length).toBeGreaterThanOrEqual(shipDef.count); + }); + }); + + test('should reset board before placement', () => { + game.cpuBoard.placeShip(3, 0, ORIENTATION.HORIZONTAL); + game.deployCpuFleet(); + + const expectedShipCount = SHIP_DEFINITIONS.reduce( + (total, ship) => total + ship.count, + 0 + ); + expect(game.cpuBoard.ships.length).toBe(expectedShipCount); + }); + + test('should reset AI after deployment', () => { + game.ai.focusQueue.push(1, 2, 3); + game.deployCpuFleet(); + + expect(game.ai.focusQueue.length).toBe(0); + }); + + test('should place ships without overlap', () => { + game.deployCpuFleet(); + + const allCells = new Set(); + game.cpuBoard.ships.forEach(ship => { + ship.cells.forEach(cell => { + expect(allCells.has(cell)).toBe(false); + allCells.add(cell); + }); + }); + }); + }); + + describe('randomIndex', () => { + test('should return valid board index', () => { + const index = game.randomIndex(); + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(100); + }); + + test('should return different values over multiple calls', () => { + const indices = new Set(); + for (let i = 0; i < 50; i++) { + indices.add(game.randomIndex()); + } + expect(indices.size).toBeGreaterThan(1); + }); + }); + + describe('Integration scenarios', () => { + test('should handle complete game flow', () => { + // Place all player ships + game.selectShip(5); + game.placePlayerShip(0); + + game.selectShip(4); + game.placePlayerShip(10); + game.placePlayerShip(20); + + game.selectShip(3); + game.placePlayerShip(30); + + game.selectShip(2); + game.placePlayerShip(40); + + // Start game + expect(game.canStartGame()).toBe(true); + game.startGame(); + expect(game.gameStarted).toBe(true); + + // Make an attack + game.playerAttack(50); + + expect(game.cpuBoard.attackedCells.has(50)).toBe(true); + }); + + test('should handle ship placement errors gracefully', () => { + game.selectShip(3); + game.placePlayerShip(0); + + // Try to place overlapping + game.selectShip(3); + game.placePlayerShip(0); + + // Should still be in valid state + expect(game.playerBoard.ships.length).toBe(1); + }); + + test('should maintain game state consistency', () => { + const initialShipsToPlace = game.shipsToPlace; + + game.selectShip(5); + game.placePlayerShip(0); + + expect(game.shipsToPlace).toBe(initialShipsToPlace - 1); + expect(game.playerBoard.ships.length).toBe(1); + + const ship = game.getShipSpec(5); + expect(ship.remaining).toBe(0); + }); + }); + + describe('Edge cases', () => { + test('should handle rapid ship selections', () => { + game.selectShip(5); + game.selectShip(4); + game.selectShip(3); + + expect(game.selectedShipLength).toBe(3); + }); + + test('should handle placement attempts without selection', () => { + game.selectedShipLength = 0; + game.placePlayerShip(0); + + expect(game.playerBoard.ships.length).toBe(0); + }); + + test('should handle attacks on invalid indices gracefully', () => { + game.shipsToPlace = 0; + game.startGame(); + + expect(() => game.playerAttack(-1)).not.toThrow(); + expect(() => game.playerAttack(100)).not.toThrow(); + }); + + test('should handle preview on boundary cells', () => { + game.selectShip(3); + + expect(() => game.previewPlayerPlacement(0)).not.toThrow(); + expect(() => game.previewPlayerPlacement(99)).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/ship.test.js b/src/__tests__/ship.test.js new file mode 100644 index 0000000..f2202c0 --- /dev/null +++ b/src/__tests__/ship.test.js @@ -0,0 +1,275 @@ +import { Ship } from '../ship.js'; + +describe('Ship', () => { + describe('constructor', () => { + test('should create a ship with given length and cells', () => { + const cells = [0, 1, 2]; + const ship = new Ship(3, cells); + + expect(ship.length).toBe(3); + expect(ship.cells).toBeInstanceOf(Set); + expect(ship.cells.size).toBe(3); + expect(ship.hits).toBeInstanceOf(Set); + expect(ship.hits.size).toBe(0); + }); + + test('should assign unique IDs to different ships', () => { + const ship1 = new Ship(3, [0, 1, 2]); + const ship2 = new Ship(4, [10, 11, 12, 13]); + + expect(ship1.id).toBeDefined(); + expect(ship2.id).toBeDefined(); + expect(ship1.id).not.toBe(ship2.id); + expect(ship1.id).toMatch(/^ship-\d+$/); + expect(ship2.id).toMatch(/^ship-\d+$/); + }); + + test('should handle empty cell array', () => { + const ship = new Ship(0, []); + + expect(ship.length).toBe(0); + expect(ship.cells.size).toBe(0); + }); + + test('should handle single cell ship', () => { + const ship = new Ship(1, [42]); + + expect(ship.length).toBe(1); + expect(ship.cells.size).toBe(1); + expect(ship.cells.has(42)).toBe(true); + }); + + test('should handle duplicate cells in input array', () => { + const ship = new Ship(3, [5, 5, 5]); + + expect(ship.cells.size).toBe(1); + expect(ship.cells.has(5)).toBe(true); + }); + + test('should handle large ship', () => { + const cells = Array.from({ length: 10 }, (_, i) => i); + const ship = new Ship(10, cells); + + expect(ship.length).toBe(10); + expect(ship.cells.size).toBe(10); + }); + }); + + describe('occupies', () => { + test('should return true for cells the ship occupies', () => { + const ship = new Ship(3, [10, 11, 12]); + + expect(ship.occupies(10)).toBe(true); + expect(ship.occupies(11)).toBe(true); + expect(ship.occupies(12)).toBe(true); + }); + + test('should return false for cells the ship does not occupy', () => { + const ship = new Ship(3, [10, 11, 12]); + + expect(ship.occupies(0)).toBe(false); + expect(ship.occupies(9)).toBe(false); + expect(ship.occupies(13)).toBe(false); + expect(ship.occupies(99)).toBe(false); + }); + + test('should return false for negative indices', () => { + const ship = new Ship(3, [10, 11, 12]); + + expect(ship.occupies(-1)).toBe(false); + expect(ship.occupies(-10)).toBe(false); + }); + + test('should return false for undefined or null', () => { + const ship = new Ship(3, [10, 11, 12]); + + expect(ship.occupies(undefined)).toBe(false); + expect(ship.occupies(null)).toBe(false); + }); + + test('should work with single cell ship', () => { + const ship = new Ship(1, [5]); + + expect(ship.occupies(5)).toBe(true); + expect(ship.occupies(4)).toBe(false); + expect(ship.occupies(6)).toBe(false); + }); + }); + + describe('recordHit', () => { + test('should record hit on occupied cell', () => { + const ship = new Ship(3, [10, 11, 12]); + + ship.recordHit(11); + + expect(ship.hits.size).toBe(1); + expect(ship.hits.has(11)).toBe(true); + }); + + test('should record multiple hits on different cells', () => { + const ship = new Ship(3, [10, 11, 12]); + + ship.recordHit(10); + ship.recordHit(12); + + expect(ship.hits.size).toBe(2); + expect(ship.hits.has(10)).toBe(true); + expect(ship.hits.has(12)).toBe(true); + expect(ship.hits.has(11)).toBe(false); + }); + + test('should ignore hit on non-occupied cell', () => { + const ship = new Ship(3, [10, 11, 12]); + + ship.recordHit(99); + + expect(ship.hits.size).toBe(0); + }); + + test('should handle duplicate hits on same cell', () => { + const ship = new Ship(3, [10, 11, 12]); + + ship.recordHit(11); + ship.recordHit(11); + ship.recordHit(11); + + expect(ship.hits.size).toBe(1); + expect(ship.hits.has(11)).toBe(true); + }); + + test('should not record hit on negative index', () => { + const ship = new Ship(3, [10, 11, 12]); + + ship.recordHit(-1); + + expect(ship.hits.size).toBe(0); + }); + + test('should handle all cells hit', () => { + const ship = new Ship(3, [10, 11, 12]); + + ship.recordHit(10); + ship.recordHit(11); + ship.recordHit(12); + + expect(ship.hits.size).toBe(3); + expect(ship.hits.has(10)).toBe(true); + expect(ship.hits.has(11)).toBe(true); + expect(ship.hits.has(12)).toBe(true); + }); + }); + + describe('isSunk', () => { + test('should return false when no hits recorded', () => { + const ship = new Ship(3, [10, 11, 12]); + + expect(ship.isSunk()).toBe(false); + }); + + test('should return false when partially hit', () => { + const ship = new Ship(3, [10, 11, 12]); + + ship.recordHit(10); + expect(ship.isSunk()).toBe(false); + + ship.recordHit(11); + expect(ship.isSunk()).toBe(false); + }); + + test('should return true when all cells are hit', () => { + const ship = new Ship(3, [10, 11, 12]); + + ship.recordHit(10); + ship.recordHit(11); + ship.recordHit(12); + + expect(ship.isSunk()).toBe(true); + }); + + test('should return true for single-cell ship when hit', () => { + const ship = new Ship(1, [5]); + + ship.recordHit(5); + + expect(ship.isSunk()).toBe(true); + }); + + test('should handle empty ship', () => { + const ship = new Ship(0, []); + + expect(ship.isSunk()).toBe(true); + }); + + test('should remain sunk after additional invalid hits', () => { + const ship = new Ship(2, [10, 11]); + + ship.recordHit(10); + ship.recordHit(11); + expect(ship.isSunk()).toBe(true); + + ship.recordHit(99); + expect(ship.isSunk()).toBe(true); + }); + + test('should handle large ship sinking', () => { + const cells = [0, 10, 20, 30, 40]; + const ship = new Ship(5, cells); + + cells.forEach((cell, index) => { + if (index < cells.length - 1) { + ship.recordHit(cell); + expect(ship.isSunk()).toBe(false); + } + }); + + ship.recordHit(cells[cells.length - 1]); + expect(ship.isSunk()).toBe(true); + }); + }); + + describe('Edge cases and integration', () => { + test('should maintain state across multiple operations', () => { + const ship = new Ship(4, [20, 21, 22, 23]); + + expect(ship.occupies(21)).toBe(true); + expect(ship.isSunk()).toBe(false); + + ship.recordHit(20); + expect(ship.occupies(21)).toBe(true); + expect(ship.isSunk()).toBe(false); + + ship.recordHit(21); + ship.recordHit(22); + expect(ship.isSunk()).toBe(false); + + ship.recordHit(23); + expect(ship.isSunk()).toBe(true); + expect(ship.occupies(21)).toBe(true); + }); + + test('should handle non-contiguous cells', () => { + const ship = new Ship(3, [1, 5, 9]); + + expect(ship.occupies(1)).toBe(true); + expect(ship.occupies(5)).toBe(true); + expect(ship.occupies(9)).toBe(true); + expect(ship.occupies(2)).toBe(false); + + ship.recordHit(1); + ship.recordHit(5); + ship.recordHit(9); + + expect(ship.isSunk()).toBe(true); + }); + + test('should not be affected by hits to non-occupied adjacent cells', () => { + const ship = new Ship(3, [10, 11, 12]); + + ship.recordHit(9); + ship.recordHit(13); + + expect(ship.hits.size).toBe(0); + expect(ship.isSunk()).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/ui-controller.test.js b/src/__tests__/ui-controller.test.js new file mode 100644 index 0000000..18fdf6e --- /dev/null +++ b/src/__tests__/ui-controller.test.js @@ -0,0 +1,559 @@ +import { UIController } from '../ui-controller.js'; + +describe('UIController', () => { + let mockDoc; + let ui; + + beforeEach(() => { + // Create mock DOM elements + mockDoc = { + querySelector: jest.fn(), + querySelectorAll: jest.fn(), + getElementById: jest.fn(), + }; + + const mockMessageEl = { textContent: '' }; + const mockAnnouncementEl = { innerHTML: '' }; + const mockSelectionWrapper = { remove: jest.fn() }; + const mockHeaderEl = {}; + const mockContainerMain = { classList: { add: jest.fn() } }; + + mockDoc.querySelector.mockImplementation((selector) => { + if (selector === '.message') return mockMessageEl; + if (selector === '.container-main') return mockContainerMain; + return null; + }); + + mockDoc.getElementById.mockImplementation((id) => { + if (id === 'announce') return mockAnnouncementEl; + if (id === 'remove-on-start') return mockSelectionWrapper; + if (id === 'header') return mockHeaderEl; + return null; + }); + + ui = new UIController(mockDoc); + }); + + describe('constructor', () => { + test('should store document reference', () => { + expect(ui.doc).toBe(mockDoc); + }); + + test('should query and store message element', () => { + expect(mockDoc.querySelector).toHaveBeenCalledWith('.message'); + expect(ui.messageEl).toBeDefined(); + }); + + test('should query and store announcement element', () => { + expect(mockDoc.getElementById).toHaveBeenCalledWith('announce'); + expect(ui.announcementEl).toBeDefined(); + }); + + test('should initialize with empty previewedCells map', () => { + expect(ui.previewedCells).toBeInstanceOf(Map); + expect(ui.previewedCells.size).toBe(0); + }); + + test('should handle missing DOM elements gracefully', () => { + mockDoc.querySelector.mockReturnValue(null); + mockDoc.getElementById.mockReturnValue(null); + + expect(() => new UIController(mockDoc)).not.toThrow(); + }); + + test('should use document as default parameter', () => { + const uiWithDefault = new UIController(); + expect(uiWithDefault.doc).toBeDefined(); + }); + }); + + describe('setMessage', () => { + test('should set text content of message element', () => { + ui.setMessage('Test message'); + expect(ui.messageEl.textContent).toBe('Test message'); + }); + + test('should handle empty string', () => { + ui.setMessage(''); + expect(ui.messageEl.textContent).toBe(''); + }); + + test('should handle null message', () => { + ui.setMessage(null); + expect(ui.messageEl.textContent).toBe(''); + }); + + test('should handle undefined message', () => { + ui.setMessage(undefined); + expect(ui.messageEl.textContent).toBe(''); + }); + + test('should handle long messages', () => { + const longMessage = 'A'.repeat(1000); + ui.setMessage(longMessage); + expect(ui.messageEl.textContent).toBe(longMessage); + }); + + test('should handle special characters', () => { + const specialMessage = ''; + ui.setMessage(specialMessage); + expect(ui.messageEl.textContent).toBe(specialMessage); + }); + + test('should do nothing if message element is null', () => { + ui.messageEl = null; + expect(() => ui.setMessage('test')).not.toThrow(); + }); + }); + + describe('clearMessage', () => { + test('should clear message text', () => { + ui.messageEl.textContent = 'Some message'; + ui.clearMessage(); + expect(ui.messageEl.textContent).toBe(''); + }); + + test('should be idempotent', () => { + ui.clearMessage(); + ui.clearMessage(); + expect(ui.messageEl.textContent).toBe(''); + }); + }); + + describe('updateShipCount', () => { + test('should update ship count element', () => { + const mockCounterEl = { textContent: '' }; + mockDoc.getElementById.mockReturnValue(mockCounterEl); + + ui.updateShipCount('Battleship', 2); + + expect(mockCounterEl.textContent).toBe(' 2'); + }); + + test('should handle zero count', () => { + const mockCounterEl = { textContent: '' }; + mockDoc.getElementById.mockReturnValue(mockCounterEl); + + ui.updateShipCount('Destroyer', 0); + + expect(mockCounterEl.textContent).toBe(' 0'); + }); + + test('should handle missing element', () => { + mockDoc.getElementById.mockReturnValue(null); + + expect(() => ui.updateShipCount('Invalid', 5)).not.toThrow(); + }); + + test('should handle negative count', () => { + const mockCounterEl = { textContent: '' }; + mockDoc.getElementById.mockReturnValue(mockCounterEl); + + ui.updateShipCount('Ship', -1); + + expect(mockCounterEl.textContent).toBe(' -1'); + }); + + test('should format with leading space', () => { + const mockCounterEl = { textContent: '' }; + mockDoc.getElementById.mockReturnValue(mockCounterEl); + + ui.updateShipCount('Ship', 3); + + expect(mockCounterEl.textContent).toMatch(/^ \d+$/); + }); + }); + + describe('prepareForBattle', () => { + test('should clear placement preview', () => { + ui.previewedCells.set(0, 'preview-valid'); + const mockSquare = { classList: { remove: jest.fn() } }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.prepareForBattle(); + + expect(ui.previewedCells.size).toBe(0); + }); + + test('should remove selection wrapper', () => { + ui.prepareForBattle(); + + expect(ui.selectionWrapper.remove).toHaveBeenCalled(); + }); + + test('should add battle-mode class to container', () => { + ui.prepareForBattle(); + + expect(ui.containerMain.classList.add).toHaveBeenCalledWith('battle-mode'); + }); + + test('should set selectionWrapper to null after removal', () => { + ui.prepareForBattle(); + + expect(ui.selectionWrapper).toBeNull(); + }); + + test('should handle missing selectionWrapper', () => { + ui.selectionWrapper = null; + + expect(() => ui.prepareForBattle()).not.toThrow(); + }); + + test('should handle missing containerMain', () => { + ui.containerMain = null; + + expect(() => ui.prepareForBattle()).not.toThrow(); + }); + }); + + describe('showVictory', () => { + test('should display victory message', () => { + ui.showVictory(); + + expect(ui.announcementEl.innerHTML).toContain('YOU WIN!'); + }); + + test('should include retry button', () => { + ui.showVictory(); + + expect(ui.announcementEl.innerHTML).toContain('RETRY'); + }); + + test('should have onclick handler for retry', () => { + ui.showVictory(); + + expect(ui.announcementEl.innerHTML).toContain('location.reload()'); + }); + + test('should handle missing announcement element', () => { + ui.announcementEl = null; + + expect(() => ui.showVictory()).not.toThrow(); + }); + }); + + describe('showDefeat', () => { + test('should display defeat message', () => { + ui.showDefeat(); + + expect(ui.announcementEl.innerHTML).toContain('YOU LOSE!'); + }); + + test('should include retry button', () => { + ui.showDefeat(); + + expect(ui.announcementEl.innerHTML).toContain('RETRY'); + }); + + test('should have onclick handler for retry', () => { + ui.showDefeat(); + + expect(ui.announcementEl.innerHTML).toContain('location.reload()'); + }); + + test('should handle missing announcement element', () => { + ui.announcementEl = null; + + expect(() => ui.showDefeat()).not.toThrow(); + }); + }); + + describe('clearAnnouncement', () => { + test('should clear announcement HTML', () => { + ui.announcementEl.innerHTML = 'Test'; + ui.clearAnnouncement(); + + expect(ui.announcementEl.innerHTML).toBe(''); + }); + + test('should handle missing announcement element', () => { + ui.announcementEl = null; + + expect(() => ui.clearAnnouncement()).not.toThrow(); + }); + + test('should be idempotent', () => { + ui.clearAnnouncement(); + ui.clearAnnouncement(); + + expect(ui.announcementEl.innerHTML).toBe(''); + }); + }); + + describe('showPlacementPreview', () => { + test('should add preview classes to cells', () => { + const mockSquare = { + classList: { add: jest.fn() } + }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.showPlacementPreview([0, 1, 2], true); + + expect(mockSquare.classList.add).toHaveBeenCalledWith('preview-active', 'preview-valid'); + }); + + test('should add invalid class when not valid', () => { + const mockSquare = { + classList: { add: jest.fn() } + }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.showPlacementPreview([0, 1], false); + + expect(mockSquare.classList.add).toHaveBeenCalledWith('preview-active', 'preview-invalid'); + }); + + test('should track previewed cells in map', () => { + const mockSquare = { + classList: { add: jest.fn() } + }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.showPlacementPreview([5, 6], true); + + expect(ui.previewedCells.has(5)).toBe(true); + expect(ui.previewedCells.has(6)).toBe(true); + }); + + test('should clear previous preview before showing new', () => { + const mockSquare = { + classList: { add: jest.fn(), remove: jest.fn() } + }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.showPlacementPreview([0, 1], true); + ui.showPlacementPreview([2, 3], true); + + expect(ui.previewedCells.size).toBe(2); + }); + + test('should handle null cells array', () => { + expect(() => ui.showPlacementPreview(null, true)).not.toThrow(); + }); + + test('should handle empty cells array', () => { + expect(() => ui.showPlacementPreview([], true)).not.toThrow(); + }); + + test('should skip cells with missing elements', () => { + mockDoc.getElementById.mockReturnValue(null); + + expect(() => ui.showPlacementPreview([0, 1], true)).not.toThrow(); + }); + }); + + describe('clearPlacementPreview', () => { + test('should remove preview classes from cells', () => { + const mockSquare = { + classList: { add: jest.fn(), remove: jest.fn() } + }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.previewedCells.set(0, 'preview-valid'); + ui.clearPlacementPreview(); + + expect(mockSquare.classList.remove).toHaveBeenCalledWith( + 'preview-active', + 'preview-valid', + 'preview-invalid' + ); + }); + + test('should clear previewedCells map', () => { + ui.previewedCells.set(0, 'preview-valid'); + ui.previewedCells.set(1, 'preview-invalid'); + + ui.clearPlacementPreview(); + + expect(ui.previewedCells.size).toBe(0); + }); + + test('should handle empty preview map', () => { + expect(() => ui.clearPlacementPreview()).not.toThrow(); + }); + + test('should be idempotent', () => { + const mockSquare = { + classList: { remove: jest.fn() } + }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.previewedCells.set(0, 'preview-valid'); + ui.clearPlacementPreview(); + ui.clearPlacementPreview(); + + expect(ui.previewedCells.size).toBe(0); + }); + }); + + describe('renderShip', () => { + test('should render ship on player board', () => { + const mockSquare = { innerHTML: '' }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.renderShip('player', [0, 1, 2]); + + expect(mockSquare.innerHTML).toContain('ship-select'); + }); + + test('should render ship on cpu board', () => { + const mockSquare = { innerHTML: '' }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.renderShip('cpu', [10, 11, 12]); + + expect(mockSquare.innerHTML).toContain('ship-select'); + }); + + test('should handle empty cells array', () => { + expect(() => ui.renderShip('player', [])).not.toThrow(); + }); + }); + + describe('renderHit', () => { + test('should render hit marker', () => { + const mockSquare = { innerHTML: '' }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.renderHit('player', 10); + + expect(mockSquare.innerHTML).toContain('ship-hit'); + }); + + test('should work for cpu board', () => { + const mockSquare = { innerHTML: '' }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.renderHit('cpu', 20); + + expect(mockSquare.innerHTML).toContain('ship-hit'); + }); + }); + + describe('renderMiss', () => { + test('should render miss marker', () => { + const mockSquare = { innerHTML: '' }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.renderMiss('player', 15); + + expect(mockSquare.innerHTML).toContain('ship-miss'); + }); + + test('should work for cpu board', () => { + const mockSquare = { innerHTML: '' }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.renderMiss('cpu', 25); + + expect(mockSquare.innerHTML).toContain('ship-miss'); + }); + }); + + describe('renderSquare', () => { + test('should set innerHTML with correct class', () => { + const mockSquare = { innerHTML: '' }; + mockDoc.getElementById.mockReturnValue(mockSquare); + + ui.renderSquare('player', 5, 'test-class'); + + expect(mockSquare.innerHTML).toContain('test-class'); + expect(mockSquare.innerHTML).toContain('