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
+28 -107
View File
@@ -3,6 +3,25 @@ const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
// Import pure game logic functions
const {
BOARD_WIDTH,
BOARD_HEIGHT,
GARBAGE_COLOR,
TETROMINOS,
TETROMINO_KEYS,
createEmptyBoard,
getPieceFromType,
isValidPosition,
getGhostY,
checkBoardOverflow,
addGarbageRow,
lockPieceToBoard,
clearRows: clearRowsFromBoard,
generatePieceBag,
createPieceQueue
} = require('./game-logic');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
@@ -21,54 +40,8 @@ const lobby = {
};
const PORT = process.env.PORT || 3000;
// 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);
const BOARD_WIDTH = 10;
const BOARD_HEIGHT = 20;
const GARBAGE_COLOR = '#666666';
const LOBBY_ROOM = 'global-lobby';
// 7-bag piece generation system
function generatePieceBag() {
const bag = [...TETROMINO_KEYS];
// Fisher-Yates shuffle
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;
}
function createPieceQueue(bagCount = 14) {
let queue = [];
for (let i = 0; i < bagCount; i++) {
queue = queue.concat(generatePieceBag());
}
return queue;
}
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
};
}
io.on('connection', (socket) => {
console.log('Player connected:', socket.id);
@@ -331,10 +304,6 @@ io.on('connection', (socket) => {
});
});
function createEmptyBoard() {
return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0));
}
function getPlayersList() {
return Array.from(lobby.players.values()).map(p => ({
id: p.id,
@@ -354,21 +323,6 @@ function getSpectatorsList() {
}));
}
function isValidPosition(piece, x, y, board, shape = null) {
const s = shape || piece.shape;
for (let row = 0; row < s.length; row++) {
for (let col = 0; col < s[row].length; col++) {
if (s[row][col]) {
const newX = x + col;
const newY = y + row;
if (newX < 0 || newX >= BOARD_WIDTH || newY < 0 || newY >= BOARD_HEIGHT) return false;
if (board[newY][newX]) return false;
}
}
}
return true;
}
function getRandomPiece() {
const key = TETROMINO_KEYS[Math.floor(Math.random() * TETROMINO_KEYS.length)];
const tetromino = TETROMINOS[key];
@@ -421,17 +375,7 @@ function lockPiece(player) {
const rowsCleared = clearRows(player);
// Check if board overflowed (top 2 rows have blocks) - this means player is eliminated
let boardOverflowed = false;
for (let row = 0; row < 2; row++) {
for (let col = 0; col < BOARD_WIDTH; col++) {
if (player.board[row][col] !== 0) {
boardOverflowed = true;
break;
}
}
if (boardOverflowed) break;
}
if (boardOverflowed) {
if (checkBoardOverflow(player.board)) {
player.eliminated = true;
console.log(`[ELIMINATION] ${player.name} eliminated - board overflowed`);
broadcastState();
@@ -455,15 +399,10 @@ function lockPiece(player) {
}
function clearRows(player) {
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++;
}
}
const result = clearRowsFromBoard(player.board);
const rowsCleared = result.rowsCleared;
player.board = result.board;
if (rowsCleared > 0) {
player.lines += rowsCleared;
const points = [0, 100, 300, 500, 800];
@@ -496,31 +435,13 @@ function sendGarbage(sender, rowsCleared) {
}
function addGarbageToPlayer(player, senderName) {
// Remove top row and add garbage to bottom
player.board.shift();
const garbageRow = Array(BOARD_WIDTH).fill(GARBAGE_COLOR);
const gap = Math.floor(Math.random() * BOARD_WIDTH);
garbageRow[gap] = 0;
player.board.push(garbageRow);
// Use the pure function to add garbage row
const result = addGarbageRow(player.board, player.currentPiece);
player.board = result.board;
player.currentPiece = result.currentPiece;
// Track garbage received
player.garbageReceived.push({ rows: 1, sender: senderName });
// Push current piece up by 1 row if it exists (y decreases when moving up)
// Don't eliminate here - let the piece lock and check board overflow then
if (player.currentPiece) {
player.currentPiece.y--;
}
}
function getGhostY(piece, board) {
if (!piece) return null;
let ghostY = piece.y;
while (isValidPosition(piece, piece.x, ghostY + 1, board)) {
ghostY++;
}
return ghostY;
}
function startGame() {