television/tests/app.rs
LM c8df96270a
refactor(help): more informative help panel (#668)
## 📺 PR Description

Rework the help panel to show live keybinding source information
(global, channel) instead of hard-coded entries

Before
<img width="511" height="934" alt="image"
src="https://github.com/user-attachments/assets/bea29e07-ec4a-443d-a468-be1e9f28a705"
/>

After
<img width="690" height="1355" alt="image"
src="https://github.com/user-attachments/assets/a930382b-e814-482a-a729-54ef2dc286c4"
/>

## Env

### Command

```bash
RUST_LOG=debug cargo run -q -- --cable-dir ./cable/unix --config-file ./.config/config.toml --show-help-panel --keybindings "f10 = \"toggle_help\"; f9 = \"toggle_status_bar\""
```

### Config file
```toml
[metadata]
name = "files"
description = "A channel to select files and directories"
requirements = ["fd", "bat"]

[source]
command = ["fd -t f", "fd -t f -H"]

[preview]
command = "bat -n --color=always '{}'"
env = { BAT_THEME = "ansi" }

[keybindings]
shortcut = "f1"
f9 = "toggle_preview"
f8 = "quit"
esc = false
```

## Checklist

<!-- a quick pass through the following items to make sure you haven't
forgotten anything -->

- [x] my commits **and PR title** follow the [conventional
commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format
- [x] if this is a new feature, I have added tests to consolidate the
feature and prevent regressions
- [ ] if this is a bug fix, I have added a test that reproduces the bug
(if applicable)
- [x] I have added a reasonable amount of documentation to the code
where appropriate
2025-07-25 13:16:12 +02:00

280 lines
8.1 KiB
Rust

//! This module tests the inner `App` struct of the `television` crate.
use std::{
collections::HashSet, path::PathBuf, thread::sleep, time::Duration,
};
use television::{
action::Action,
app::{App, AppOptions},
cable::Cable,
channels::prototypes::ChannelPrototype,
cli::PostProcessedCli,
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(1000);
/// 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(
channel_prototype: Option<ChannelPrototype>,
select_1: bool,
exact: bool,
) -> (
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 chan: ChannelPrototype = channel_prototype
.unwrap_or(ChannelPrototype::new("files", "find . -type f"));
let mut config = default_config_from_file().unwrap();
// this speeds up the tests
config.application.tick_rate = 100.0;
let input = None;
let options = AppOptions::new(
exact,
select_1,
false,
false,
false,
false,
Some(50),
config.application.tick_rate,
0.0, // watch_interval
None,
None,
false,
);
let cli_args = PostProcessedCli {
exact,
input: input.clone(),
..PostProcessedCli::default()
};
let mut app = App::new(
chan,
config,
options,
Cable::from_prototypes(vec![
ChannelPrototype::new("files", "find . -type f"),
ChannelPrototype::new("dirs", "find . -type d"),
ChannelPrototype::new("env", "printenv"),
]),
&cli_args,
);
// 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(100));
(f, tx)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_does_quit() {
let (f, tx) = setup_app(None, false, false);
// 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);
assert!(f.is_finished());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_starts_normally() {
let (f, _) = setup_app(None, false, false);
// assert that the app is still running after the default timeout
std::thread::sleep(DEFAULT_TIMEOUT);
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(None, false, false);
// send actions to the app
for c in "file1".chars() {
tx.send(Action::AddInputChar(c)).unwrap();
sleep(Duration::from_millis(100));
}
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().raw,
"./file1.txt"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_basic_search_multiselect() {
let (f, tx) = setup_app(None, false, false);
// 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.raw)
.collect::<HashSet<_>>(),
HashSet::from([
&"./file1.txt".to_string(),
&"./file2.txt".to_string()
])
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_exact_search_multiselect() {
let (f, tx) = setup_app(None, false, true);
// send actions to the app
for c in "fie".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();
let selected_entries = output.selected_entries.clone();
assert!(selected_entries.is_some());
// should contain a single entry with the prompt
assert!(!selected_entries.as_ref().unwrap().is_empty());
assert_eq!(selected_entries.unwrap().drain().next().unwrap().raw, "fie");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_exact_search_positive() {
let (f, tx) = setup_app(None, false, true);
// 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.raw)
.collect::<HashSet<_>>(),
HashSet::from([
&"./file1.txt".to_string(),
&"./file2.txt".to_string()
])
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_exits_when_select_1_and_only_one_result() {
let prototype = ChannelPrototype::new("some_channel", "echo file1.txt");
let (f, tx) = setup_app(Some(prototype), true, false);
// tick a few times to get the results
for _ in 0..=10 {
tx.send(Action::Tick).unwrap();
}
// check the output with a timeout
// Note: we don't need to send a confirm action here, as the app should
// exit automatically when there's only one result
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().raw,
"file1.txt"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_does_not_exit_when_select_1_and_more_than_one_result() {
let prototype =
ChannelPrototype::new("some_channel", "echo 'file1.txt\nfile2.txt'");
let (f, tx) = setup_app(Some(prototype), true, false);
// tick a few times to get the results
for _ in 0..=10 {
tx.send(Action::Tick).unwrap();
}
// check that the app is still running after the default timeout
let output = timeout(DEFAULT_TIMEOUT, f).await;
assert!(output.is_err());
}