diff --git a/tests/app.rs b/tests/app.rs index 42bfbfa..c295769 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -5,13 +5,12 @@ use scooter::{ }; use serial_test::serial; use std::cmp::max; -use std::fs::{self, create_dir_all}; use std::path::{Path, PathBuf}; use std::thread::sleep; use std::time::{Duration, Instant}; use tempfile::TempDir; -use tokio::fs::File; -use tokio::io::AsyncWriteExt; + +mod utils; fn build_test_search_state() -> SearchState { SearchState { @@ -195,91 +194,6 @@ async fn test_error_popup() { assert_eq!(res, EventHandlingResult::Exit); } -macro_rules! create_test_files { - ($($name:expr => {$($line:expr),+ $(,)?}),+ $(,)?) => { - { - let temp_dir = TempDir::new().unwrap(); - $( - let contents = concat!($($line,"\n",)+); - let path = [temp_dir.path().to_str().unwrap(), $name].join("/"); - let path = Path::new(&path); - create_dir_all(path.parent().unwrap()).unwrap(); - { - let mut file = File::create(path).await.unwrap(); - file.write_all(contents.as_bytes()).await.unwrap(); - file.sync_all().await.unwrap(); - } - )+ - - #[cfg(windows)] - sleep(Duration::from_millis(100)); - temp_dir - } - }; -} -fn collect_files(dir: &Path, base: &Path, files: &mut Vec) { - for entry in fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_file() { - let rel_path = path - .strip_prefix(base) - .unwrap() - .to_str() - .unwrap() - .to_string() - .replace('\\', "/"); - files.push(rel_path); - } else if path.is_dir() { - collect_files(&path, base, files); - } - } -} - -macro_rules! assert_test_files { - ($temp_dir:expr, $($name:expr => {$($line:expr),+ $(,)?}),+ $(,)?) => { - { - use std::fs; - use std::path::Path; - - $( - let expected_contents = concat!($($line,"\n",)+); - let path = Path::new($temp_dir.path()).join($name); - - assert!(path.exists(), "File {} does not exist", $name); - - let actual_contents = fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("Failed to read file {}: {}", $name, e)); - assert_eq!( - actual_contents, - expected_contents, - "Contents mismatch for file {}.\nExpected:\n{}\nActual:\n{}", - $name, - expected_contents, - actual_contents - ); - )+ - - let mut expected_files: Vec = vec![$($name.to_string()),+]; - expected_files.sort(); - - let mut actual_files = Vec::new(); - collect_files( - $temp_dir.path(), - $temp_dir.path(), - &mut actual_files - ); - actual_files.sort(); - - assert_eq!( - actual_files, - expected_files, - "Directory contains unexpected files.\nExpected files: {:?}\nActual files: {:?}", - expected_files, - actual_files - ); - } - }; -} pub fn wait_until(condition: F, timeout: Duration) -> bool where F: Fn() -> bool, @@ -436,7 +350,7 @@ test_with_both_regex_modes!( ) .await; - assert_test_files! { + assert_test_files!( &temp_dir, "file1.txt" => { "This is a test file", @@ -453,7 +367,7 @@ test_with_both_regex_modes!( "123 bar[a-b]+examplebar)(baz 456", "something", } - }; + ); } ); @@ -492,7 +406,7 @@ test_with_both_regex_modes!( ) .await; - assert_test_files! { + assert_test_files!( temp_dir, "file1.txt" => { "This is a test file", @@ -509,7 +423,7 @@ test_with_both_regex_modes!( "123 bar[a-b]+.*bar)(baz 456", "VERB", } - }; + ); } ); @@ -549,7 +463,7 @@ test_with_both_regex_modes!( ) .await; - assert_test_files! { + assert_test_files!( temp_dir, "file1.txt" => { "This is a test file", @@ -566,7 +480,7 @@ test_with_both_regex_modes!( "123 bar[a-b]+.*bar)(baz 456", "something", } - }; + ); } ); @@ -639,7 +553,7 @@ async fn test_advanced_regex_negative_lookahead() { ) .await; - assert_test_files! { + assert_test_files!( temp_dir, "file1.txt" => { "This is a BAR file", @@ -656,7 +570,7 @@ async fn test_advanced_regex_negative_lookahead() { "123 bar[a-b]+.*bar)(baz 456", "something", } - }; + ); } test_with_both_regex_modes!( @@ -694,7 +608,7 @@ test_with_both_regex_modes!( ) .await; - assert_test_files! { + assert_test_files!( temp_dir, "dir1/file1.txt" => { "This is a test file", @@ -711,7 +625,7 @@ test_with_both_regex_modes!( "123 bar[a-b]+.*bar)(baz 456", "something f", } - }; + ); } ); @@ -742,7 +656,7 @@ test_with_both_regex_modes!(test_ignores_gif_file, |advanced_regex: bool| async ) .await; - assert_test_files! { + assert_test_files!( temp_dir, "dir1/file1.txt" => { "Th a text file", @@ -753,7 +667,7 @@ test_with_both_regex_modes!(test_ignores_gif_file, |advanced_regex: bool| async "file3.txt" => { "Th a text file", } - }; + ); }); test_with_both_regex_modes!( @@ -785,7 +699,7 @@ test_with_both_regex_modes!( ) .await; - assert_test_files! { + assert_test_files!( temp_dir, "dir1/file1.txt" => { "This REPLACED a text file", @@ -796,7 +710,7 @@ test_with_both_regex_modes!( ".file3.txt" => { "This is a hidden text file", } - }; + ); } ); @@ -829,7 +743,7 @@ test_with_both_regex_modes!( ) .await; - assert_test_files! { + assert_test_files!( temp_dir, "dir1/file1.txt" => { "This REPLACED a text file", @@ -840,7 +754,7 @@ test_with_both_regex_modes!( ".file3.txt" => { "This REPLACED a hidden text file", } - }; + ); } ); @@ -848,4 +762,3 @@ test_with_both_regex_modes!( // - Add: // - more tests for replacing in files // - tests for passing in directory via CLI arg -// - Tidy up tests - lots of duplication diff --git a/tests/app_runner.rs b/tests/app_runner.rs index 77f4048..4396144 100644 --- a/tests/app_runner.rs +++ b/tests/app_runner.rs @@ -4,13 +4,15 @@ use futures::Stream; use log::LevelFilter; use ratatui::backend::TestBackend; use scooter::app_runner::{AppConfig, AppRunner}; -use std::{io, pin::Pin, task::Poll}; +use std::{io, path::Path, pin::Pin, task::Poll}; use tokio::{ sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, task::JoinHandle, time::{sleep, Duration, Instant}, }; +mod utils; + struct TestEventStream(UnboundedReceiver); impl TestEventStream { @@ -38,41 +40,43 @@ async fn wait_for_text( snapshot_rx: &mut UnboundedReceiver, text: &str, timeout_ms: u64, -) -> anyhow::Result<()> { +) -> anyhow::Result { let timeout = Duration::from_millis(timeout_ms); - let start = Instant::now(); let mut last_snapshot = None; - loop { - if let Ok(snapshot) = snapshot_rx.try_recv() { - if snapshot.contains(text) { - return Ok(()); + + while start.elapsed() <= timeout { + tokio::select! { + snapshot = snapshot_rx.recv() => { + match snapshot { + Some(s) if s.contains(text) => return Ok(s), + Some(s) => { last_snapshot = Some(s); }, + None => bail!("Channel closed while waiting for text: {text}"), + } + } + _ = sleep(timeout - start.elapsed()) => { + break; } - last_snapshot = Some(snapshot); - }; - - if start.elapsed() > timeout { - let formatted_snapshot = match last_snapshot { - Some(snapshot) => &format!("Current buffer snapshot:\n{snapshot}"), - None => "No buffer snapshots recieved", - }; - bail!("Timeout waiting for text: {text}\n{formatted_snapshot}"); } - - sleep(Duration::from_millis(5)).await; } + + let formatted_snapshot = match last_snapshot { + Some(snapshot) => &format!("Current buffer snapshot:\n{snapshot}"), + None => "No buffer snapshots recieved", + }; + bail!("Timeout waiting for text: {text}\n{formatted_snapshot}") } type TestRunner = ( - AppRunner, + JoinHandle<()>, UnboundedSender, UnboundedReceiver, ); -fn build_test_runner() -> anyhow::Result { +fn build_test_runner(directory: Option<&Path>) -> anyhow::Result { let backend = TestBackend::new(80, 24); let config = AppConfig { - directory: None, + directory: directory.map(|d| d.to_str().unwrap().to_owned()), hidden: false, advanced_regex: false, log_level: LevelFilter::Warn, @@ -80,9 +84,16 @@ fn build_test_runner() -> anyhow::Result { let (event_sender, event_stream) = TestEventStream::new(); let (snapshot_tx, snapshot_rx) = mpsc::unbounded_channel(); - let runner = AppRunner::new(config, backend, event_stream)?.with_snapshot_channel(snapshot_tx); - Ok((runner, event_sender, snapshot_rx)) + let mut runner = + AppRunner::new(config, backend, event_stream)?.with_snapshot_channel(snapshot_tx); + runner.init()?; + + let run_handle = tokio::spawn(async move { + runner.run_event_loop().await.unwrap(); + }); + + Ok((run_handle, event_sender, snapshot_rx)) } async fn shutdown( @@ -99,25 +110,136 @@ async fn shutdown( Ok(()) } +fn send_key(key: KeyCode, event_sender: &UnboundedSender) { + event_sender + .send(CrosstermEvent::Key(KeyEvent::new( + key, + KeyModifiers::empty(), + ))) + .unwrap(); +} + +fn send_chars(word: &str, event_sender: &UnboundedSender) { + word.chars() + .for_each(|key| send_key(KeyCode::Char(key), event_sender)); +} + #[tokio::test] -async fn test_basic_search() -> anyhow::Result<()> { - let (mut runner, event_sender, mut snapshot_rx) = build_test_runner()?; - runner.init()?; +async fn test_search_current_dir() -> anyhow::Result<()> { + let (run_handle, event_sender, mut snapshot_rx) = build_test_runner(None)?; - let run_handle = tokio::spawn(async move { - runner.run_event_loop().await.unwrap(); - }); + wait_for_text(&mut snapshot_rx, "Search text", 10).await?; + + send_key(KeyCode::Enter, &event_sender); + + wait_for_text(&mut snapshot_rx, "Still searching", 500).await?; + + wait_for_text(&mut snapshot_rx, "Search complete", 1000).await?; + + shutdown(event_sender, run_handle).await +} + +#[tokio::test] +async fn test_search_and_replace_simple_dir() -> anyhow::Result<()> { + let temp_dir = &create_test_files! { + "dir1/file1.txt" => { + "This is some test content before 123", + " with some spaces at the start", + "and special ? characters 1! @@ # and number 890", + " some tabs and - more % special **** characters ())", + }, + "file2.txt" => { + "from datetime import datetime as dt, timedelta as td", + "def mix_types(x=100, y=\"test\"): return f\"{x}_{y}\" if isinstance(x, int) else None", + "class TestClass:", + " super_long_name_really_before_long_name_very_long_name = 123", + " return super_long_name_really_before_long_name_very_long_name", + "test_dict = {\"key1\": [1,2,3], 123: \"num key\", (\"a\",\"b\"): True, \"before\": 1, \"test-key\": None}", + }, + }; + + let (run_handle, event_sender, mut snapshot_rx) = build_test_runner(Some(temp_dir.path()))?; wait_for_text(&mut snapshot_rx, "Search text", 10).await?; - event_sender.send(CrosstermEvent::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::empty(), - )))?; + send_chars("before", &event_sender); + send_key(KeyCode::Tab, &event_sender); + send_chars("after", &event_sender); + send_key(KeyCode::Enter, &event_sender); - wait_for_text(&mut snapshot_rx, "Still searching", 100).await?; + wait_for_text(&mut snapshot_rx, "Still searching", 500).await?; wait_for_text(&mut snapshot_rx, "Search complete", 1000).await?; + // Nothing should have changed yet + assert_test_files!( + &temp_dir, + "dir1/file1.txt" => { + "This is some test content before 123", + " with some spaces at the start", + "and special ? characters 1! @@ # and number 890", + " some tabs and - more % special **** characters ())", + }, + "file2.txt" => { + "from datetime import datetime as dt, timedelta as td", + "def mix_types(x=100, y=\"test\"): return f\"{x}_{y}\" if isinstance(x, int) else None", + "class TestClass:", + " super_long_name_really_before_long_name_very_long_name = 123", + " return super_long_name_really_before_long_name_very_long_name", + "test_dict = {\"key1\": [1,2,3], 123: \"num key\", (\"a\",\"b\"): True, \"before\": 1, \"test-key\": None}", + }, + ); + + send_key(KeyCode::Enter, &event_sender); + + wait_for_text(&mut snapshot_rx, "Success!", 1000).await?; + + // Verify that "before" has been replaced with "after" + assert_test_files!( + &temp_dir, + "dir1/file1.txt" => { + "This is some test content after 123", + " with some spaces at the start", + "and special ? characters 1! @@ # and number 890", + " some tabs and - more % special **** characters ())", + }, + "file2.txt" => { + "from datetime import datetime as dt, timedelta as td", + "def mix_types(x=100, y=\"test\"): return f\"{x}_{y}\" if isinstance(x, int) else None", + "class TestClass:", + " super_long_name_really_after_long_name_very_long_name = 123", + " return super_long_name_really_after_long_name_very_long_name", + "test_dict = {\"key1\": [1,2,3], 123: \"num key\", (\"a\",\"b\"): True, \"after\": 1, \"test-key\": None}", + }, + ); + + shutdown(event_sender, run_handle).await +} + +#[tokio::test] +async fn test_search_and_replace_empty_dir() -> anyhow::Result<()> { + let temp_dir = &create_test_files! {}; + + let (run_handle, event_sender, mut snapshot_rx) = build_test_runner(Some(temp_dir.path()))?; + + wait_for_text(&mut snapshot_rx, "Search text", 10).await?; + + send_chars("before", &event_sender); + send_key(KeyCode::Tab, &event_sender); + send_chars("after", &event_sender); + send_key(KeyCode::Enter, &event_sender); + + wait_for_text(&mut snapshot_rx, "Still searching", 500).await?; + + wait_for_text(&mut snapshot_rx, "Search complete", 1000).await?; + + assert_test_files!(&temp_dir); + + send_key(KeyCode::Enter, &event_sender); + + wait_for_text(&mut snapshot_rx, "Success!", 1000).await?; + + assert_test_files!(&temp_dir); + shutdown(event_sender, run_handle).await } diff --git a/tests/utils.rs b/tests/utils.rs new file mode 100644 index 0000000..cc5e06c --- /dev/null +++ b/tests/utils.rs @@ -0,0 +1,121 @@ +use std::{fs, path::Path}; + +#[macro_export] +macro_rules! create_test_files { + () => { + { + use tempfile::TempDir; + TempDir::new().unwrap() + } + }; + + ($($name:expr => {$($content:expr),*$(,)?}),+ $(,)?) => { + { + use std::path::Path; + use std::fs::create_dir_all; + use tempfile::TempDir; + use tokio::fs::File; + use tokio::io::AsyncWriteExt; + + let temp_dir = TempDir::new().unwrap(); + $( + let contents = vec![$($content),*].join("\n"); + + let path = [temp_dir.path().to_str().unwrap(), $name].join("/"); + let path = Path::new(&path); + create_dir_all(path.parent().unwrap()).unwrap(); + { + let mut file = File::create(path).await.unwrap(); + file.write_all(contents.as_bytes()).await.unwrap(); + file.sync_all().await.unwrap(); + } + )+ + + #[cfg(windows)] + sleep(Duration::from_millis(100)); + temp_dir + } + }; +} + +pub fn collect_files(dir: &Path, base: &Path, files: &mut Vec) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + let rel_path = path + .strip_prefix(base) + .unwrap() + .to_str() + .unwrap() + .to_string() + .replace('\\', "/"); + files.push(rel_path); + } else if path.is_dir() { + collect_files(&path, base, files); + } + } +} + +#[macro_export] +macro_rules! assert_test_files { + ($temp_dir:expr) => { + { + let mut actual_files = Vec::new(); + utils::collect_files( + $temp_dir.path(), + $temp_dir.path(), + &mut actual_files + ); + + assert!( + actual_files.is_empty(), + "Directory should be empty but contains files: {:?}", + actual_files + ); + } + }; + + ($temp_dir:expr, $($name:expr => {$($line:expr),+ $(,)?}),+ $(,)?) => { + { + use std::fs; + use std::path::Path; + + $( + let expected_contents = concat!($($line,"\n",)+); + let path = Path::new($temp_dir.path()).join($name); + + assert!(path.exists(), "File {} does not exist", $name); + + let actual_contents = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read file {}: {}", $name, e)); + assert_eq!( + actual_contents, + expected_contents, + "Contents mismatch for file {}.\nExpected:\n{}\nActual:\n{}", + $name, + expected_contents, + actual_contents + ); + )+ + + let mut expected_files: Vec = vec![$($name.to_string()),+]; + expected_files.sort(); + + let mut actual_files = Vec::new(); + utils::collect_files( + $temp_dir.path(), + $temp_dir.path(), + &mut actual_files + ); + actual_files.sort(); + + assert_eq!( + actual_files, + expected_files, + "Directory contains unexpected files.\nExpected files: {:?}\nActual files: {:?}", + expected_files, + actual_files + ); + } + }; +}