Add unit and integration tests for garbage system

- Extract pure game logic functions to game-logic.js for testability
- Add Jest testing framework with 57 tests covering:
  * Unit tests for pure functions (createEmptyBoard, checkBoardOverflow, etc.)
  * Integration tests for garbage system (addGarbageToPlayer, sendGarbage)
  * Socket.io integration tests for multiplayer flow
- Refactor index.js to use extracted functions
- Tests verify garbage elimination bug fix: players only eliminated when board overflows
This commit is contained in:
2026-03-21 08:55:23 +00:00
parent 7d385806df
commit 80f59fd3b3
7 changed files with 4627 additions and 108 deletions
+224
View File
@@ -0,0 +1,224 @@
// Pure game logic functions extracted for testability
const BOARD_WIDTH = 10;
const BOARD_HEIGHT = 20;
const GARBAGE_COLOR = '#666666';
// Tetromino definitions
const TETROMINOS = {
I: { shape: [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]], color: '#00ffff' },
O: { shape: [[1,1], [1,1]], color: '#ffff00' },
T: { shape: [[0,1,0], [1,1,1], [0,0,0]], color: '#800080' },
S: { shape: [[0,1,1], [1,1,0], [0,0,0]], color: '#00ff00' },
Z: { shape: [[1,1,0], [0,1,1], [0,0,0]], color: '#ff0000' },
J: { shape: [[1,0,0], [1,1,1], [0,0,0]], color: '#0000ff' },
L: { shape: [[0,0,1], [1,1,1], [0,0,0]], color: '#ffa500' }
};
const TETROMINO_KEYS = Object.keys(TETROMINOS);
/**
* Create an empty board
* @returns {number[][]} Empty 20x10 board
*/
function createEmptyBoard() {
return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0));
}
/**
* Get a piece from its type
* @param {string} pieceType - Type of tetromino (I, O, T, S, Z, J, L)
* @returns {object} Piece object with shape and color
*/
function getPieceFromType(pieceType) {
const tetromino = TETROMINOS[pieceType];
return {
type: pieceType,
shape: JSON.parse(JSON.stringify(tetromino.shape)),
color: tetromino.color,
x: Math.floor(BOARD_WIDTH / 2) - Math.floor(tetromino.shape[0].length / 2),
y: 0
};
}
/**
* Check if a position is valid for a piece
* @param {object} piece - The piece to check
* @param {number} x - X position
* @param {number} y - Y position
* @param {number[][]} board - The game board
* @param {number[][]} shape - Optional custom shape (for rotation)
* @returns {boolean} True if position is valid
*/
function isValidPosition(piece, x, y, board, shape = null) {
const pieceShape = shape || piece.shape;
for (let row = 0; row < pieceShape.length; row++) {
for (let col = 0; col < pieceShape[row].length; col++) {
if (pieceShape[row][col]) {
const newX = x + col;
const newY = y + row;
// Check bounds
if (newX < 0 || newX >= BOARD_WIDTH || newY >= BOARD_HEIGHT) {
return false;
}
// Check collision with locked pieces (only if on board)
if (newY >= 0 && board[newY][newX]) {
return false;
}
}
}
}
return true;
}
/**
* Calculate ghost piece Y position
* @param {object} piece - The current piece
* @param {number[][]} board - The game board
* @returns {number|null} Ghost Y position or null if no piece
*/
function getGhostY(piece, board) {
if (!piece) return null;
let ghostY = piece.y;
while (isValidPosition(piece, piece.x, ghostY + 1, board)) {
ghostY++;
}
return ghostY;
}
/**
* Check if board has overflowed (blocks in top 2 rows)
* @param {number[][]} board - The game board
* @returns {boolean} True if board has overflowed
*/
function checkBoardOverflow(board) {
for (let row = 0; row < 2; row++) {
for (let col = 0; col < BOARD_WIDTH; col++) {
if (board[row][col] !== 0) {
return true;
}
}
}
return false;
}
/**
* Add garbage row to board
* @param {number[][]} board - The game board
* @param {object} currentPiece - Current falling piece (may be null)
* @returns {object} Object with new board, updated piece, and gap position
*/
function addGarbageRow(board, currentPiece) {
// Remove top row
const newBoard = board.slice(1);
// Add garbage row with random gap
const garbageRow = Array(BOARD_WIDTH).fill(GARBAGE_COLOR);
const gap = Math.floor(Math.random() * BOARD_WIDTH);
garbageRow[gap] = 0;
newBoard.push(garbageRow);
// Push current piece up by 1 row
let updatedPiece = currentPiece;
if (currentPiece) {
updatedPiece = {
...currentPiece,
y: currentPiece.y - 1
};
}
return { board: newBoard, currentPiece: updatedPiece, gap };
}
/**
* Lock a piece to the board
* @param {object} piece - The piece to lock
* @param {number[][]} board - The game board
* @returns {number[][]} New board with piece locked
*/
function lockPieceToBoard(piece, board) {
const newBoard = board.map(row => [...row]);
for (let row = 0; row < piece.shape.length; row++) {
for (let col = 0; col < piece.shape[row].length; col++) {
if (piece.shape[row][col]) {
const boardY = piece.y + row;
const boardX = piece.x + col;
if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) {
newBoard[boardY][boardX] = piece.color;
}
}
}
}
return newBoard;
}
/**
* Clear completed rows from board
* @param {number[][]} board - The game board
* @returns {object} Object with new board and rows cleared count
*/
function clearRows(board) {
let rowsCleared = 0;
const newBoard = board.map(row => [...row]);
for (let row = BOARD_HEIGHT - 1; row >= 0; row--) {
if (newBoard[row].every(cell => cell !== 0)) {
newBoard.splice(row, 1);
newBoard.unshift(Array(BOARD_WIDTH).fill(0));
rowsCleared++;
row++; // Check same row again
}
}
return { board: newBoard, rowsCleared };
}
/**
* Generate a shuffled bag of 7 tetrominoes
* @returns {string[]} Array of 7 piece types
*/
function generatePieceBag() {
const bag = [...TETROMINO_KEYS];
for (let i = bag.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
return bag;
}
/**
* Create a piece queue with multiple bags
* @param {number} bagCount - Number of bags to generate
* @returns {string[]} Array of piece types
*/
function createPieceQueue(bagCount = 14) {
const queue = [];
for (let i = 0; i < bagCount; i++) {
queue.push(...generatePieceBag());
}
return queue;
}
module.exports = {
BOARD_WIDTH,
BOARD_HEIGHT,
GARBAGE_COLOR,
TETROMINOS,
TETROMINO_KEYS,
createEmptyBoard,
getPieceFromType,
isValidPosition,
getGhostY,
checkBoardOverflow,
addGarbageRow,
lockPieceToBoard,
clearRows,
generatePieceBag,
createPieceQueue
};