Test timing out in vitest

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?