Simulate key presses to test the game loop?

Hi,

I'm currently working on building a Tetris game in Rust as a way to learn the language.
As part of this project, I'm attempting to add unit tests for the key event handling functionality.

Here's the main function to handle the key events after starting:

pub struct Game {
    terminal: Box<dyn Terminal>,
}
impl Game {
    pub fn start(&mut self) -> Result<()> {
        self.terminal.enable_raw_mode()?;
        self.terminal.enter_alternate_screen()?;

        let mut stdout = io::stdout();
        self.render(&mut stdout)?;

        match self.handle_event(&mut stdout) {
            Ok(_) => {}
            Err(err) => eprintln!("Error: {}", err),
        }

        Ok(())
    }
    pub fn handle_event(&mut self, stdout: &mut std::io::Stdout) -> Result<()> {
        let mut drop_timer = Instant::now();
        let mut soft_drop_timer = Instant::now();

        let mut reset_needed = false;
        loop {
            if self.paused {
                self.handle_pause_event(stdout)?;
            } else {
                if self.level <= MAX_LEVEL && self.lines >= LINES_PER_LEVEL * (self.level + 1) {
                    self.level += 1;
                    self.drop_interval -= self.drop_interval / 10;
                }

                if drop_timer.elapsed() >= Duration::from_millis(self.drop_interval) {
                    let mut tetromino = self.current_tetromino.clone();
                    let can_move_down = self.can_move(
                        &tetromino,
                        tetromino.position.row as i16 + 1,
                        tetromino.position.col as i16,
                    );

                    if can_move_down {
                        tetromino.move_down(self, stdout)?;
                        self.current_tetromino = tetromino;
                    } else {
                        self.lock_and_move_to_next(&tetromino, stdout)?;
                    }

                    self.render_current_tetromino(stdout)?;

                    drop_timer = Instant::now();
                }

                if poll(Duration::from_millis(10))? {
                    if let Ok(event) = self.terminal.read_event() {
                        match event {
                            Event::Key(KeyEvent {
                                code,
                                state: _,
                                kind,
                                modifiers: _,
                            }) => {
                                if kind == KeyEventKind::Press {
                                    let mut tetromino = self.current_tetromino.clone();
                                    match code {
                                        KeyCode::Char('h') | KeyCode::Left => {
                                            tetromino.move_left(self, stdout)?;
                                            self.current_tetromino = tetromino;
                                        }

I've created a trait for the terminal:

pub trait Terminal {
    fn read_event(&self) -> Result<Event>;
}

and implemented it for both the real terminal and mock terminal to make testing possible:

pub struct RealTerminal;

impl Terminal for RealTerminal {
    fn read_event(&self) -> Result<Event> {
        Ok(read()?)
    }
}
    struct MockTerminal {
        mock_key_code: Option<KeyCode>,
    }

    impl MockTerminal {
        pub fn new(mock_key_code: Option<KeyCode>) -> Self {
            MockTerminal { mock_key_code }
        }
    }

    impl Terminal for MockTerminal {
        fn read_event(&self) -> Result<Event> {
            if let Some(mock_key_code) = self.mock_key_code {
                Ok(Event::Key(KeyEvent {
                    code: mock_key_code,
                    modifiers: KeyModifiers::empty(),
                    kind: KeyEventKind::Press,
                    state: KeyEventState::empty(),
                }))
            } else {
                Ok(Event::Key(KeyEvent {
                    code: KeyCode::Null,
                    modifiers: KeyModifiers::empty(),
                    kind: KeyEventKind::Press,
                    state: KeyEventState::empty(),
                }))
            }
        }
    }

    #[test]
    fn test_move_left() -> Result<()> {
        let conn = Connection::open_in_memory()?;
        let sqlite_highscore_repository = Box::new(SqliteHighScoreRepository { conn });

        let mut game = Game::new(
            Box::new(MockTerminal::new(None)),
            40,
            20,
            sqlite_highscore_repository,
            None,
            None,
            0,
            0,
        )?;
        game.start()?;

        let previous_col = game.current_tetromino.position.col;
        game.terminal = Box::new(MockTerminal::new(Some(KeyCode::Char('h'))));
        assert_eq!(game.current_tetromino.position.col, previous_col - 1);

        Ok(())
    }

The thing is it runs forever (due to the game loop?). So, I tried to start it in a separate thread:

        let game_thread = thread::spawn(move || -> Result<()> {
            game.start()?;
            Ok(())
        });

        let previous_col = game.current_tetromino.position.col;
        game.terminal = Box::new(MockTerminal::new(Some(KeyCode::Char('h'))));
        assert_eq!(game.current_tetromino.position.col, previous_col - 1);

        game_thread.join().unwrap()?;

but the error is:

error[E0277]: `(dyn std::error::Error + 'static)` cannot be sent between threads safely
    --> src/lib.rs:1899:27
     |
1899 |         let game_thread = thread::spawn(move || -> Result<()> {
     |                           ^^^^^^^^^^^^^ `(dyn std::error::Error + 'static)` cannot be sent between threads safely
     |
     = help: the trait `Send` is not implemented for `(dyn std::error::Error + 'static)`
     = note: required for `Unique<(dyn std::error::Error + 'static)>` to implement `Send`

I also tried to wrap the game instance in Arc<Mutex<T>> but I still get the same error.

I have two questions:

  1. Is this the recommended approach for testing a TUI app?
  2. If so, how can I address the thread safety issue?

Thanks,

It looks more like an "integration test" that runs the whole app. Using dependency inversion might help, but it's going to introduce weird code that only exists for testing purposes. The dependency inversion is just passing the dependency to the function as an argument or a generic parameter. This is basically what you are doing with the Terminal trait.

One thing that is missing is an input to break out of the loop. Instead of passing Option<KeyCode> consider mpsc::Receiver<KeyCode>, or even Receiver<Event>. Then you can use the channel to drive the main loop in your tests end cleanly exit by passing your exit key (Escape, maybe?).

2 Likes

I'm not sure if this helps in your case. I have been doing some TUI-ish stuff myself, and my general app design is:

pub enum Keyboard_Input { ... }
pub trait Terminal_Writer { ... };

pub trait Tui_App {
  fn handle_input(&mut self, key: Keyboarc_Input);
  fn render(&self, t: &mut dyn Terminal_Writer);
}

My original motivation here is the ability to swap out terminal backends. The additional advantage here is that if you implement a 'dummy' Terminal, you can unit test fairly easily: create a Tui_App, feed it a few keyboard inputs, and assert the state of the Terminal.

PS: You might also find GitHub - ratatui-org/ratatui: Rust library that's all about cooking up terminal user interfaces (TUIs) useful.

In particular, may be helpful to look at: Introduction | Ratatui

Note how they start with a 1-file counter, then refactor it into a multi-file counter w/ all types of nice abstractions.

I ended up with something like this to make it compiled:

pub struct Game {
    terminal: Box<dyn Terminal + Send>,
    tetromino_spawner: Box<dyn TetrominoSpawner + Send>,
    highscore_repository: Box<dyn HighScoreRepository + Send>,
}
#[test]
fn clear_lines() -> Result<()> {
    let tetromino_spawner = Box::new(SpecificTetromino);
    let conn = Connection::open_in_memory()?;
    let sqlite_highscore_repository = Box::new(SqliteHighScoreRepository { conn });

    let (tx, rx): (Sender<KeyCode>, Receiver<KeyCode>) = channel();
    let mut game = Game::new(
        Box::new(MockTerminal::new(Some(rx))),
        40,
        20,
        tetromino_spawner,
        sqlite_highscore_repository,
        None,
        None,
        0,
        0,
    )?;

    let receiver = thread::spawn(move || {
        game.start().unwrap();
    });

    tx.send(KeyCode::Char('q')).unwrap();
    tx.send(KeyCode::Char('y')).unwrap();

    receiver.join().unwrap();

    Ok(())
}

The question now is how can I assert after each action

  • send h (move left) -> check if the column is decreased by 1
  • send j (hard drop) -> check if row is 19
  • clear one line -> check if score is 100
  • ...

Here's what I've tried:

    let (tx, rx): (Sender<KeyCode>, Receiver<KeyCode>) = channel();
    let (signal_tx, signal_rx): (Sender<()>, Receiver<()>) = channel();
    let mut game = Game::new(
        Box::new(MockTerminal::new(Some(rx))),
        40,
        20,
        tetromino_spawner,
        sqlite_highscore_repository,
        None,
        None,
        0,
        0,
    )?;

    let receiver = thread::spawn(move || {
        game.start().unwrap();

        signal_rx.recv().unwrap();
        assert_eq!(game.current_tetromino.position.col, 3);
    });

    tx.send(KeyCode::Char('h')).unwrap();
    signal_tx.send(()).unwrap();

    tx.send(KeyCode::Char('q')).unwrap();
    tx.send(KeyCode::Char('y')).unwrap();

    receiver.join().unwrap();

    Ok(())

but it doesn't work:

     Running tests/integration_test.rs (target/debug/deps/integration_test-81597c4542f1a01f)

running 1 test
   Doc-tests tetris-tui

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Why there is no test result for tests/integration_test.rs?

Well you're not running any tests, so I assume you're not marking anything with #[test]

Check out Testing - Rust By Example

Actually, it was running, please pay attention to "Running tests/integration_test.rs" and "running 1 test".
To be confirmed, I added a logging statement into the read_event function:

    fn read_event(&self) -> Result<Event> {
        if let Some(mock_key_code) = &self.mock_key_code {
            for code in mock_key_code.try_iter() {
                println!("Received: {:?}", code);
                return Ok(Event::Key(KeyEvent {
                    code,
                    modifiers: KeyModifiers::empty(),
                    kind: KeyEventKind::Press,
                    state: KeyEventState::empty(),
                }));
            }
        }

        Ok(Event::Key(KeyEvent {
            code: KeyCode::Null,
            modifiers: KeyModifiers::empty(),
            kind: KeyEventKind::Press,
            state: KeyEventState::empty(),
        }))
    }

Here's the output when running cargo test -- --nocapture:

     Running tests/integration_test.rs (target/debug/deps/integration_test-81597c4542f1a01f)

running 1 test
Received: Char('h')
Received: Char('q')
Received: Char('y')

I think I'm in a dilemma situation: there is a loop in game.start() function, read_event() will wait for some key codes to be sent into the channel. If I send q (quit), then y (confirm), the app will quit before reaching the assert_eq!. That's the reason why there is no output related to assertion.

It's back to my previous question:

How can I assert after each action?

  • send h (move left) -> check if the column is decreased by 1
  • send j (hard drop) -> check if row is 19
  • clear one line -> check if score is 100
  • ...

Note that you don't need to make it an integration test. In fact, if it's a unit test it will have access to private state for assertions.

Using better synchronization seems like the way. Your test has race conditions, and that can all be sorted by running the game thread and test thread in lockstep. Something like:

tx.send(KeyCode::Char('h')).unwrap();
let resp = response_rx.recv().unwrap();
assert!(...);

tx.send(KeyCode::Char('q')).unwrap();
let resp = response_rx.recv().unwrap();
assert!(...);

tx.send(KeyCode::Char('y')).unwrap();
let resp = response_rx.recv().unwrap();
assert!(...);

receiver.join().unwrap();
2 Likes

@parasyte Do you mean something like this?

    struct MockTerminal {
        mock_key_code: Option<Receiver<KeyCode>>,
        response_tx: Option<Sender<Response>>,
    }

    #[derive(Debug, PartialEq)]
    enum Response {
        Acknowledge,
    }
        fn read_event(&self) -> Result<Event> {
            if let Some(mock_key_code) = &self.mock_key_code {
                for code in mock_key_code.try_iter() {
                    println!("Received: {:?}", code);

                    if let Some(response_tx) = &self.response_tx {
                        response_tx.send(Response::Acknowledge).unwrap();
                    }

                    return Ok(Event::Key(KeyEvent {
                        code,
                        modifiers: KeyModifiers::empty(),
                        kind: KeyEventKind::Press,
                        state: KeyEventState::empty(),
                    }));
                }
            }

            Ok(Event::Key(KeyEvent {
                code: KeyCode::Null,
                modifiers: KeyModifiers::empty(),
                kind: KeyEventKind::Press,
                state: KeyEventState::empty(),
            }))
        }
        let (tx, rx): (Sender<KeyCode>, Receiver<KeyCode>) = channel();
        let (response_tx, response_rx) = channel();
        let mock_terminal = MockTerminal {
            mock_key_code: Some(rx),
            response_tx: Some(response_tx.clone()),
        };

        let game = Arc::new(Mutex::new(Game::new(
            Box::new(mock_terminal),
            40,
            20,
            tetromino_spawner,
            sqlite_highscore_repository,
            None,
            None,
            0,
            0,
        )?));

        let receiver = thread::spawn({
            let game = Arc::clone(&game);
            move || {
                let mut game_lock = game.lock().unwrap();
                game_lock.start().unwrap();
            }
        });

        tx.send(KeyCode::Char('h')).unwrap();
        let resp = response_rx.recv().unwrap();
        assert_eq!(resp, Response::Acknowledge);

        let game_lock = game.lock().unwrap();
        assert_eq!(game_lock.current_tetromino.position.col, 0);

        tx.send(KeyCode::Char('q')).unwrap();
        tx.send(KeyCode::Char('y')).unwrap();

        receiver.join().unwrap();

        Ok(())

Seemingly, this has caused a deadlock when asserting on tetromino position:

     Running unittests src/lib.rs (target/debug/deps/tetris_tui-9de94e197983383c)

running 1 test
Received: Char('h')

In game thread, I'm not sure how can I drop the lock after receiving the Char('h') and update the tetromino position?

Deadlock occurs when you attempt to acquire the lock in the test, while the lock is already held by the game thread. What I meant with the "return channel" was literally passing the state back through the channel for assertions. It's very easy to do if you don't mind cloning the state for your tests.

And of course, you wouldn't send the state until after it has been updated by handling the key event. Going back to what I said earlier about dependency inversion introducing weird code, this is a great example of that. If you need to do end-to-end automated testing, some sacrifices will have to be made.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.