I built a tetris game and am writing tests for it. I am unable to figure out how to write a test for “Update game information when rows are cleared”.
Application Description:
The game information is updated in a useGameStatus() that accepts the number of rows cleared – rowsCleared and the game mode which is SinglePlayer here. useStage(), another custom hook computes the number of rows cleared and returns rowsCleared. Based on rowsCleared, useGameStatus sets the score, level and rows to be displayed (rows).
Code (link):
import GameArea from '@components/GameArea/GameArea';
import './SinglePlayer.scss';
import { useEffect, useRef, useState } from 'react';
import useStage from '@hooks/useStage';
import useGameStatus from '@hooks/useGameStatus';
import { checkCollision, createStage } from '@utils/game-helpers';
import {
BASE_DROP_TIME,
DROP_TIME_INCR,
GameMode,
INITAL_ROWS,
INITIAL_LEVEL,
INITIAL_SCORE,
KEY_CODE_DOWN,
KEY_CODE_LEFT,
KEY_CODE_RIGHT,
KEY_CODE_UP,
} from '@constants/game';
import useInterval from '@hooks/useInterval';
import { KeyCode } from '@customTypes/gameTypes';
import usePiece from '@hooks/usePiece';
function SinglePlayer() {
const [dropTime, setDropTime] = useState<number | null>(null);
const [gameOver, setGameOver] = useState<boolean>(false);
const [gamePaused, setGamePaused] = useState<boolean>(false);
const [gameStarted, setGameStarted] = useState<boolean>(false);
const { piece, updatePiecePosition, resetPiece, pieceRotate } = usePiece(
GameMode.SINGLE_PLAYER
);
const { stage, setStage, rowsCleared } = useStage(
piece,
resetPiece,
GameMode.SINGLE_PLAYER
);
const { score, setScore, rows, setRows, level, setLevel } = useGameStatus(
rowsCleared,
GameMode.SINGLE_PLAYER
);
const gameAreaRef = useRef<HTMLDivElement>(null);
// Move the piece if no collision occurs
const movePiece = (direction: number) => {
const didCollide = checkCollision(piece, stage, { x: direction, y: 0 });
if (!didCollide) {
updatePiecePosition({ x: direction, y: 0, collided: didCollide });
}
};
// Starts the game or resets the game when game over
const startGame = () => {
// document.removeEventListener('keydown', move);
setStage(createStage());
setDropTime(BASE_DROP_TIME);
resetPiece(null);
setGameOver(false);
setScore(INITIAL_SCORE);
setRows(INITAL_ROWS);
setLevel(INITIAL_LEVEL);
setGameStarted(true);
if (gameAreaRef.current) {
gameAreaRef.current.focus();
}
// document.addEventListener('keydown', move);
};
// Pauses the game
const pauseGame = () => {
setGamePaused(true);
setDropTime(null);
};
// Resumes the game
const resumeGame = () => {
setGamePaused(false);
// setDropTime(BASE_DROP_TIME / (level + 1) + DROP_TIME_INCR);
setDropTime(BASE_DROP_TIME / level);
if (gameAreaRef.current) {
gameAreaRef.current.focus();
}
};
// Logic to drop the tetromino
const drop = () => {
if (!gameOver && !gamePaused) {
// Increase level when piece has cleared 10 rows
if (rows > (level + 1) * 10) {
setLevel((prev) => prev + 1);
// Increase speed of tetromino fall
setDropTime(1000 / (level + 1) + DROP_TIME_INCR);
}
if (!checkCollision(piece, stage, { x: 0, y: 1 })) {
updatePiecePosition({ x: 0, y: 1, collided: false });
} else {
// Game over when collision occurs at the top
if (piece.position.y < 1) {
setGameOver(true);
setGameStarted(false);
setDropTime(null);
} else {
// Else the tetromino collided with the stage boundary
// or/and other tetromino
updatePiecePosition({ x: 0, y: 0, collided: true });
}
}
}
};
const dropPiece = () => {
drop();
};
// Processes key strokes from the key board
const move = ({ keyCode }: KeyCode) => {
if (!gameOver && !gamePaused) {
if (keyCode === KEY_CODE_LEFT) {
// Left arrow
// Moves the piece to the left on x-axis
movePiece(-1);
} else if (keyCode === KEY_CODE_RIGHT) {
// Right arrow
// Moves the piece to the right on x-axis
movePiece(1);
} else if (keyCode === KEY_CODE_DOWN) {
// Down arrow
dropPiece();
} else if (keyCode === KEY_CODE_UP) {
// Up arrow
pieceRotate(stage, 1);
}
}
};
// Rotates the tetromino
const keyUp = ({ keyCode }: KeyCode) => {
if (!gameOver && !gamePaused) {
if (keyCode === KEY_CODE_DOWN) {
setDropTime(BASE_DROP_TIME / (level + 1) + DROP_TIME_INCR);
}
}
};
// Drops the tetromino at intervals dropTime
useInterval(() => {
drop();
}, dropTime!);
// Event listener for pause/resume
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (!gameOver) {
if (gamePaused) {
resumeGame();
} else {
pauseGame();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [gamePaused, gameOver]);
const getHandlerFunction = () => {
if (!gameStarted || gameOver) {
return startGame;
}
return gamePaused ? resumeGame : pauseGame;
};
return (
<div className="single-player">
<div
role="button"
className="game-area-wrapper"
onKeyDown={(e) => move(e)}
onKeyUp={keyUp}
ref={gameAreaRef}
tabIndex={0}
data-testid="singleplayer-container"
>
<GameArea
stage={stage}
currentLevel={level}
gameOver={gameOver}
gameScore={score}
rows={rows}
onButtonClick={getHandlerFunction()}
gameStarted={gameStarted}
gamePaused={gamePaused}
/>
</div>
</div>
);
}
export default SinglePlayer;
useGameStatus hook:
import {
GameMode,
INITAL_ROWS,
INITIAL_LEVEL,
INITIAL_SCORE,
LINE_POINTS,
TurnState,
} from '@constants/game';
import { useMultiplayerGameContext } from '@contexts/MultiplayerGameContext';
import { useState, useEffect, useCallback } from 'react';
// eslint-disable-next-line implicit-arrow-linebreak
const useGameStatus = (rowsCleared: number, gameMode: string) => {
const [score, setScore] = useState<number>(INITIAL_SCORE);
const [rows, setRows] = useState<number>(INITAL_ROWS);
const [level, setLevel] = useState<number>(INITIAL_LEVEL);
const { turn, updateScore } = useMultiplayerGameContext();
// Calculates the score
const calculateScore = useCallback(() => {
if (rowsCleared > 0) {
// Score formula from web
setScore((prev) => prev + LINE_POINTS[rowsCleared - 1] * (level + 1));
setRows((prev) => prev + rowsCleared);
}
}, [level, rowsCleared]);
useEffect(() => {
calculateScore();
if (
gameMode === GameMode.MULTI_PLAYER &&
turn.currentState === TurnState.UPDATE_PLAYER_INFO
) {
updateScore(score);
}
}, [
calculateScore,
rowsCleared,
score,
updateScore,
gameMode,
turn.currentState,
]);
return {
score,
setScore,
rows,
setRows,
level,
setLevel,
};
};
export default useGameStatus;
useStage hook:
import { CLEAR_CELL, INITIAL_ROWS_CLEARED, MERGE_CELL } from '@constants/game';
import { useMultiplayerGameContext } from '@contexts/MultiplayerGameContext';
import { StageType } from '@customTypes/gameTypes';
import { Piece } from '@customTypes/pieceTypes';
import { TetrominoShape } from '@customTypes/tetromonoTypes';
import { createStage } from '@utils/game-helpers';
import { useState, useEffect } from 'react';
const useStage = (
piece: Piece,
resetPiece: (tetromino: TetrominoShape | null) => void,
gameMode: string
) => {
const [stage, setStage] = useState(createStage());
const [rowsCleared, setRowsCleared] = useState(INITIAL_ROWS_CLEARED);
const { turn, userSelectedTetromino } = useMultiplayerGameContext();
useEffect(() => {
setRowsCleared(0);
// Clearing rows logic - if n rows are filled then add n empty rows
// at the top by using unshift and return the accumulated array
// If the row contains 0 then it shouldn't be cleared
const clearRows = (newStage: StageType) =>
newStage.reduce((acc: StageType, row) => {
if (row.findIndex((cell) => cell[0] === 0) === -1) {
// Row does not contain empty cells
setRowsCleared((prev) => prev + 1);
acc.unshift(new Array(newStage[0].length).fill([0, CLEAR_CELL]));
return acc;
}
acc.push(row);
return acc;
}, []);
const updateStage = (prevStage: StageType) => {
// Flush the stage first
// eslint-disable-next-line implicit-arrow-linebreak
const newStage: StageType = prevStage.map((row) =>
// Checks if the cell in the row is clear (empty)
// eslint-disable-next-line implicit-arrow-linebreak
row.map((cell) => (cell[1] === CLEAR_CELL ? [0, CLEAR_CELL] : cell))
);
// Draw the tetromino
piece.tetromino.forEach((row, y) => {
row.forEach((value, x) => {
if (value !== 0) {
// Prevents out-of-bounds due to continuous down arrow key presses
newStage[y + piece.position.y][x + piece.position.x] = [
value,
`${piece.collided ? MERGE_CELL : CLEAR_CELL}`,
];
}
});
});
if (piece.collided) {
// if (gameMode === GameMode.SINGLE_PLAYER) {
// resetPiece(null);
// } else if (gameMode === GameMode.MULTI_PLAYER) {
// if (turn.currentState === TurnState.PLAY_TURN) {
// // handleTurnStateChange(TurnState.UPDATE_PLAYER_INFO);
// // resetPiece(TETROMINOES[0].shape);
// }
// }
resetPiece(null);
return clearRows(newStage);
}
return newStage;
};
setStage((prev) => updateStage(prev));
}, [piece, resetPiece, gameMode, userSelectedTetromino, turn.currentState]);
return {
stage,
setStage,
rowsCleared,
};
};
export default useStage;
Link to hooks.
My attempt:
- Create a mock stage of size 6 X 4 and update rowsCleared property of
the useStage hook. - Also, mock a 2X2 piece of shape ‘O’.
- Start the game using a click event Simulate ArrowDown event (in the application the piece drops automatically as soon as the game starts but I don’t know how to simulate this so I’m manually dropping the piece).
- Expect the labels associated with rows, score and level to not be ‘0’.
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest';
import SinglePlayer from '@pages/SinglePlayer/SinglePlayer';
import render from './setupTests';
import { cleanup, fireEvent, waitFor } from '@testing-library/react';
import * as gameHelpers from '@utils/game-helpers';
import {
BASE_DROP_TIME,
CLEAR_CELL,
GameMode,
INITIAL_LEVEL,
KEY_CODE_DOWN,
KEY_CODE_LEFT,
KEY_CODE_RIGHT,
KEY_CODE_UP,
} from '@constants/game';
import * as usePiece from '@hooks/usePiece';
import { Tetromino } from '@customTypes/tetromonoTypes';
import * as useStage from '@hooks/useStage';
import * as useGameStatus from '@hooks/useGameStatus';
vi.mock('@hooks/useGameStatus');
vi.mock('@hooks/usePiece');
vi.mock('@hooks/useStage');
const testTetromino: Tetromino = {
shape: [
[0, 'I'],
[0, 'I'],
],
color: '80, 227, 230',
};
const TEST_STAGE_WIDTH = 6;
const TEST_STAGE_HEIGHT = 10;
describe('Keyboard input tests', () => {
beforeAll(() => {
vi.mock('@hooks/useGameStatus', () => ({
default: vi.fn(() => ({
score: 0,
setScore: vi.fn(),
rows: 0,
setRows: vi.fn(),
level: 1,
setLevel: vi.fn(),
})),
}));
});
beforeEach(() => {
// Mock useStage to provide a typical stage setup
useStage.default = vi.fn().mockReturnValue({
stage: Array.from(Array(TEST_STAGE_HEIGHT), () =>
new Array(TEST_STAGE_WIDTH).fill([0, 'CLEAR'])
),
setStage: vi.fn(),
rowsCleared: 0,
});
// Mocking usePiece to control the piece's initial position and movement
usePiece.default = vi.fn().mockReturnValue({
piece: {
// A piece on the stage has x position at TEST_STAGE_WIDTH - 2 (as tetromino is a 2X2 matrix)
position: { x: TEST_STAGE_WIDTH - 4, y: 0 },
tetromino: [
[0, 'I'],
[0, 'I'],
],
collided: false,
},
updatePiecePosition: vi.fn(),
resetPiece: vi.fn(),
pieceRotate: vi.fn(),
});
});
afterEach(() => {
cleanup;
vi.restoreAllMocks();
});
test('Adjust dropTime when ArrowDown is pressed and verify drop interval', async () => {
const { getByTestId } = render(<SinglePlayer />);
vi.useFakeTimers();
// Start the game
fireEvent.click(getByTestId('singleplayer-start-game'));
// Simulate pressing ArrowDown to increase drop speed
fireEvent.keyDown(getByTestId('singleplayer-container'), {
key: 'ArrowDown',
keyCode: KEY_CODE_DOWN,
});
// Advance timers to see if the piece drops faster
// Assuming the dropTime has been reduced to 200 ms
vi.advanceTimersByTime(200);
// Check if updatePiecePosition was called to move the piece down
expect(
usePiece.default(GameMode.SINGLE_PLAYER).updatePiecePosition
).toHaveBeenCalledWith({
x: 0,
y: 1,
collided: false,
});
vi.useRealTimers();
});
test('Update game information on rows are cleared', async () => {
// Mock useStage to provide a typical stage setup
useStage.default = vi.fn().mockReturnValue({
stage: Array.from(Array(TEST_STAGE_HEIGHT - 5), () =>
new Array(TEST_STAGE_WIDTH - 2).fill([0, 'CLEAR'])
),
setStage: vi.fn(),
rowsCleared: 0,
});
const { getByTestId } = render(<SinglePlayer />);
vi.useFakeTimers();
// Start the game
fireEvent.click(getByTestId('singleplayer-start-game'));
// Simulate pressing ArrowDown to increase drop speed
fireEvent.keyDown(getByTestId('singleplayer-container'), {
key: 'ArrowDown',
keyCode: KEY_CODE_DOWN,
});
// Advance timers to see if the piece drops faster
// Assuming the dropTime has been reduced to 200 ms
vi.advanceTimersByTime(200);
// Assert that game information displayed have changed
await waitFor(() => {
expect(getByTestId('singleplayer-rows-cleared').innerHTML).not.toBe('0');
expect(getByTestId('singleplayer-level').innerHTML).not.toBe('0');
expect(getByTestId('singleplayer-score').innerHTML).not.toBe('0');
});
vi.useRealTimers();
});
});
Error – The Update game information on rows are cleared times out. Why? And, how do I write a test for this?