From 92f262f795df1b9de0a8280ad9855cb9203557d9 Mon Sep 17 00:00:00 2001 From: alex pasmantier Date: Mon, 10 Mar 2025 16:10:34 +0100 Subject: [PATCH] test: more tests for cli, app, and main --- television/app.rs | 91 ++++++++++++----- television/cli.rs | 21 +++- television/config/mod.rs | 9 +- television/keymap.rs | 32 ++++-- television/main.rs | 199 +++++++++++++++++++++++++++---------- television/utils/syntax.rs | 4 +- tests/app.rs | 135 +++++++++++++++++++++++++ tests/target_dir/file1.txt | 1 + tests/target_dir/file2.txt | 1 + 9 files changed, 399 insertions(+), 94 deletions(-) create mode 100644 tests/app.rs create mode 100644 tests/target_dir/file1.txt create mode 100644 tests/target_dir/file2.txt diff --git a/television/app.rs b/television/app.rs index f1a7c16..daad908 100644 --- a/television/app.rs +++ b/television/app.rs @@ -29,7 +29,9 @@ pub struct App { /// A flag that indicates whether the application should suspend during the next frame. should_suspend: bool, /// A sender channel for actions. - action_tx: mpsc::UnboundedSender, + /// + /// This is made public so that tests for instance can send actions to a running application. + pub action_tx: mpsc::UnboundedSender, /// The receiver channel for actions. action_rx: mpsc::UnboundedReceiver, /// The receiver channel for events. @@ -38,9 +40,19 @@ pub struct App { event_abort_tx: mpsc::UnboundedSender<()>, /// A sender channel for rendering tasks. render_tx: mpsc::UnboundedSender, + /// The receiver channel for rendering tasks. + /// + /// This will most of the time get replaced by the rendering task handle once the rendering + /// task is started but is needed for tests, where we start the app in "headless" mode and + /// need to keep a fake rendering channel alive so that the rest of the application can run + /// without any further modifications. + #[allow(dead_code)] + render_rx: mpsc::UnboundedReceiver, /// A channel that listens to UI updates. ui_state_rx: mpsc::UnboundedReceiver, ui_state_tx: mpsc::UnboundedSender, + /// Render task handle + render_task: Option>>, } /// The outcome of an action. @@ -91,9 +103,9 @@ impl App { config: Config, passthrough_keybindings: &[String], input: Option, - ) -> Result { + ) -> Self { let (action_tx, action_rx) = mpsc::unbounded_channel(); - let (render_tx, _) = mpsc::unbounded_channel(); + let (render_tx, render_rx) = mpsc::unbounded_channel(); let (_, event_rx) = mpsc::unbounded_channel(); let (event_abort_tx, _) = mpsc::unbounded_channel(); let tick_rate = config.config.tick_rate; @@ -106,13 +118,13 @@ impl App { Err(e) => Err(e), }) .collect(), - )?; + ); debug!("{:?}", keymap); let (ui_state_tx, ui_state_rx) = mpsc::unbounded_channel(); let television = Television::new(action_tx.clone(), channel, config, input); - Ok(Self { + Self { keymap, tick_rate, television, @@ -123,9 +135,11 @@ impl App { event_rx, event_abort_tx, render_tx, + render_rx, ui_state_rx, ui_state_tx, - }) + render_task: None, + } } /// Run the application main loop. @@ -142,22 +156,33 @@ impl App { /// /// # Errors /// If an error occurs during the execution of the application. - pub async fn run(&mut self, is_output_tty: bool) -> Result { - debug!("Starting backend event loop"); - let event_loop = EventLoop::new(self.tick_rate, true); - self.event_rx = event_loop.rx; - self.event_abort_tx = event_loop.abort_tx; + pub async fn run( + &mut self, + is_output_tty: bool, + headless: bool, + ) -> Result { + if !headless { + debug!("Starting backend event loop"); + let event_loop = EventLoop::new(self.tick_rate, true); + self.event_rx = event_loop.rx; + self.event_abort_tx = event_loop.abort_tx; + } // Rendering loop - debug!("Starting rendering loop"); - let (render_tx, render_rx) = mpsc::unbounded_channel(); - self.render_tx = render_tx.clone(); - let ui_state_tx = self.ui_state_tx.clone(); - let action_tx_r = self.action_tx.clone(); - let rendering_task = tokio::spawn(async move { - render(render_rx, action_tx_r, ui_state_tx, is_output_tty).await - }); - self.action_tx.send(Action::Render)?; + if !headless { + debug!("Starting rendering loop"); + let (render_tx, render_rx) = mpsc::unbounded_channel(); + self.render_tx = render_tx.clone(); + let ui_state_tx = self.ui_state_tx.clone(); + let action_tx_r = self.action_tx.clone(); + self.render_task = Some(tokio::spawn(async move { + render(render_rx, action_tx_r, ui_state_tx, is_output_tty) + .await + })); + self.action_tx + .send(Action::Render) + .expect("Unable to send init render action."); + } // event handling loop debug!("Starting event handling loop"); @@ -177,21 +202,33 @@ impl App { action_tx.send(action)?; } } - - let action_outcome = self.handle_action(&mut action_buf).await?; + let action_outcome = self.handle_actions(&mut action_buf).await?; if self.should_quit { // send a termination signal to the event loop - self.event_abort_tx.send(())?; + if !headless { + self.event_abort_tx.send(())?; + } // wait for the rendering task to finish - rendering_task.await??; + if let Some(rendering_task) = self.render_task.take() { + rendering_task.await??; + } return Ok(AppOutput::from(action_outcome)); } } } + /// Run the application in headless mode. + /// + /// This function will start the event loop and handle all actions that are sent to the + /// application but will never start the rendering loop. This is mostly used in tests as + /// a means to run the application and control it via the actions channel. + pub async fn run_headless(&mut self) -> Result { + self.run(false, true).await + } + /// Convert an event to an action. /// /// This function will convert an event to an action based on the current @@ -249,7 +286,7 @@ impl App { /// /// # Errors /// If an error occurs during the execution of the application. - async fn handle_action( + async fn handle_actions( &mut self, buf: &mut Vec, ) -> Result { @@ -273,7 +310,9 @@ impl App { } Action::SelectAndExit => { self.should_quit = true; - self.render_tx.send(RenderingTask::Quit)?; + if !self.render_tx.is_closed() { + self.render_tx.send(RenderingTask::Quit)?; + } if let Some(entries) = self .television .get_selected_entries(Some(Mode::Channel)) diff --git a/television/cli.rs b/television/cli.rs index 946d116..0e8cc3a 100644 --- a/television/cli.rs +++ b/television/cli.rs @@ -64,7 +64,7 @@ pub struct Cli { command: Option, } -#[derive(Subcommand, Debug, PartialEq)] +#[derive(Subcommand, Debug, PartialEq, Clone)] pub enum Command { /// Lists available channels ListChannels, @@ -98,7 +98,7 @@ impl From for UtilShell { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct PostProcessedCli { pub channel: ParsedCliChannel, pub preview_command: Option, @@ -112,6 +112,23 @@ pub struct PostProcessedCli { pub autocomplete_prompt: Option, } +impl Default for PostProcessedCli { + fn default() -> Self { + Self { + channel: ParsedCliChannel::Builtin(CliTvChannel::Files), + preview_command: None, + no_preview: false, + tick_rate: None, + frame_rate: None, + passthrough_keybindings: Vec::new(), + input: None, + command: None, + working_directory: None, + autocomplete_prompt: None, + } + } +} + impl From for PostProcessedCli { fn from(cli: Cli) -> Self { let passthrough_keybindings = cli diff --git a/television/config/mod.rs b/television/config/mod.rs index 451695d..e80dc1f 100644 --- a/television/config/mod.rs +++ b/television/config/mod.rs @@ -94,12 +94,17 @@ impl ConfigEnv { } } +pub fn default_config_from_file() -> Result { + let default_config: Config = toml::from_str(DEFAULT_CONFIG) + .context("Error parsing default config")?; + Ok(default_config) +} + impl Config { #[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)] pub fn new(config_env: &ConfigEnv) -> Result { // Load the default_config values as base defaults - let default_config: Config = toml::from_str(DEFAULT_CONFIG) - .expect("Error parsing default config"); + let default_config: Config = default_config_from_file()?; // if a config file exists, load it and merge it with the default configuration if config_env.config_dir.join(CONFIG_FILE_NAME).is_file() { diff --git a/television/keymap.rs b/television/keymap.rs index 47125da..9cb9253 100644 --- a/television/keymap.rs +++ b/television/keymap.rs @@ -2,13 +2,27 @@ use rustc_hash::FxHashMap; use std::ops::Deref; use crate::television::Mode; -use anyhow::Result; use crate::action::Action; use crate::config::{Binding, KeyBindings}; use crate::event::Key; #[derive(Default, Debug)] +/// A keymap is a set of mappings of keys to actions for every mode. +/// +/// # Example: +/// ```ignore +/// Keymap { +/// Mode::Channel => { +/// Key::Char('j') => Action::MoveDown, +/// Key::Char('k') => Action::MoveUp, +/// Key::Char('q') => Action::Quit, +/// }, +/// Mode::Insert => { +/// Key::Ctrl('a') => Action::MoveToStart, +/// }, +/// } +/// ``` pub struct Keymap(pub FxHashMap>); impl Deref for Keymap { @@ -19,6 +33,11 @@ impl Deref for Keymap { } impl From<&KeyBindings> for Keymap { + /// Convert a `KeyBindings` into a `Keymap`. + /// + /// This essentially "reverses" the inner `KeyBindings` structure, so that each mode keymap is + /// indexed by its keys instead of the actions so as to be used as a routing table for incoming + /// key events. fn from(keybindings: &KeyBindings) -> Self { let mut keymap = FxHashMap::default(); for (mode, bindings) in keybindings.iter() { @@ -42,18 +61,17 @@ impl From<&KeyBindings> for Keymap { } impl Keymap { + /// For a provided `Mode`, merge the given `mappings` into the keymap. pub fn with_mode_mappings( mut self, mode: Mode, mappings: Vec<(Key, Action)>, - ) -> Result { - let mode_keymap = self - .0 - .get_mut(&mode) - .ok_or_else(|| anyhow::anyhow!("Mode {:?} not found", mode))?; + ) -> Self { + let mode_keymap = self.0.entry(mode).or_default(); + for (key, action) in mappings { mode_keymap.insert(key, action); } - Ok(self) + self } } diff --git a/television/main.rs b/television/main.rs index d60b447..df04224 100644 --- a/television/main.rs +++ b/television/main.rs @@ -62,8 +62,8 @@ async fn main() -> Result<()> { config.ui.show_preview_panel = false; } - if let Some(working_directory) = args.working_directory { - let path = Path::new(&working_directory); + if let Some(working_directory) = &args.working_directory { + let path = Path::new(working_directory); if !path.exists() { error!( "Working directory \"{}\" does not exist", @@ -80,60 +80,149 @@ async fn main() -> Result<()> { CLIPBOARD.with(<_>::default); - match App::new( - { - if is_readable_stdin() { - debug!("Using stdin channel"); - TelevisionChannel::Stdin(StdinChannel::new( - args.preview_command.map(PreviewType::Command), - )) - } else if let Some(prompt) = args.autocomplete_prompt { - let channel = guess_channel_from_prompt( - &prompt, - &config.shell_integration.commands, - )?; - debug!("Using guessed channel: {:?}", channel); - match channel { - ParsedCliChannel::Builtin(c) => c.to_channel(), - ParsedCliChannel::Cable(c) => { - TelevisionChannel::Cable(c.into()) - } - } - } else { - debug!("Using {:?} channel", args.channel); - match args.channel { - ParsedCliChannel::Builtin(c) => c.to_channel(), - ParsedCliChannel::Cable(c) => { - TelevisionChannel::Cable(c.into()) - } - } - } - }, - config, - &args.passthrough_keybindings, - args.input, - ) { - Ok(mut app) => { - stdout().flush()?; - let output = app.run(stdout().is_terminal()).await?; - info!("{:?}", output); - // lock stdout - let stdout_handle = stdout().lock(); - let mut bufwriter = BufWriter::new(stdout_handle); - if let Some(passthrough) = output.passthrough { - writeln!(bufwriter, "{passthrough}")?; - } - if let Some(entries) = output.selected_entries { - for entry in &entries { - writeln!(bufwriter, "{}", entry.stdout_repr())?; - } - } - bufwriter.flush()?; - exit(0); + let channel = + determine_channel(args.clone(), &config, is_readable_stdin())?; + + let mut app = + App::new(channel, config, &args.passthrough_keybindings, args.input); + + stdout().flush()?; + let output = app.run(stdout().is_terminal(), false).await?; + info!("{:?}", output); + // lock stdout + let stdout_handle = stdout().lock(); + let mut bufwriter = BufWriter::new(stdout_handle); + if let Some(passthrough) = output.passthrough { + writeln!(bufwriter, "{passthrough}")?; + } + if let Some(entries) = output.selected_entries { + for entry in &entries { + writeln!(bufwriter, "{}", entry.stdout_repr())?; } - Err(err) => { - println!("{err:?}"); - exit(1); + } + bufwriter.flush()?; + exit(0); +} + +pub fn determine_channel( + args: PostProcessedCli, + config: &Config, + readable_stdin: bool, +) -> Result { + if readable_stdin { + debug!("Using stdin channel"); + Ok(TelevisionChannel::Stdin(StdinChannel::new( + args.preview_command.map(PreviewType::Command), + ))) + } else if let Some(prompt) = args.autocomplete_prompt { + let channel = guess_channel_from_prompt( + &prompt, + &config.shell_integration.commands, + )?; + debug!("Using guessed channel: {:?}", channel); + match channel { + ParsedCliChannel::Builtin(c) => Ok(c.to_channel()), + ParsedCliChannel::Cable(c) => { + Ok(TelevisionChannel::Cable(c.into())) + } + } + } else { + debug!("Using {:?} channel", args.channel); + match args.channel { + ParsedCliChannel::Builtin(c) => Ok(c.to_channel()), + ParsedCliChannel::Cable(c) => { + Ok(TelevisionChannel::Cable(c.into())) + } } } } + +#[cfg(test)] +mod tests { + use rustc_hash::FxHashMap; + + use super::*; + + fn assert_is_correct_channel( + args: &PostProcessedCli, + config: &Config, + readable_stdin: bool, + expected_channel: &TelevisionChannel, + ) { + let channel = + determine_channel(args.clone(), config, readable_stdin).unwrap(); + + assert!( + channel.name() == expected_channel.name(), + "Expected {:?} but got {:?}", + expected_channel.name(), + channel.name() + ); + } + + #[tokio::test] + async fn test_determine_channel_readable_stdin() { + let channel = television::cli::ParsedCliChannel::Builtin( + television::channels::CliTvChannel::Env, + ); + let args = PostProcessedCli { + channel, + ..Default::default() + }; + let config = Config::default(); + assert_is_correct_channel( + &args, + &config, + true, + &TelevisionChannel::Stdin(StdinChannel::new(None)), + ); + } + + #[tokio::test] + async fn test_determine_channel_autocomplete_prompt() { + let autocomplete_prompt = Some("cd".to_string()); + let expected_channel = television::channels::TelevisionChannel::Dirs( + television::channels::dirs::Channel::default(), + ); + let args = PostProcessedCli { + autocomplete_prompt, + ..Default::default() + }; + let mut config = Config { + shell_integration: + television::config::shell_integration::ShellIntegrationConfig { + commands: FxHashMap::default(), + channel_triggers: { + let mut m = FxHashMap::default(); + m.insert("dirs".to_string(), vec!["cd".to_string()]); + m + }, + keybindings: FxHashMap::default(), + }, + ..Default::default() + }; + config.shell_integration.merge_triggers(); + + assert_is_correct_channel(&args, &config, false, &expected_channel); + } + + #[tokio::test] + async fn test_determine_channel_standard_case() { + let channel = television::cli::ParsedCliChannel::Builtin( + television::channels::CliTvChannel::Dirs, + ); + let args = PostProcessedCli { + channel, + ..Default::default() + }; + let config = Config::default(); + assert_is_correct_channel( + &args, + &config, + false, + &TelevisionChannel::Dirs( + television::channels::dirs::Channel::default(), + ), + ); + } +} diff --git a/television/utils/syntax.rs b/television/utils/syntax.rs index 6b2f5c1..03f8770 100644 --- a/television/utils/syntax.rs +++ b/television/utils/syntax.rs @@ -276,8 +276,8 @@ impl HighlightingAssetsExt for HighlightingAssets { /// to stderr when a theme is not found which might mess up the TUI. This function /// suppresses that warning by temporarily redirecting stderr and stdout. fn get_theme_no_output(&self, theme_name: &str) -> &Theme { - let _e = Gag::stderr().unwrap(); - let _o = Gag::stdout().unwrap(); + let _e = Gag::stderr(); + let _o = Gag::stdout(); let theme = self.get_theme(theme_name); theme } diff --git a/tests/app.rs b/tests/app.rs new file mode 100644 index 0000000..547ec9e --- /dev/null +++ b/tests/app.rs @@ -0,0 +1,135 @@ +use std::{collections::HashSet, path::PathBuf, time::Duration}; + +use television::{ + action::Action, app::App, channels::TelevisionChannel, + config::default_config_from_file, +}; +use tokio::{task::JoinHandle, time::timeout}; + +/// Default timeout for tests. +/// +/// This is kept quite high to avoid flakiness in CI. +const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); + +/// Sets up an app with a file channel and default config. +/// +/// Returns a tuple containing the app's `JoinHandle` and the action channel's +/// sender. +/// +/// The app is started in a separate task and can be interacted with by sending +/// actions to the action channel. +fn setup_app() -> ( + JoinHandle, + tokio::sync::mpsc::UnboundedSender, +) { + let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("target_dir"); + std::env::set_current_dir(&target_dir).unwrap(); + let channel = TelevisionChannel::Files( + television::channels::files::Channel::new(vec![target_dir]), + ); + let config = default_config_from_file().unwrap(); + let passthrough_keybindings = Vec::new(); + let input = None; + + let mut app = App::new(channel, config, &passthrough_keybindings, input); + + // retrieve the app's action channel handle in order to send a quit action + let tx = app.action_tx.clone(); + + // start the app in a separate task + let f = tokio::spawn(async move { app.run_headless().await.unwrap() }); + + // let the app spin up + std::thread::sleep(Duration::from_millis(200)); + + (f, tx) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_app_does_quit() { + let (f, tx) = setup_app(); + + // send a quit action to the app + tx.send(Action::Quit).unwrap(); + + // assert that the app quits within a default timeout + std::thread::sleep(DEFAULT_TIMEOUT / 4); + assert!(f.is_finished()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_app_starts_normally() { + let (f, _) = setup_app(); + + // assert that the app is still running after the default timeout + std::thread::sleep(DEFAULT_TIMEOUT / 4); + assert!(!f.is_finished()); + + f.abort(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_app_basic_search() { + let (f, tx) = setup_app(); + + // send actions to the app + for c in "file1".chars() { + tx.send(Action::AddInputChar(c)).unwrap(); + } + tx.send(Action::ConfirmSelection).unwrap(); + + // check the output with a timeout + let output = timeout(DEFAULT_TIMEOUT, f) + .await + .expect("app did not finish within the default timeout") + .unwrap(); + + assert!(output.selected_entries.is_some()); + assert_eq!( + &output + .selected_entries + .unwrap() + .drain() + .next() + .unwrap() + .name, + "file1.txt" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_app_basic_search_multiselect() { + let (f, tx) = setup_app(); + + // send actions to the app + for c in "file".chars() { + tx.send(Action::AddInputChar(c)).unwrap(); + } + + // select both files + tx.send(Action::ToggleSelectionDown).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + tx.send(Action::ToggleSelectionDown).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + tx.send(Action::ConfirmSelection).unwrap(); + + // check the output with a timeout + let output = timeout(DEFAULT_TIMEOUT, f) + .await + .expect("app did not finish within the default timeout") + .unwrap(); + + assert!(output.selected_entries.is_some()); + assert_eq!( + output + .selected_entries + .as_ref() + .unwrap() + .iter() + .map(|e| &e.name) + .collect::>(), + HashSet::from([&"file1.txt".to_string(), &"file2.txt".to_string()]) + ); +} diff --git a/tests/target_dir/file1.txt b/tests/target_dir/file1.txt new file mode 100644 index 0000000..49a9521 --- /dev/null +++ b/tests/target_dir/file1.txt @@ -0,0 +1 @@ +This is file number 1. diff --git a/tests/target_dir/file2.txt b/tests/target_dir/file2.txt new file mode 100644 index 0000000..f3cd4c9 --- /dev/null +++ b/tests/target_dir/file2.txt @@ -0,0 +1 @@ +This is file number 2.