From 1af068923b81fb245ffda31f81ef9ea09657deef Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Sat, 21 Mar 2026 15:22:11 +0000 Subject: [PATCH] Improve unit tests with mocking, edge cases, and better coverage - Add deterministic mocking for Math.random() in garbage tests - Add edge case tests for piece positioning at boundaries - Add tests for negative Y positions (spawn area) - Improve lockPieceToBoard tests with proper I piece shape handling - Add performance tests for checkBoardOverflow and createEmptyBoard - Add tests for multiple garbage rows and board overflow scenarios - Fix test expectations to match actual game logic behavior - Total tests increased from 57 to 105 --- server/__tests__/game-logic.test.js | 294 ++++++++++++++++++++++++ server/__tests__/garbage-system.test.js | 243 +++++++++++++++++++- 2 files changed, 536 insertions(+), 1 deletion(-) diff --git a/server/__tests__/game-logic.test.js b/server/__tests__/game-logic.test.js index 9bc96a6..f668b75 100644 --- a/server/__tests__/game-logic.test.js +++ b/server/__tests__/game-logic.test.js @@ -47,10 +47,21 @@ describe('getPieceFromType', () => { expect(piece.color).toBe('#ffff00'); }); + test('returns correct piece for T tetromino', () => { + const piece = getPieceFromType('T'); + expect(piece.type).toBe('T'); + expect(piece.color).toBe('#800080'); + }); + test('centers piece horizontally', () => { const piece = getPieceFromType('I'); expect(piece.x).toBe(Math.floor(BOARD_WIDTH / 2) - Math.floor(4 / 2)); }); + + test('O piece is centered correctly', () => { + const piece = getPieceFromType('O'); + expect(piece.x).toBe(Math.floor(BOARD_WIDTH / 2) - Math.floor(2 / 2)); + }); }); describe('isValidPosition', () => { @@ -84,6 +95,28 @@ describe('isValidPosition', () => { const piece = getPieceFromType('O'); expect(isValidPosition(piece, 4, 9, board)).toBe(false); }); + + test('returns true for negative Y (piece spawning above board)', () => { + const board = createEmptyBoard(); + const piece = getPieceFromType('I'); + // Negative Y is allowed for spawn area above visible board + expect(isValidPosition(piece, 3, -1, board)).toBe(true); + }); + + test('handles piece partially above board', () => { + const board = createEmptyBoard(); + const piece = { ...getPieceFromType('I'), y: -1 }; + // Piece can be partially above board (y < 0) + expect(isValidPosition(piece, 3, -1, board)).toBe(true); + }); + + test('returns false when piece collides with block at spawn area', () => { + const board = createEmptyBoard(); + board[0][3] = '#ff0000'; // Block at top + const piece = getPieceFromType('I'); + // Piece at y=-1 would have blocks at y=-1,0,1,2 - row 0 has block + expect(isValidPosition(piece, 3, -1, board)).toBe(false); + }); }); describe('getGhostY', () => { @@ -104,6 +137,19 @@ describe('getGhostY', () => { const ghostY = getGhostY(piece, board); expect(ghostY).toBeGreaterThan(piece.y); }); + + test('calculates ghost position with obstacle', () => { + const board = createEmptyBoard(); + board[10][3] = '#ff0000'; + board[10][4] = '#ff0000'; + board[10][5] = '#ff0000'; + board[10][6] = '#ff0000'; + const piece = getPieceFromType('I'); + const ghostY = getGhostY(piece, board); + // I piece at x=3 with shape [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]] + // Bottom of piece (row 1 of shape) at y=8 would hit obstacle at row 10 + expect(ghostY).toBe(8); + }); }); describe('checkBoardOverflow', () => { @@ -136,6 +182,26 @@ describe('checkBoardOverflow', () => { board[2][5] = '#ff0000'; expect(checkBoardOverflow(board)).toBe(false); }); + + test('returns true when garbage fills row 0', () => { + const board = createEmptyBoard(); + board[0].fill(GARBAGE_COLOR); + expect(checkBoardOverflow(board)).toBe(true); + }); + + test('returns true when row 0 has garbage with gap', () => { + const board = createEmptyBoard(); + board[0].fill(GARBAGE_COLOR); + board[0][5] = 0; // Gap in row 0 + expect(checkBoardOverflow(board)).toBe(true); // Still overflowed + }); + + test('returns true when row 1 has garbage even if row 0 empty', () => { + const board = createEmptyBoard(); + board[1].fill(GARBAGE_COLOR); + // Row 0 is empty but row 1 has blocks + expect(checkBoardOverflow(board)).toBe(true); + }); }); describe('addGarbageRow', () => { @@ -172,6 +238,38 @@ describe('addGarbageRow', () => { const result = addGarbageRow(board, null); expect(result.currentPiece).toBe(null); }); + + test('garbage gap position is deterministic with mocked random', () => { + const originalRandom = Math.random; + Math.random = () => 0.5; // Always return 0.5 -> gap at index 5 + + const board = createEmptyBoard(); + const result = addGarbageRow(board, null); + + expect(result.gap).toBe(5); + expect(result.board[19][5]).toBe(0); + + Math.random = originalRandom; + }); + + test('removes top row correctly', () => { + const board = createEmptyBoard(); + board[0][5] = '#ff0000'; // Mark top row + const result = addGarbageRow(board, null); + // Original row 1 should now be row 0 + expect(result.board[0][5]).toBe(0); + }); + + test('piece pushed to negative Y is still tracked', () => { + const board = createEmptyBoard(); + const piece = { ...getPieceFromType('I'), y: 2 }; + const result = addGarbageRow(board, piece); + expect(result.currentPiece.y).toBe(1); + + // Push again + const result2 = addGarbageRow(result.board, result.currentPiece); + expect(result2.currentPiece.y).toBe(0); + }); }); describe('lockPieceToBoard', () => { @@ -195,6 +293,74 @@ describe('lockPieceToBoard', () => { // Original board should still be empty expect(board[5][4]).toBe(0); }); + + test('piece at negative Y only locks visible portion', () => { + const board = createEmptyBoard(); + const piece = { + ...getPieceFromType('I'), + x: 3, + y: -1 // Top half above board + }; + const newBoard = lockPieceToBoard(piece, board); + + // Only bottom half should be locked (I piece is 4 rows tall) + expect(newBoard[0][3]).toBe(piece.color); + expect(newBoard[0][4]).toBe(piece.color); + expect(newBoard[0][5]).toBe(piece.color); + expect(newBoard[0][6]).toBe(piece.color); + }); + + test('piece at bottom of board locks correctly', () => { + const board = createEmptyBoard(); + const piece = { ...getPieceFromType('O'), x: 4, y: 18 }; + const newBoard = lockPieceToBoard(piece, board); + + expect(newBoard[18][4]).toBe(piece.color); + expect(newBoard[18][5]).toBe(piece.color); + expect(newBoard[19][4]).toBe(piece.color); + expect(newBoard[19][5]).toBe(piece.color); + }); + + test('piece partially off bottom edge only locks visible part', () => { + const board = createEmptyBoard(); + const piece = { ...getPieceFromType('I'), x: 3, y: 16 }; + const newBoard = lockPieceToBoard(piece, board); + + // I piece shape: [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]] + // At y=16, the blocks (row 1 of shape) lock at row 17 + // Rows 17,18,19 are visible + expect(newBoard[17][3]).toBe(piece.color); + expect(newBoard[17][4]).toBe(piece.color); + expect(newBoard[17][5]).toBe(piece.color); + expect(newBoard[17][6]).toBe(piece.color); + }); + + test('piece completely above board locks nothing', () => { + const board = createEmptyBoard(); + const piece = { ...getPieceFromType('I'), x: 3, y: -10 }; + const newBoard = lockPieceToBoard(piece, board); + + // Board should still be empty + expect(newBoard[0][3]).toBe(0); + }); + + test('piece at left edge locks correctly', () => { + const board = createEmptyBoard(); + const piece = { ...getPieceFromType('O'), x: 0, y: 5 }; + const newBoard = lockPieceToBoard(piece, board); + + expect(newBoard[5][0]).toBe(piece.color); + expect(newBoard[5][1]).toBe(piece.color); + }); + + test('piece at right edge locks correctly', () => { + const board = createEmptyBoard(); + const piece = { ...getPieceFromType('O'), x: 8, y: 5 }; + const newBoard = lockPieceToBoard(piece, board); + + expect(newBoard[5][8]).toBe(piece.color); + expect(newBoard[5][9]).toBe(piece.color); + }); }); describe('clearRows', () => { @@ -234,6 +400,43 @@ describe('clearRows', () => { const result = clearRows(board); expect(result.board.length).toBe(BOARD_HEIGHT); }); + + test('does not clear incomplete rows', () => { + const board = createEmptyBoard(); + board[10].fill('#ff0000'); + board[10][5] = 0; // Leave a gap + const result = clearRows(board); + expect(result.rowsCleared).toBe(0); + }); + + test('clears tetris (4 rows at once)', () => { + const board = createEmptyBoard(); + board[16].fill('#ff0000'); + board[17].fill('#ff0000'); + board[18].fill('#ff0000'); + board[19].fill('#ff0000'); + const result = clearRows(board); + expect(result.rowsCleared).toBe(4); + }); + + test('clears rows from bottom to top', () => { + const board = createEmptyBoard(); + board[5].fill('#ff0000'); + board[10].fill('#00ff00'); + board[15].fill('#0000ff'); + const result = clearRows(board); + expect(result.rowsCleared).toBe(3); + }); + + test('shifts rows down correctly after clearing', () => { + const board = createEmptyBoard(); + board[18].fill('#ff0000'); + board[19].fill('#00ff00'); + const result = clearRows(board); + // After clearing 2 rows, top should be empty + expect(result.board[0].every(cell => cell === 0)).toBe(true); + expect(result.board[1].every(cell => cell === 0)).toBe(true); + }); }); describe('generatePieceBag', () => { @@ -254,6 +457,20 @@ describe('generatePieceBag', () => { expect(types.has('J')).toBe(true); expect(types.has('L')).toBe(true); }); + + test('each bag contains exactly one of each piece type', () => { + const bag = generatePieceBag(); + const counts = {}; + bag.forEach(type => counts[type] = (counts[type] || 0) + 1); + + expect(counts.I).toBe(1); + expect(counts.O).toBe(1); + expect(counts.T).toBe(1); + expect(counts.S).toBe(1); + expect(counts.Z).toBe(1); + expect(counts.J).toBe(1); + expect(counts.L).toBe(1); + }); }); describe('createPieceQueue', () => { @@ -266,4 +483,81 @@ describe('createPieceQueue', () => { const queue = createPieceQueue(); expect(queue.length).toBe(98); // 14 bags * 7 pieces }); + + test('creates multiple complete bags', () => { + const queue = createPieceQueue(3); // 3 bags = 21 pieces + expect(queue.length).toBe(21); + + // Each 7-piece segment should have all types + for (let i = 0; i < 3; i++) { + const bag = queue.slice(i * 7, (i + 1) * 7); + expect(new Set(bag).size).toBe(7); + } + }); +}); + +describe('garbage edge cases', () => { + test('multiple garbage rows push piece progressively', () => { + let board = createEmptyBoard(); + let piece = { ...getPieceFromType('I'), y: 10 }; + + // Add 5 garbage rows + for (let i = 0; i < 5; i++) { + const result = addGarbageRow(board, piece); + board = result.board; + piece = result.currentPiece; + } + + expect(piece.y).toBe(5); // 10 - 5 = 5 + }); + + test('19 garbage rows causes board overflow', () => { + let board = createEmptyBoard(); + let piece = { ...getPieceFromType('I'), y: 10 }; + + for (let i = 0; i < 19; i++) { + const result = addGarbageRow(board, piece); + board = result.board; + piece = result.currentPiece; + } + + // Board should now have garbage in top rows (row 0 and 1) + expect(checkBoardOverflow(board)).toBe(true); + }); + + test('piece pushed to negative Y is still tracked correctly', () => { + let board = createEmptyBoard(); + let piece = { ...getPieceFromType('I'), y: 5 }; + + // Push piece 10 rows up + for (let i = 0; i < 10; i++) { + const result = addGarbageRow(board, piece); + board = result.board; + piece = result.currentPiece; + } + + expect(piece.y).toBe(-5); + // Board not overflowed yet (piece is above board, not in it) + expect(checkBoardOverflow(board)).toBe(false); + }); +}); + +describe('performance', () => { + test('checkBoardOverflow is fast', () => { + const board = createEmptyBoard(); + + expect(() => { + for (let i = 0; i < 10000; i++) { + checkBoardOverflow(board); + } + }).not.toThrow(); + }); + + test('createEmptyBoard is fast', () => { + expect(() => { + for (let i = 0; i < 1000; i++) { + createEmptyBoard(); + } + }).not.toThrow(); + }); }); diff --git a/server/__tests__/garbage-system.test.js b/server/__tests__/garbage-system.test.js index 37b4e18..3b6fd53 100644 --- a/server/__tests__/garbage-system.test.js +++ b/server/__tests__/garbage-system.test.js @@ -7,7 +7,8 @@ const { createEmptyBoard, getPieceFromType, checkBoardOverflow, - addGarbageRow + addGarbageRow, + lockPieceToBoard } = require('../game-logic'); // Mock player object @@ -64,6 +65,33 @@ function sendGarbage(lobby, sender, rowsCleared) { return garbageLog; } +// Simulate lockPiece with elimination check (from index.js) +function lockPiece(player) { + if (!player.currentPiece) return 0; + + // Lock piece into board + player.board = lockPieceToBoard(player.currentPiece, player.board); + + // Check board overflow for elimination + if (checkBoardOverflow(player.board)) { + player.eliminated = true; + return 0; + } + + // Clear rows + let rowsCleared = 0; + for (let row = BOARD_HEIGHT - 1; row >= 0; row--) { + if (player.board[row].every(cell => cell !== 0)) { + player.board.splice(row, 1); + player.board.unshift(Array(BOARD_WIDTH).fill(0)); + rowsCleared++; + row++; + } + } + + return rowsCleared; +} + describe('addGarbageToPlayer', () => { test('adds exactly 1 garbage row per call', () => { const player = createMockPlayer('Player1', 5); @@ -111,6 +139,28 @@ describe('addGarbageToPlayer', () => { expect(player.eliminated).toBe(false); expect(player.currentPiece.y).toBe(5); // 10 - 5 = 5 }); + + test('handles player with no current piece', () => { + const player = createMockPlayer('Player1', null); + + addGarbageToPlayer(player, 'Enemy'); + + expect(player.currentPiece).toBe(null); + expect(player.garbageReceived.length).toBe(1); + }); + + test('multiple garbage from different senders tracked separately', () => { + const player = createMockPlayer('Player1', 10); + + addGarbageToPlayer(player, 'Enemy1'); + addGarbageToPlayer(player, 'Enemy2'); + addGarbageToPlayer(player, 'Enemy1'); + + expect(player.garbageReceived).toHaveLength(3); + expect(player.garbageReceived[0].sender).toBe('Enemy1'); + expect(player.garbageReceived[1].sender).toBe('Enemy2'); + expect(player.garbageReceived[2].sender).toBe('Enemy1'); + }); }); describe('sendGarbage', () => { @@ -171,6 +221,106 @@ describe('sendGarbage', () => { // Total garbage sent should equal rows cleared expect(target1.garbageReceived.length + target2.garbageReceived.length).toBe(4); }); + + test('garbage distribution is deterministic with mocked random', () => { + const sender = createMockPlayer('Sender', 5); + const target1 = createMockPlayer('Target1', 10); + const target2 = createMockPlayer('Target2', 10); + const lobby = createMockLobby([sender, target1, target2]); + + // Mock Math.random to always pick first opponent (index 0) + const originalRandom = Math.random; + Math.random = () => 0.1; + + sendGarbage(lobby, sender, 4); + + Math.random = originalRandom; + + // All garbage should go to target1 (first in array) + expect(target1.garbageReceived.length).toBe(4); + expect(target2.garbageReceived.length).toBe(0); + }); + + test('1 row cleared sends 1 garbage row', () => { + const sender = createMockPlayer('Sender', 5); + const target = createMockPlayer('Target', 10); + const lobby = createMockLobby([sender, target]); + + const garbageLog = sendGarbage(lobby, sender, 1); + + expect(garbageLog.length).toBe(1); + expect(target.garbageReceived.length).toBe(1); + }); + + test('2 rows cleared sends 2 garbage rows', () => { + const sender = createMockPlayer('Sender', 5); + const target = createMockPlayer('Target', 10); + const lobby = createMockLobby([sender, target]); + + const garbageLog = sendGarbage(lobby, sender, 2); + + expect(garbageLog.length).toBe(2); + }); + + test('4 rows (tetris) sends 4 garbage rows', () => { + const sender = createMockPlayer('Sender', 5); + const target = createMockPlayer('Target', 10); + const lobby = createMockLobby([sender, target]); + + const garbageLog = sendGarbage(lobby, sender, 4); + + expect(garbageLog.length).toBe(4); + }); +}); + +describe('lockPiece elimination', () => { + test('piece locking does not eliminate when board not overflowed', () => { + const player = createMockPlayer('Player1', 10); + + const rowsCleared = lockPiece(player); + + expect(player.eliminated).toBe(false); + expect(rowsCleared).toBe(0); + }); + + test('piece locking eliminates when board overflowed', () => { + const player = createMockPlayer('Player1', 0); + // Fill top 2 rows with garbage + player.board[0].fill(GARBAGE_COLOR); + player.board[0][5] = 0; // Gap + + lockPiece(player); + + expect(player.eliminated).toBe(true); + }); + + test('piece at y=0 eliminates when it locks into top rows', () => { + const player = createMockPlayer('Player1', 0); + // I piece at y=0 locks into rows 0,1,2,3 + // This causes overflow since row 0 and 1 will have blocks + + lockPiece(player); + + expect(player.eliminated).toBe(true); + }); + + test('piece at y=5 does not eliminate if top rows empty', () => { + const player = createMockPlayer('Player1', 5); + // Top rows are empty, piece locks at rows 5,6,7,8 + + lockPiece(player); + + expect(player.eliminated).toBe(false); + }); + + test('piece locks and does not clear rows when no complete rows', () => { + const player = createMockPlayer('Player1', 10); + // Piece locks at rows 10,11,12,13 - no complete rows + + const rowsCleared = lockPiece(player); + + expect(rowsCleared).toBe(0); + }); }); describe('checkBoardOverflow', () => { @@ -203,6 +353,20 @@ describe('checkBoardOverflow', () => { board[18].fill(GARBAGE_COLOR); expect(checkBoardOverflow(board)).toBe(false); }); + + test('returns true when both row 0 and row 1 have blocks', () => { + const board = createEmptyBoard(); + board[0][3] = '#ff0000'; + board[1][7] = '#ff0000'; + expect(checkBoardOverflow(board)).toBe(true); + }); + + test('returns true when row 0 has garbage with gap', () => { + const board = createEmptyBoard(); + board[0].fill(GARBAGE_COLOR); + board[0][5] = 0; // Gap in row 0 + expect(checkBoardOverflow(board)).toBe(true); + }); }); describe('Garbage Elimination Bug Fix', () => { @@ -263,4 +427,81 @@ describe('Garbage Elimination Bug Fix', () => { expect(target.eliminated).toBe(false); expect(target.garbageReceived.length).toBe(4); }); + + test('elimination only happens when piece locks with overflow', () => { + // Complete scenario: garbage received, piece locks, then elimination checked + + const player = createMockPlayer('Player1', 0); // Piece at spawn + + // Receive 1 garbage row - player NOT eliminated + addGarbageToPlayer(player, 'Enemy'); + expect(player.eliminated).toBe(false); + + // Fill top row to simulate overflow + player.board[0].fill(GARBAGE_COLOR); + player.board[0][5] = 0; + + // Now lock piece - player eliminated + lockPiece(player); + expect(player.eliminated).toBe(true); + }); + + test('player with piece above board survives garbage', () => { + // Edge case: piece is already above visible area (negative y) + // Should still not be eliminated by garbage alone + + const player = createMockPlayer('Player1', 2); + + // Push piece 5 rows up (to y=-3) + for (let i = 0; i < 5; i++) { + addGarbageToPlayer(player, 'Enemy'); + } + + // Piece is at y=-3, but player NOT eliminated + expect(player.eliminated).toBe(false); + expect(player.currentPiece.y).toBe(-3); + }); +}); + +describe('edge cases', () => { + test('player with piece at y=0 receives garbage', () => { + const player = createMockPlayer('Player1', 0); + + addGarbageToPlayer(player, 'Enemy'); + + expect(player.currentPiece.y).toBe(-1); + expect(player.eliminated).toBe(false); + }); + + test('18 garbage rows pushes piece well above board', () => { + const player = createMockPlayer('Player1', 10); + + for (let i = 0; i < 18; i++) { + addGarbageToPlayer(player, 'Enemy'); + } + + expect(player.currentPiece.y).toBe(-8); + // Board overflowed but player not eliminated yet (only on lock) + expect(player.eliminated).toBe(false); + }); + + test('garbage with no opponents does not crash', () => { + const sender = createMockPlayer('Sender', 5); + const lobby = createMockLobby([sender]); + + const garbageLog = sendGarbage(lobby, sender, 4); + + expect(garbageLog.length).toBe(0); + }); + + test('zero rows cleared sends no garbage', () => { + const sender = createMockPlayer('Sender', 5); + const target = createMockPlayer('Target', 10); + const lobby = createMockLobby([sender, target]); + + const garbageLog = sendGarbage(lobby, sender, 0); + + expect(garbageLog.length).toBe(0); + expect(target.garbageReceived.length).toBe(0); + }); });