mirror of
https://github.com/alexpasmantier/television.git
synced 2025-06-06 03:25:23 +00:00
test: more tests for cli, app, and main (#375)
This commit is contained in:
parent
fcf4b35272
commit
64b2f730b3
@ -29,7 +29,9 @@ pub struct App {
|
|||||||
/// A flag that indicates whether the application should suspend during the next frame.
|
/// A flag that indicates whether the application should suspend during the next frame.
|
||||||
should_suspend: bool,
|
should_suspend: bool,
|
||||||
/// A sender channel for actions.
|
/// 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.
|
/// The receiver channel for actions.
|
||||||
action_rx: mpsc::UnboundedReceiver<Action>,
|
action_rx: mpsc::UnboundedReceiver<Action>,
|
||||||
/// The receiver channel for events.
|
/// The receiver channel for events.
|
||||||
@ -38,9 +40,19 @@ pub struct App {
|
|||||||
event_abort_tx: mpsc::UnboundedSender<()>,
|
event_abort_tx: mpsc::UnboundedSender<()>,
|
||||||
/// A sender channel for rendering tasks.
|
/// A sender channel for rendering tasks.
|
||||||
render_tx: mpsc::UnboundedSender<RenderingTask>,
|
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.
|
/// A channel that listens to UI updates.
|
||||||
ui_state_rx: mpsc::UnboundedReceiver<UiState>,
|
ui_state_rx: mpsc::UnboundedReceiver<UiState>,
|
||||||
ui_state_tx: mpsc::UnboundedSender<UiState>,
|
ui_state_tx: mpsc::UnboundedSender<UiState>,
|
||||||
|
/// Render task handle
|
||||||
|
render_task: Option<tokio::task::JoinHandle<Result<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The outcome of an action.
|
/// The outcome of an action.
|
||||||
@ -91,9 +103,9 @@ impl App {
|
|||||||
config: Config,
|
config: Config,
|
||||||
passthrough_keybindings: &[String],
|
passthrough_keybindings: &[String],
|
||||||
input: Option<String>,
|
input: Option<String>,
|
||||||
) -> Result<Self> {
|
) -> Self {
|
||||||
let (action_tx, action_rx) = mpsc::unbounded_channel();
|
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_rx) = mpsc::unbounded_channel();
|
||||||
let (event_abort_tx, _) = mpsc::unbounded_channel();
|
let (event_abort_tx, _) = mpsc::unbounded_channel();
|
||||||
let tick_rate = config.config.tick_rate;
|
let tick_rate = config.config.tick_rate;
|
||||||
@ -106,13 +118,13 @@ impl App {
|
|||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)?;
|
);
|
||||||
debug!("{:?}", keymap);
|
debug!("{:?}", keymap);
|
||||||
let (ui_state_tx, ui_state_rx) = mpsc::unbounded_channel();
|
let (ui_state_tx, ui_state_rx) = mpsc::unbounded_channel();
|
||||||
let television =
|
let television =
|
||||||
Television::new(action_tx.clone(), channel, config, input);
|
Television::new(action_tx.clone(), channel, config, input);
|
||||||
|
|
||||||
Ok(Self {
|
Self {
|
||||||
keymap,
|
keymap,
|
||||||
tick_rate,
|
tick_rate,
|
||||||
television,
|
television,
|
||||||
@ -123,9 +135,11 @@ impl App {
|
|||||||
event_rx,
|
event_rx,
|
||||||
event_abort_tx,
|
event_abort_tx,
|
||||||
render_tx,
|
render_tx,
|
||||||
|
render_rx,
|
||||||
ui_state_rx,
|
ui_state_rx,
|
||||||
ui_state_tx,
|
ui_state_tx,
|
||||||
})
|
render_task: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the application main loop.
|
/// Run the application main loop.
|
||||||
@ -142,22 +156,33 @@ impl App {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// If an error occurs during the execution of the application.
|
/// If an error occurs during the execution of the application.
|
||||||
pub async fn run(&mut self, is_output_tty: bool) -> Result<AppOutput> {
|
pub async fn run(
|
||||||
|
&mut self,
|
||||||
|
is_output_tty: bool,
|
||||||
|
headless: bool,
|
||||||
|
) -> Result<AppOutput> {
|
||||||
|
if !headless {
|
||||||
debug!("Starting backend event loop");
|
debug!("Starting backend event loop");
|
||||||
let event_loop = EventLoop::new(self.tick_rate, true);
|
let event_loop = EventLoop::new(self.tick_rate, true);
|
||||||
self.event_rx = event_loop.rx;
|
self.event_rx = event_loop.rx;
|
||||||
self.event_abort_tx = event_loop.abort_tx;
|
self.event_abort_tx = event_loop.abort_tx;
|
||||||
|
}
|
||||||
|
|
||||||
// Rendering loop
|
// Rendering loop
|
||||||
|
if !headless {
|
||||||
debug!("Starting rendering loop");
|
debug!("Starting rendering loop");
|
||||||
let (render_tx, render_rx) = mpsc::unbounded_channel();
|
let (render_tx, render_rx) = mpsc::unbounded_channel();
|
||||||
self.render_tx = render_tx.clone();
|
self.render_tx = render_tx.clone();
|
||||||
let ui_state_tx = self.ui_state_tx.clone();
|
let ui_state_tx = self.ui_state_tx.clone();
|
||||||
let action_tx_r = self.action_tx.clone();
|
let action_tx_r = self.action_tx.clone();
|
||||||
let rendering_task = tokio::spawn(async move {
|
self.render_task = Some(tokio::spawn(async move {
|
||||||
render(render_rx, action_tx_r, ui_state_tx, is_output_tty).await
|
render(render_rx, action_tx_r, ui_state_tx, is_output_tty)
|
||||||
});
|
.await
|
||||||
self.action_tx.send(Action::Render)?;
|
}));
|
||||||
|
self.action_tx
|
||||||
|
.send(Action::Render)
|
||||||
|
.expect("Unable to send init render action.");
|
||||||
|
}
|
||||||
|
|
||||||
// event handling loop
|
// event handling loop
|
||||||
debug!("Starting event handling loop");
|
debug!("Starting event handling loop");
|
||||||
@ -177,21 +202,33 @@ impl App {
|
|||||||
action_tx.send(action)?;
|
action_tx.send(action)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let action_outcome = self.handle_actions(&mut action_buf).await?;
|
||||||
let action_outcome = self.handle_action(&mut action_buf).await?;
|
|
||||||
|
|
||||||
if self.should_quit {
|
if self.should_quit {
|
||||||
// send a termination signal to the event loop
|
// send a termination signal to the event loop
|
||||||
|
if !headless {
|
||||||
self.event_abort_tx.send(())?;
|
self.event_abort_tx.send(())?;
|
||||||
|
}
|
||||||
|
|
||||||
// wait for the rendering task to finish
|
// wait for the rendering task to finish
|
||||||
|
if let Some(rendering_task) = self.render_task.take() {
|
||||||
rendering_task.await??;
|
rendering_task.await??;
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(AppOutput::from(action_outcome));
|
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.
|
/// Convert an event to an action.
|
||||||
///
|
///
|
||||||
/// This function will convert an event to an action based on the current
|
/// This function will convert an event to an action based on the current
|
||||||
@ -249,7 +286,7 @@ impl App {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// If an error occurs during the execution of the application.
|
/// If an error occurs during the execution of the application.
|
||||||
async fn handle_action(
|
async fn handle_actions(
|
||||||
&mut self,
|
&mut self,
|
||||||
buf: &mut Vec<Action>,
|
buf: &mut Vec<Action>,
|
||||||
) -> Result<ActionOutcome> {
|
) -> Result<ActionOutcome> {
|
||||||
@ -273,7 +310,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
Action::SelectAndExit => {
|
Action::SelectAndExit => {
|
||||||
self.should_quit = true;
|
self.should_quit = true;
|
||||||
|
if !self.render_tx.is_closed() {
|
||||||
self.render_tx.send(RenderingTask::Quit)?;
|
self.render_tx.send(RenderingTask::Quit)?;
|
||||||
|
}
|
||||||
if let Some(entries) = self
|
if let Some(entries) = self
|
||||||
.television
|
.television
|
||||||
.get_selected_entries(Some(Mode::Channel))
|
.get_selected_entries(Some(Mode::Channel))
|
||||||
|
@ -64,7 +64,7 @@ pub struct Cli {
|
|||||||
command: Option<Command>,
|
command: Option<Command>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug, PartialEq)]
|
#[derive(Subcommand, Debug, PartialEq, Clone)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Lists available channels
|
/// Lists available channels
|
||||||
ListChannels,
|
ListChannels,
|
||||||
@ -98,7 +98,7 @@ impl From<Shell> for UtilShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PostProcessedCli {
|
pub struct PostProcessedCli {
|
||||||
pub channel: ParsedCliChannel,
|
pub channel: ParsedCliChannel,
|
||||||
pub preview_command: Option<PreviewCommand>,
|
pub preview_command: Option<PreviewCommand>,
|
||||||
@ -112,6 +112,23 @@ pub struct PostProcessedCli {
|
|||||||
pub autocomplete_prompt: Option<String>,
|
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 {
|
impl From<Cli> for PostProcessedCli {
|
||||||
fn from(cli: Cli) -> Self {
|
fn from(cli: Cli) -> Self {
|
||||||
let passthrough_keybindings = cli
|
let passthrough_keybindings = cli
|
||||||
|
@ -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 {
|
impl Config {
|
||||||
#[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)]
|
#[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)]
|
||||||
pub fn new(config_env: &ConfigEnv) -> Result<Self> {
|
pub fn new(config_env: &ConfigEnv) -> Result<Self> {
|
||||||
// Load the default_config values as base defaults
|
// Load the default_config values as base defaults
|
||||||
let default_config: Config = toml::from_str(DEFAULT_CONFIG)
|
let default_config: Config = default_config_from_file()?;
|
||||||
.expect("Error parsing default config");
|
|
||||||
|
|
||||||
// if a config file exists, load it and merge it with the default configuration
|
// 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() {
|
if config_env.config_dir.join(CONFIG_FILE_NAME).is_file() {
|
||||||
|
@ -2,13 +2,27 @@ use rustc_hash::FxHashMap;
|
|||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
|
||||||
use crate::television::Mode;
|
use crate::television::Mode;
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::config::{Binding, KeyBindings};
|
use crate::config::{Binding, KeyBindings};
|
||||||
use crate::event::Key;
|
use crate::event::Key;
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[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>>);
|
pub struct Keymap(pub FxHashMap<Mode, FxHashMap<Key, Action>>);
|
||||||
|
|
||||||
impl Deref for Keymap {
|
impl Deref for Keymap {
|
||||||
@ -19,6 +33,11 @@ impl Deref for Keymap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl From<&KeyBindings> 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 {
|
fn from(keybindings: &KeyBindings) -> Self {
|
||||||
let mut keymap = FxHashMap::default();
|
let mut keymap = FxHashMap::default();
|
||||||
for (mode, bindings) in keybindings.iter() {
|
for (mode, bindings) in keybindings.iter() {
|
||||||
@ -42,18 +61,17 @@ impl From<&KeyBindings> for Keymap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Keymap {
|
impl Keymap {
|
||||||
|
/// For a provided `Mode`, merge the given `mappings` into the keymap.
|
||||||
pub fn with_mode_mappings(
|
pub fn with_mode_mappings(
|
||||||
mut self,
|
mut self,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
mappings: Vec<(Key, Action)>,
|
mappings: Vec<(Key, Action)>,
|
||||||
) -> Result<Self> {
|
) -> Self {
|
||||||
let mode_keymap = self
|
let mode_keymap = self.0.entry(mode).or_default();
|
||||||
.0
|
|
||||||
.get_mut(&mode)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mode {:?} not found", mode))?;
|
|
||||||
for (key, action) in mappings {
|
for (key, action) in mappings {
|
||||||
mode_keymap.insert(key, action);
|
mode_keymap.insert(key, action);
|
||||||
}
|
}
|
||||||
Ok(self)
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -62,8 +62,8 @@ async fn main() -> Result<()> {
|
|||||||
config.ui.show_preview_panel = false;
|
config.ui.show_preview_panel = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(working_directory) = args.working_directory {
|
if let Some(working_directory) = &args.working_directory {
|
||||||
let path = Path::new(&working_directory);
|
let path = Path::new(working_directory);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
error!(
|
error!(
|
||||||
"Working directory \"{}\" does not exist",
|
"Working directory \"{}\" does not exist",
|
||||||
@ -80,42 +80,14 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
CLIPBOARD.with(<_>::default);
|
CLIPBOARD.with(<_>::default);
|
||||||
|
|
||||||
match App::new(
|
let channel =
|
||||||
{
|
determine_channel(args.clone(), &config, is_readable_stdin())?;
|
||||||
if is_readable_stdin() {
|
|
||||||
debug!("Using stdin channel");
|
let mut app =
|
||||||
TelevisionChannel::Stdin(StdinChannel::new(
|
App::new(channel, config, &args.passthrough_keybindings, args.input);
|
||||||
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()?;
|
stdout().flush()?;
|
||||||
let output = app.run(stdout().is_terminal()).await?;
|
let output = app.run(stdout().is_terminal(), false).await?;
|
||||||
info!("{:?}", output);
|
info!("{:?}", output);
|
||||||
// lock stdout
|
// lock stdout
|
||||||
let stdout_handle = stdout().lock();
|
let stdout_handle = stdout().lock();
|
||||||
@ -130,10 +102,127 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
bufwriter.flush()?;
|
bufwriter.flush()?;
|
||||||
exit(0);
|
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()))
|
||||||
}
|
}
|
||||||
Err(err) => {
|
|
||||||
println!("{err:?}");
|
|
||||||
exit(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -276,8 +276,8 @@ impl HighlightingAssetsExt for HighlightingAssets {
|
|||||||
/// to stderr when a theme is not found which might mess up the TUI. This function
|
/// 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.
|
/// suppresses that warning by temporarily redirecting stderr and stdout.
|
||||||
fn get_theme_no_output(&self, theme_name: &str) -> &Theme {
|
fn get_theme_no_output(&self, theme_name: &str) -> &Theme {
|
||||||
let _e = Gag::stderr().unwrap();
|
let _e = Gag::stderr();
|
||||||
let _o = Gag::stdout().unwrap();
|
let _o = Gag::stdout();
|
||||||
let theme = self.get_theme(theme_name);
|
let theme = self.get_theme(theme_name);
|
||||||
theme
|
theme
|
||||||
}
|
}
|
||||||
|
135
tests/app.rs
Normal file
135
tests/app.rs
Normal 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()])
|
||||||
|
);
|
||||||
|
}
|
1
tests/target_dir/file1.txt
Normal file
1
tests/target_dir/file1.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
This is file number 1.
|
1
tests/target_dir/file2.txt
Normal file
1
tests/target_dir/file2.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
This is file number 2.
|
Loading…
x
Reference in New Issue
Block a user