test: more tests for cli, app, and main (#375)

This commit is contained in:
Alexandre Pasmantier 2025-03-11 01:14:02 +01:00 committed by GitHub
parent fcf4b35272
commit 64b2f730b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 399 additions and 94 deletions

View File

@ -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<Action>,
///
/// This is made public so that tests for instance can send actions to a running application.
pub action_tx: mpsc::UnboundedSender<Action>,
/// The receiver channel for actions.
action_rx: mpsc::UnboundedReceiver<Action>,
/// 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<RenderingTask>,
/// 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<RenderingTask>,
/// A channel that listens to UI updates.
ui_state_rx: mpsc::UnboundedReceiver<UiState>,
ui_state_tx: mpsc::UnboundedSender<UiState>,
/// Render task handle
render_task: Option<tokio::task::JoinHandle<Result<()>>>,
}
/// The outcome of an action.
@ -91,9 +103,9 @@ impl App {
config: Config,
passthrough_keybindings: &[String],
input: Option<String>,
) -> Result<Self> {
) -> 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<AppOutput> {
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<AppOutput> {
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<AppOutput> {
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<Action>,
) -> Result<ActionOutcome> {
@ -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))

View File

@ -64,7 +64,7 @@ pub struct Cli {
command: Option<Command>,
}
#[derive(Subcommand, Debug, PartialEq)]
#[derive(Subcommand, Debug, PartialEq, Clone)]
pub enum Command {
/// Lists available channels
ListChannels,
@ -98,7 +98,7 @@ impl From<Shell> for UtilShell {
}
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct PostProcessedCli {
pub channel: ParsedCliChannel,
pub preview_command: Option<PreviewCommand>,
@ -112,6 +112,23 @@ pub struct PostProcessedCli {
pub autocomplete_prompt: Option<String>,
}
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<Cli> for PostProcessedCli {
fn from(cli: Cli) -> Self {
let passthrough_keybindings = cli

View File

@ -94,12 +94,17 @@ impl ConfigEnv {
}
}
pub fn default_config_from_file() -> Result<Config> {
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<Self> {
// 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() {

View File

@ -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<Mode, FxHashMap<Key, Action>>);
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<Self> {
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
}
}

View File

@ -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<TelevisionChannel> {
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(),
),
);
}
}

View File

@ -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
}

135
tests/app.rs Normal file
View File

@ -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<television::app::AppOutput>,
tokio::sync::mpsc::UnboundedSender<Action>,
) {
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<_>>(),
HashSet::from([&"file1.txt".to_string(), &"file2.txt".to_string()])
);
}

View File

@ -0,0 +1 @@
This is file number 1.

View File

@ -0,0 +1 @@
This is file number 2.