From 115db26ab86fcb50dd14b12b64240b66bbac53f1 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 5 Jun 2021 10:52:45 +0800 Subject: refactor --- Cargo.toml | 2 +- src/interactive/app/mod.rs | 3 + src/interactive/app/tests/journeys_readonly.rs | 264 +++++++++++++++++++ src/interactive/app/tests/journeys_with_writes.rs | 52 ++++ src/interactive/app/tests/mod.rs | 6 + src/interactive/app/tests/unit.rs | 31 +++ src/interactive/app/tests/utils.rs | 303 ++++++++++++++++++++++ src/interactive/app_test/journeys_readonly.rs | 265 ------------------- src/interactive/app_test/journeys_with_writes.rs | 52 ---- src/interactive/app_test/mod.rs | 6 - src/interactive/app_test/unit.rs | 31 --- src/interactive/app_test/utils.rs | 302 --------------------- src/interactive/mod.rs | 9 +- 13 files changed, 663 insertions(+), 663 deletions(-) create mode 100644 src/interactive/app/tests/journeys_readonly.rs create mode 100644 src/interactive/app/tests/journeys_with_writes.rs create mode 100644 src/interactive/app/tests/mod.rs create mode 100644 src/interactive/app/tests/unit.rs create mode 100644 src/interactive/app/tests/utils.rs delete mode 100644 src/interactive/app_test/journeys_readonly.rs delete mode 100644 src/interactive/app_test/journeys_with_writes.rs delete mode 100644 src/interactive/app_test/mod.rs delete mode 100644 src/interactive/app_test/unit.rs delete mode 100644 src/interactive/app_test/utils.rs diff --git a/Cargo.toml b/Cargo.toml index ade2424..fa36d26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ repository = "https://github.com/Byron/dua-cli" readme = "README.md" description = "A tool to conveniently learn about the disk usage of directories, fast!" license = "MIT" -include = ["src/**/*", "Cargo.*", "LICENSE", "README.md", "CHANGELOG.md", "!**/*_test/*"] +include = ["src/**/*", "Cargo.*", "LICENSE", "README.md", "CHANGELOG.md", "!**/tests/*"] [features] default = ["tui-crossplatform"] diff --git a/src/interactive/app/mod.rs b/src/interactive/app/mod.rs index 36ba14e..7959646 100644 --- a/src/interactive/app/mod.rs +++ b/src/interactive/app/mod.rs @@ -7,3 +7,6 @@ pub use bytevis::*; pub use common::*; pub use eventloop::*; pub use handlers::*; + +#[cfg(test)] +mod tests; diff --git a/src/interactive/app/tests/journeys_readonly.rs b/src/interactive/app/tests/journeys_readonly.rs new file mode 100644 index 0000000..f704742 --- /dev/null +++ b/src/interactive/app/tests/journeys_readonly.rs @@ -0,0 +1,264 @@ +use anyhow::Result; +use pretty_assertions::assert_eq; +use std::ffi::OsString; + +use crate::interactive::app::tests::utils::{ + fixture_str, index_by_name, initialized_app_and_terminal_from_fixture, into_keys, + node_by_index, node_by_name, +}; +use crate::interactive::app::tests::FIXTURE_PATH; +use crate::interactive::SortMode; + +#[test] +fn simple_user_journey_read_only() -> Result<()> { + let long_root = "sample-02/dir"; + let short_root = "sample-01"; + let (mut terminal, mut app) = + initialized_app_and_terminal_from_fixture(&[short_root, long_root])?; + + // POST-INIT + // after initialization, we expect that... + { + assert_eq!( + app.state.sorting, + SortMode::SizeDescending, + "it will sort entries in descending order by size" + ); + + assert_eq!( + app.state.is_scanning, false, + "it will not think it is still scanning" + ); + + let first_selected_path = OsString::from(format!("{}/{}", FIXTURE_PATH, long_root)); + assert_eq!( + node_by_name(&app, &first_selected_path).name, + first_selected_path, + "the roots are always listed with the given (possibly long) names", + ); + + assert_eq!( + node_by_name(&app, fixture_str(short_root)), + node_by_index(&app, *app.state.selected.as_ref().unwrap()), + "it selects the first node in the list", + ); + + assert_eq!( + app.traversal.root_index, app.state.root, + "the root is the 'virtual' root", + ); + } + + // SORTING + { + // when hitting the S key + app.process_events(&mut terminal, into_keys(b"s".iter()))?; + assert_eq!( + app.state.sorting, + SortMode::SizeAscending, + "it sets the sort mode to ascending by size" + ); + assert_eq!( + node_by_index(&app, app.state.entries[0].index), + node_by_name(&app, fixture_str(long_root)), + "it recomputes the cached entries" + ); + // when hitting the S key again + app.process_events(&mut terminal, into_keys(b"s".iter()))?; + assert_eq!( + app.state.sorting, + SortMode::SizeDescending, + "it sets the sort mode to descending by size" + ); + assert_eq!( + node_by_index(&app, app.state.entries[0].index), + node_by_name(&app, fixture_str(short_root)), + "it recomputes the cached entries" + ); + } + + // Entry-Navigation + { + // when hitting the j key + app.process_events(&mut terminal, into_keys(b"j".iter()))?; + assert_eq!( + node_by_name(&app, fixture_str(long_root)), + node_by_index(&app, *app.state.selected.as_ref().unwrap()), + "it moves the cursor down and selects the next entry based on the current sort mode" + ); + // when hitting it while there is nowhere to go + app.process_events(&mut terminal, into_keys(b"j".iter()))?; + assert_eq!( + node_by_name(&app, fixture_str(long_root)), + node_by_index(&app, *app.state.selected.as_ref().unwrap()), + "it stays at the previous position" + ); + // when hitting the k key + app.process_events(&mut terminal, into_keys(b"k".iter()))?; + assert_eq!( + node_by_name(&app, fixture_str(short_root)), + node_by_index(&app, *app.state.selected.as_ref().unwrap()), + "it moves the cursor up and selects the next entry based on the current sort mode" + ); + // when hitting the k key again + app.process_events(&mut terminal, into_keys(b"k".iter()))?; + assert_eq!( + node_by_name(&app, fixture_str(short_root)), + node_by_index(&app, *app.state.selected.as_ref().unwrap()), + "it stays at the current cursor position as there is nowhere to go" + ); + // when hitting the o key with a directory selected + app.process_events(&mut terminal, into_keys(b"o".iter()))?; + { + let new_root_idx = index_by_name(&app, fixture_str(short_root)); + assert_eq!( + new_root_idx, app.state.root, + "it enters the entry if it is a directory, changing the root" + ); + assert_eq!( + index_by_name(&app, "dir"), + *app.state.selected.as_ref().unwrap(), + "it selects the first entry in the directory" + ); + + // when hitting the u key while inside a sub-directory + app.process_events(&mut terminal, into_keys(b"u".iter()))?; + { + assert_eq!( + app.traversal.root_index, app.state.root, + "it sets the root to be the (roots) parent directory, being the virtual root" + ); + assert_eq!( + node_by_name(&app, fixture_str(short_root)), + node_by_index(&app, *app.state.selected.as_ref().unwrap()), + "changes the selection to the first item in the list of entries" + ); + } + } + // when hitting the u key while inside of the root directory + // We are moving the cursor down just to have a non-default selection + app.process_events(&mut terminal, into_keys(b"ju".iter()))?; + { + assert_eq!( + app.traversal.root_index, app.state.root, + "it keeps the root - it can't go further up" + ); + assert_eq!( + node_by_name(&app, fixture_str(long_root)), + node_by_index(&app, *app.state.selected.as_ref().unwrap()), + "keeps the previous selection" + ); + } + } + + // Deletion + { + // when hitting the 'd' key (also move cursor back to start) + app.process_events(&mut terminal, into_keys(b"k".iter()))?; + let previously_selected_index = *app.state.selected.as_ref().unwrap(); + app.process_events(&mut terminal, into_keys(b"d".iter()))?; + { + assert_eq!( + Some(1), + app.window.mark_pane.as_ref().map(|p| p.marked().len()), + "it marks only a single node", + ); + assert!( + app.window.mark_pane.as_ref().map_or(false, |p| p + .marked() + .contains_key(&previously_selected_index)), + "it marks the selected node" + ); + assert_eq!( + app.state.selected.as_ref().unwrap().index(), + app.state.entries[1].index.index(), + "moves the cursor down one level to facilitate many markings in a row" + ); + } + + // when hitting the 'd' key again + { + app.process_events(&mut terminal, into_keys(b"d".iter()))?; + + assert_eq!( + Some(2), + app.window.mark_pane.as_ref().map(|p| p.marked().len()), + "it marks the currently selected, second node", + ); + + assert_eq!( + app.state.selected.as_ref().unwrap().index(), + app.state.entries[1].index.index(), + "it could not advance the cursor, thus the newly marked item is still selected" + ); + } + + // when hitting the 'd' key once again + { + app.process_events(&mut terminal, into_keys(b"d".iter()))?; + + assert_eq!( + Some(1), + app.window.mark_pane.as_ref().map(|p| p.marked().len()), + "it toggled the previous selected entry off", + ); + + assert!( + app.window.mark_pane.as_ref().map_or(false, |p| p + .marked() + .contains_key(&previously_selected_index)), + "it leaves the first selected entry marked" + ); + } + // when hitting the spacebar (after moving up to the first entry) + { + app.process_events(&mut terminal, into_keys(b"k ".iter()))?; + + assert_eq!( + None, + app.window.mark_pane.as_ref().map(|p| p.marked().len()), + "it toggles the item off", + ); + + assert_eq!( + node_by_index(&app, previously_selected_index), + node_by_index(&app, *app.state.selected.as_ref().unwrap()), + "it does not advance the selection" + ); + } + } + + // Marking + { + // select something + app.process_events(&mut terminal, into_keys(b" j ".iter()))?; + assert_eq!( + Some(false), + app.window.mark_pane.as_ref().map(|p| p.has_focus()), + "the marker pane starts out without focus", + ); + + assert_eq!( + Some(2), + app.window.mark_pane.as_ref().map(|p| p.marked().len()), + "it has two items marked", + ); + + // when advancing the selection to the marker pane + app.process_events(&mut terminal, into_keys(b"\t".iter()))?; + { + assert_eq!( + Some(true), + app.window.mark_pane.as_ref().map(|p| p.has_focus()), + "after tabbing into it, it has focus", + ); + } + + // TODO: a bunch of additional tests are missing (handling of markers, deselecting them) + // Yes, caught me, no TDD for these things, just because in Rust it's not needed as things + // tend to just work when they compile, and while experimenting, tests can be in the way. + // However, if Dua should be more widely used, we need CI and these tests written. + } + + Ok(()) +} diff --git a/src/interactive/app/tests/journeys_with_writes.rs b/src/interactive/app/tests/journeys_with_writes.rs new file mode 100644 index 0000000..9ea65d6 --- /dev/null +++ b/src/interactive/app/tests/journeys_with_writes.rs @@ -0,0 +1,52 @@ +use crate::interactive::app::tests::utils::{ + initialized_app_and_terminal_from_paths, into_keys, WritableFixture, +}; +use anyhow::Result; +use pretty_assertions::assert_eq; + +#[test] +fn basic_user_journey_with_deletion() -> Result<()> { + let fixture = WritableFixture::from("sample-02"); + let (mut terminal, mut app) = initialized_app_and_terminal_from_paths(&[fixture.root.clone()])?; + + // With a selection of items + app.process_events(&mut terminal, into_keys(b"doddd".iter()))?; + + assert_eq!( + app.window.mark_pane.as_ref().map(|p| p.marked().len()), + Some(4), + "expecting 4 selected entries, the parent dir, and some children" + ); + + assert_eq!( + fixture.as_ref().is_dir(), + true, + "expecting fixture root to exist" + ); + + // When selecting the marker window and pressing the combination to delete entries + app.process_events( + &mut terminal, + vec![ + crosstermion::input::Key::Char('\t'), + crosstermion::input::Key::Ctrl('r'), + ] + .into_iter(), + )?; + assert_eq!( + app.window.mark_pane.is_none(), + true, + "the marker pane is gone as all entries have been removed" + ); + assert_eq!(app.state.selected, None, "nothing is left to be selected"); + assert_eq!( + app.state.root, app.traversal.root_index, + "the only root left is the top-level" + ); + assert_eq!( + fixture.as_ref().is_dir(), + false, + "the directory should have been deleted", + ); + Ok(()) +} diff --git a/src/interactive/app/tests/mod.rs b/src/interactive/app/tests/mod.rs new file mode 100644 index 0000000..ef6efb5 --- /dev/null +++ b/src/interactive/app/tests/mod.rs @@ -0,0 +1,6 @@ +pub const FIXTURE_PATH: &'static str = "tests/fixtures"; + +mod journeys_readonly; +mod journeys_with_writes; +mod unit; +mod utils; diff --git a/src/interactive/app/tests/unit.rs b/src/interactive/app/tests/unit.rs new file mode 100644 index 0000000..3032e4f --- /dev/null +++ b/src/interactive/app/tests/unit.rs @@ -0,0 +1,31 @@ +use crate::interactive::app::tests::utils::{ + debug, initialized_app_and_terminal_from_fixture, sample_01_tree, sample_02_tree, +}; +use anyhow::Result; +use pretty_assertions::assert_eq; + +#[test] +fn it_can_handle_ending_traversal_reaching_top_but_skipping_levels() -> Result<()> { + let (_, app) = initialized_app_and_terminal_from_fixture(&["sample-01"])?; + let expected_tree = sample_01_tree(); + + assert_eq!( + debug(app.traversal.tree), + debug(expected_tree), + "filesystem graph is stable and matches the directory structure" + ); + Ok(()) +} + +#[test] +fn it_can_handle_ending_traversal_without_reaching_the_top() -> Result<()> { + let (_, app) = initialized_app_and_terminal_from_fixture(&["sample-02"])?; + let expected_tree = sample_02_tree(); + + assert_eq!( + debug(app.traversal.tree), + debug(expected_tree), + "filesystem graph is stable and matches the directory structure" + ); + Ok(()) +} diff --git a/src/interactive/app/tests/utils.rs b/src/interactive/app/tests/utils.rs new file mode 100644 index 0000000..26241d4 --- /dev/null +++ b/src/interactive/app/tests/utils.rs @@ -0,0 +1,303 @@ +use anyhow::{Context, Error, Result}; +use dua::{ + traverse::{EntryData, Tree, TreeIndex}, + ByteFormat, TraversalSorting, WalkOptions, +}; +use itertools::Itertools; +use jwalk::{DirEntry, WalkDir}; +use petgraph::prelude::NodeIndex; +use std::{ + env::temp_dir, + ffi::OsStr, + fmt, + fs::{copy, create_dir_all, remove_dir, remove_file}, + io::ErrorKind, + path::{Path, PathBuf}, +}; +use tui::backend::TestBackend; +use tui_react::Terminal; + +use crate::interactive::{app::tests::FIXTURE_PATH, Interaction, TerminalApp}; + +pub fn into_keys<'a>( + bytes: impl Iterator + 'a, +) -> impl Iterator + 'a { + bytes.map(|b| crosstermion::input::Key::Char(std::char::from_u32(*b as u32).unwrap())) +} + +pub fn node_by_index(app: &TerminalApp, id: TreeIndex) -> &EntryData { + app.traversal.tree.node_weight(id).unwrap() +} + +pub fn node_by_name(app: &TerminalApp, name: impl AsRef) -> &EntryData { + node_by_index(app, index_by_name(&app, name)) +} + +pub fn index_by_name_and_size( + app: &TerminalApp, + name: impl AsRef, + size: Option, +) -> TreeIndex { + let name = name.as_ref(); + let t: Vec<_> = app + .traversal + .tree + .node_indices() + .map(|idx| (idx, node_by_index(app, idx))) + .filter_map(|(idx, e)| { + if e.name == name && size.map(|s| s == e.size).unwrap_or(true) { + Some(idx) + } else { + None + } + }) + .collect(); + match t.len() { + 1 => t[0], + 0 => panic!("Node named '{}' not found in tree", name.to_string_lossy()), + n => panic!("Node named '{}' found {} times", name.to_string_lossy(), n), + } +} + +pub fn index_by_name(app: &TerminalApp, name: impl AsRef) -> TreeIndex { + index_by_name_and_size(app, name, None) +} + +pub struct WritableFixture { + pub root: PathBuf, +} + +impl Drop for WritableFixture { + fn drop(&mut self) { + delete_recursive(&self.root).ok(); + } +} + +fn delete_recursive(path: impl AsRef) -> Result<()> { + let mut files: Vec<_> = Vec::new(); + let mut dirs: Vec<_> = Vec::new(); + + for entry in WalkDir::new(&path) + .parallelism(jwalk::Parallelism::Serial) + .into_iter() + { + let entry: DirEntry<_> = entry?; + let p = entry.path(); + match p.is_dir() { + true => dirs.push(p), + false => files.push(p), + } + } + + files + .iter() + .map(|f| remove_file(f).map_err(Error::from)) + .chain( + dirs.iter() + .sorted_by_key(|p| p.components().count()) + .rev() + .map(|d| { + remove_dir(d) + .with_context(|| format!("Could not delete '{}'", d.display())) + .map_err(Error::from) + }), + ) + .collect::>() +} + +fn copy_recursive(src: impl AsRef, dst: impl AsRef) -> Result<(), Error> { + for entry in WalkDir::new(&src) + .parallelism(jwalk::Parallelism::Serial) + .into_iter() + { + let entry: DirEntry<_> = entry?; + let entry_path = entry.path(); + entry_path + .strip_prefix(&src) + .map_err(Error::from) + .and_then(|relative_entry_path| { + let dst = dst.as_ref().join(relative_entry_path); + if entry_path.is_dir() { + create_dir_all(dst).map_err(Into::into) + } else { + copy(&entry_path, dst) + .map(|_| ()) + .or_else(|e| match e.kind() { + ErrorKind::AlreadyExists => Ok(()), + _ => Err(e), + }) + .map_err(Into::into) + } + })?; + } + Ok(()) +} + +impl From<&'static str> for WritableFixture { + fn from(fixture_name: &str) -> Self { + const TEMP_TLD_DIRNAME: &'static str = "dua-unit"; + + let src = fixture(fixture_name); + let dst = temp_dir().join(TEMP_TLD_DIRNAME); + create_dir_all(&dst).unwrap(); + + let dst = dst.join(fixture_name); + copy_recursive(src, &dst).unwrap(); + WritableFixture { root: dst } + } +} + +impl AsRef for WritableFixture { + fn as_ref(&self) -> &Path { + &self.root + } +} + +pub fn fixture(p: impl AsRef) -> PathBuf { + Path::new(FIXTURE_PATH).join(p) +} + +pub fn fixture_str(p: impl AsRef) -> String { + fixture(p).to_str().unwrap().to_owned() +} + +pub fn initialized_app_and_terminal_with_closure( + fixture_paths: &[impl AsRef], + mut convert: impl FnMut(&Path) -> PathBuf, +) -> Result<(Terminal, TerminalApp), Error> { + let mut terminal = new_test_terminal()?; + std::env::set_current_dir(Path::new(env!("CARGO_MANIFEST_DIR")))?; + + let input = fixture_paths.iter().map(|c| convert(c.as_ref())).collect(); + let app = TerminalApp::initialize( + &mut terminal, + WalkOptions { + threads: 1, + byte_format: ByteFormat::Metric, + apparent_size: true, + count_hard_links: false, + sorting: TraversalSorting::AlphabeticalByFileName, + cross_filesystems: false, + }, + input, + Interaction::None, + )? + .map(|(_, app)| app); + Ok(( + terminal, + app.expect("app that didn't try to abort iteration"), + )) +} + +pub fn new_test_terminal() -> std::io::Result> { + Terminal::new(TestBackend::new(40, 20)) +} + +pub fn initialized_app_and_terminal_from_paths( + fixture_paths: &[PathBuf], +) -> Result<(Terminal, TerminalApp), Error> { + fn to_path_buf(p: &Path) -> PathBuf { + p.to_path_buf() + } + initialized_app_and_terminal_with_closure(fixture_paths, to_path_buf) +} + +pub fn initialized_app_and_terminal_from_fixture( + fixture_paths: &[&str], +) -> Result<(Terminal, TerminalApp), Error> { + initialized_app_and_terminal_with_closure(fixture_paths, |p| fixture(p)) +} + +pub fn sample_01_tree() -> Tree { + let mut t = Tree::new(); + { + let mut add_node = make_add_node(&mut t); + #[cfg(not(windows))] + let root_size = 1259070; + #[cfg(windows)] + let root_size = 1259069; + let r = add_node("", root_size, None); + { + let s = add_node(&fixture_str("sample-01"), root_size, Some(r)); + { + add_node(".hidden.666", 666, Some(s)); + add_node("a", 256, Some(s)); + add_node("b.empty", 0, Some(s)); + #[cfg(not(windows))] + add_node("c.lnk", 1, Some(s)); + #[cfg(windows)] + add_node("c.lnk", 0, Some(s)); + let d = add_node("dir", 1258024, Some(s)); + { + add_node("1000bytes", 1000, Some(d)); + add_node("dir-a.1mb", 1_000_000, Some(d)); + add_node("dir-a.kb", 1024, Some(d)); + let e = add_node("empty-dir", 0, Some(d)); + { + add_node(".gitkeep", 0, Some(e)); + } + let sub = add_node("sub", 256_000, Some(d)); + { + add_node("dir-sub-a.256kb", 256_000, Some(sub)); + } + } + add_node("z123.b", 123, Some(s)); + } + } + } + t +} + +pub fn sample_02_tree() -> Tree { + let mut t = Tree::new(); + { + let mut add_node = make_add_node(&mut t); + let root_size = 1540; + let r = add_node("", root_size, None); + { + let s = add_node( + Path::new(FIXTURE_PATH).join("sample-02").to_str().unwrap(), + root_size, + Some(r), + ); + { + add_node("a", 256, Some(s)); + add_node("b", 1, Some(s)); + let d = add_node("dir", 1283, Some(s)); + { + add_node("c", 257, Some(d)); + add_node("d", 2, Some(d)); + let e = add_node("empty-dir", 0, Some(d)); + { + add_node(".gitkeep", 0, Some(e)); + } + let sub = add_node("sub", 1024, Some(d)); + { + add_node("e", 1024, Some(sub)); + } + } + } + } + } + t +} + +pub fn make_add_node<'a>( + t: &'a mut Tree, +) -> impl FnMut(&str, u128, Option) -> NodeIndex + 'a { + move |name, size, maybe_from_idx| { + let n = t.add_node(EntryData { + name: PathBuf::from(name), + size, + metadata_io_error: false, + }); + if let Some(from) = maybe_from_idx { + t.add_edge(from, n, ()); + } + n + } +} + +pub fn debug(item: impl fmt::Debug) -> String { + format!("{:?}", item) +} diff --git a/src/interactive/app_test/journeys_readonly.rs b/src/interactive/app_test/journeys_readonly.rs deleted file mode 100644 index 28d94e6..0000000 --- a/src/interactive/app_test/journeys_readonly.rs +++ /dev/null @@ -1,265 +0,0 @@ -use crate::interactive::{ - app_test::utils::{ - fixture_str, index_by_name, initialized_app_and_terminal_from_fixture, into_keys, - node_by_index, node_by_name, - }, - app_test::FIXTURE_PATH, - SortMode, -}; -use anyhow::Result; -use pretty_assertions::assert_eq; -use std::ffi::OsString; - -#[test] -fn simple_user_journey_read_only() -> Result<()> { - let long_root = "sample-02/dir"; - let short_root = "sample-01"; - let (mut terminal, mut app) = - initialized_app_and_terminal_from_fixture(&[short_root, long_root])?; - - // POST-INIT - // after initialization, we expect that... - { - assert_eq!( - app.state.sorting, - SortMode::SizeDescending, - "it will sort entries in descending order by size" - ); - - assert_eq!( - app.state.is_scanning, false, - "it will not think it is still scanning" - ); - - let first_selected_path = OsString::from(format!("{}/{}", FIXTURE_PATH, long_root)); - assert_eq!( - node_by_name(&app, &first_selected_path).name, - first_selected_path, - "the roots are always listed with the given (possibly long) names", - ); - - assert_eq!( - node_by_name(&app, fixture_str(short_root)), - node_by_index(&app, *app.state.selected.as_ref().unwrap()), - "it selects the first node in the list", - ); - - assert_eq!( - app.traversal.root_index, app.state.root, - "the root is the 'virtual' root", - ); - } - - // SORTING - { - // when hitting the S key - app.process_events(&mut terminal, into_keys(b"s".iter()))?; - assert_eq!( - app.state.sorting, - SortMode::SizeAscending, - "it sets the sort mode to ascending by size" - ); - assert_eq!( - node_by_index(&app, app.state.entries[0].index), - node_by_name(&app, fixture_str(long_root)), - "it recomputes the cached entries" - ); - // when hitting the S key again - app.process_events(&mut terminal, into_keys(b"s".iter()))?; - assert_eq!( - app.state.sorting, - SortMode::SizeDescending, - "it sets the sort mode to descending by size" - ); - assert_eq!( - node_by_index(&app, app.state.entries[0].index), - node_by_name(&app, fixture_str(short_root)), - "it recomputes the cached entries" - ); - } - - // Entry-Navigation - { - // when hitting the j key - app.process_events(&mut terminal, into_keys(b"j".iter()))?; - assert_eq!( - node_by_name(&app, fixture_str(long_root)), - node_by_index(&app, *app.state.selected.as_ref().unwrap()), - "it moves the cursor down and selects the next entry based on the current sort mode" - ); - // when hitting it while there is nowhere to go - app.process_events(&mut terminal, into_keys(b"j".iter()))?; - assert_eq!( - node_by_name(&app, fixture_str(long_root)), - node_by_index(&app, *app.state.selected.as_ref().unwrap()), - "it stays at the previous position" - ); - // when hitting the k key - app.process_events(&mut terminal, into_keys(b"k".iter()))?; - assert_eq!( - node_by_name(&app, fixture_str(short_root)), - node_by_index(&app, *app.state.selected.as_ref().unwrap()), - "it moves the cursor up and selects the next entry based on the current sort mode" - ); - // when hitting the k key again - app.process_events(&mut terminal, into_keys(b"k".iter()))?; - assert_eq!( - node_by_name(&app, fixture_str(short_root)), - node_by_index(&app, *app.state.selected.as_ref().unwrap()), - "it stays at the current cursor position as there is nowhere to go" - ); - // when hitting the o key with a directory selected - app.process_events(&mut terminal, into_keys(b"o".iter()))?; - { - let new_root_idx = index_by_name(&app, fixture_str(short_root)); - assert_eq!( - new_root_idx, app.state.root, - "it enters the entry if it is a directory, changing the root" - ); - assert_eq!( - index_by_name(&app, "dir"), - *app.state.selected.as_ref().unwrap(), - "it selects the first entry in the directory" - ); - - // when hitting the u key while inside a sub-directory - app.process_events(&mut terminal, into_keys(b"u".iter()))?; - { - assert_eq!( - app.traversal.root_index, app.state.root, - "it sets the root to be the (roots) parent directory, being the virtual root" - ); - assert_eq!( - node_by_name(&app, fixture_str(short_root)), - node_by_index(&app, *app.state.selected.as_ref().unwrap()), - "changes the selection to the first item in the list of entries" - ); - } - } - // when hitting the u key while inside of the root directory - // We are moving the cursor down just to have a non-default selection - app.process_events(&mut terminal, into_keys(b"ju".iter()))?; - { - assert_eq!( - app.traversal.root_index, app.state.root, - "it keeps the root - it can't go further up" - ); - assert_eq!( - node_by_name(&app, fixture_str(long_root)), - node_by_index(&app, *app.state.selected.as_ref().unwrap()), - "keeps the previous selection" - ); - } - } - - // Deletion - { - // when hitting the 'd' key (also move cursor back to start) - app.process_events(&mut terminal, into_keys(b"k".iter()))?; - let previously_selected_index = *app.state.selected.as_ref().unwrap(); - app.process_events(&mut terminal, into_keys(b"d".iter()))?; - { - assert_eq!( - Some(1), - app.window.mark_pane.as_ref().map(|p| p.marked().len()), - "it marks only a single node", - ); - assert!( - app.window.mark_pane.as_ref().map_or(false, |p| p - .marked() - .contains_key(&previously_selected_index)), - "it marks the selected node" - ); - assert_eq!( - app.state.selected.as_ref().unwrap().index(), - app.state.entries[1].index.index(), - "moves the cursor down one level to facilitate many markings in a row" - ); - } - - // when hitting the 'd' key again - { - app.process_events(&mut terminal, into_keys(b"d".iter()))?; - - assert_eq!( - Some(2), - app.window.mark_pane.as_ref().map(|p| p.marked().len()), - "it marks the currently selected, second node", - ); - - assert_eq!( - app.state.selected.as_ref().unwrap().index(), - app.state.entries[1].index.index(), - "it could not advance the cursor, thus the newly marked item is still selected" - ); - } - - // when hitting the 'd' key once again - { - app.process_events(&mut terminal, into_keys(b"d".iter()))?; - - assert_eq!( - Some(1), - app.window.mark_pane.as_ref().map(|p| p.marked().len()), - "it toggled the previous selected entry off", - ); - - assert!( - app.window.mark_pane.as_ref().map_or(false, |p| p - .marked() - .contains_key(&previously_selected_index)), - "it leaves the first selected entry marked" - ); - } - // when hitting the spacebar (after moving up to the first entry) - { - app.process_events(&mut terminal, into_keys(b"k ".iter()))?; - - assert_eq!( - None, - app.window.mark_pane.as_ref().map(|p| p.marked().len()), - "it toggles the item off", - ); - - assert_eq!( - node_by_index(&app, previously_selected_index), - node_by_index(&app, *app.state.selected.as_ref().unwrap()), - "it does not advance the selection" - ); - } - } - - // Marking - { - // select something - app.process_events(&mut terminal, into_keys(b" j ".iter()))?; - assert_eq!( - Some(false), - app.window.mark_pane.as_ref().map(|p| p.has_focus()), - "the marker pane starts out without focus", - ); - - assert_eq!( - Some(2), - app.window.mark_pane.as_ref().map(|p| p.marked().len()), - "it has two items marked", - ); - - // when advancing the selection to the marker pane - app.process_events(&mut terminal, into_keys(b"\t".iter()))?; - { - assert_eq!( - Some(true), - app.window.mark_pane.as_ref().map(|p| p.has_focus()), - "after tabbing into it, it has focus", - ); - } - - // TODO: a bunch of additional tests are missing (handling of markers, deselecting them) - // Yes, caught me, no TDD for these things, just because in Rust it's not needed as things - // tend to just work when they compile, and while experimenting, tests can be in the way. - // However, if Dua should be more widely used, we need CI and these tests written. - } - - Ok(()) -} diff --git a/src/interactive/app_test/journeys_with_writes.rs b/src/interactive/app_test/journeys_with_writes.rs deleted file mode 100644 index 83238b2..0000000 --- a/src/interactive/app_test/journeys_with_writes.rs +++ /dev/null @@ -1,52 +0,0 @@ -use crate::interactive::app_test::utils::{ - initialized_app_and_terminal_from_paths, into_keys, WritableFixture, -}; -use anyhow::Result; -use pretty_assertions::assert_eq; - -#[test] -fn basic_user_journey_with_deletion() -> Result<()> { - let fixture = WritableFixture::from("sample-02"); - let (mut terminal, mut app) = initialized_app_and_terminal_from_paths(&[fixture.root.clone()])?; - - // With a selection of items - app.process_events(&mut terminal, into_keys(b"doddd".iter()))?; - - assert_eq!( - app.window.mark_pane.as_ref().map(|p| p.marked().len()), - Some(4), - "expecting 4 selected entries, the parent dir, and some children" - ); - - assert_eq!( - fixture.as_ref().is_dir(), - true, - "expecting fixture root to exist" - ); - - // When selecting the marker window and pressing the combination to delete entries - app.process_events( - &mut terminal, - vec![ - crosstermion::input::Key::Char('\t'), - crosstermion::input::Key::Ctrl('r'), - ] - .into_iter(), - )?; - assert_eq!( - app.window.mark_pane.is_none(), - true, - "the marker pane is gone as all entries have been removed" - ); - assert_eq!(app.state.selected, None, "nothing is left to be selected"); - assert_eq!( - app.state.root, app.traversal.root_index, - "the only root left is the top-level" - ); - assert_eq!( - fixture.as_ref().is_dir(), - false, - "the directory should have been deleted", - ); - Ok(()) -} diff --git a/src/interactive/app_test/mod.rs b/src/interactive/app_test/mod.rs deleted file mode 100644 index ef6efb5..0000000 --- a/src/interactive/app_test/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub const FIXTURE_PATH: &'static str = "tests/fixtures"; - -mod journeys_readonly; -mod journeys_with_writes; -mod unit; -mod utils; diff --git a/src/interactive/app_test/unit.rs b/src/interactive/app_test/unit.rs deleted file mode 100644 index e27d456..0000000 --- a/src/interactive/app_test/unit.rs +++ /dev/null @@ -1,31 +0,0 @@ -use crate::interactive::app_test::utils::{ - debug, initialized_app_and_terminal_from_fixture, sample_01_tree, sample_02_tree, -}; -use anyhow::Result; -use pretty_assertions::assert_eq; - -#[test] -fn it_can_handle_ending_traversal_reaching_top_but_skipping_levels() -> Result<()> { - let (_, app) = initialized_app_and_terminal_from_fixture(&["sample-01"])?; - let expected_tree = sample_01_tree(); - - assert_eq!( - debug(app.traversal.tree), - debug(expected_tree), - "filesystem graph is stable and matches the directory structure" - ); - Ok(()) -} - -#[test] -fn it_can_handle_ending_traversal_without_reaching_the_top() -> Result<()> { - let (_, app) = initialized_app_and_terminal_from_fixture(&["sample-02"])?; - let expected_tree = sample_02_tree(); - - assert_eq!( - debug(app.traversal.tree), - debug(expected_tree), - "filesystem graph is stable and matches the directory structure" - ); - Ok(()) -} diff --git a/src/interactive/app_test/utils.rs b/src/interactive/app_test/utils.rs deleted file mode 100644 index 454acc2..0000000 --- a/src/interactive/app_test/utils.rs +++ /dev/null @@ -1,302 +0,0 @@ -use crate::interactive::{app_test::FIXTURE_PATH, Interaction, TerminalApp}; -use anyhow::{Context, Error, Result}; -use dua::{ - traverse::{EntryData, Tree, TreeIndex}, - ByteFormat, TraversalSorting, WalkOptions, -}; -use itertools::Itertools; -use jwalk::{DirEntry, WalkDir}; -use petgraph::prelude::NodeIndex; -use std::{ - env::temp_dir, - ffi::OsStr, - fmt, - fs::{copy, create_dir_all, remove_dir, remove_file}, - io::ErrorKind, - path::{Path, PathBuf}, -}; -use tui::backend::TestBackend; -use tui_react::Terminal; - -pub fn into_keys<'a>( - bytes: impl Iterator + 'a, -) -> impl Iterator + 'a { - bytes.map(|b| crosstermion::input::Key::Char(std::char::from_u32(*b as u32).unwrap())) -} - -pub fn node_by_index(app: &TerminalApp, id: TreeIndex) -> &EntryData { - app.traversal.tree.node_weight(id).unwrap() -} - -pub fn node_by_name(app: &TerminalApp, name: impl AsRef) -> &EntryData { - node_by_index(app, index_by_name(&app, name)) -} - -pub fn index_by_name_and_size( - app: &TerminalApp, - name: impl AsRef, - size: Option, -) -> TreeIndex { - let name = name.as_ref(); - let t: Vec<_> = app - .traversal - .tree - .node_indices() - .map(|idx| (idx, node_by_index(app, idx))) - .filter_map(|(idx, e)| { - if e.name == name && size.map(|s| s == e.size).unwrap_or(true) { - Some(idx) - } else { - None - } - }) - .collect(); - match t.len() { - 1 => t[0], - 0 => panic!("Node named '{}' not found in tree", name.to_string_lossy()), - n => panic!("Node named '{}' found {} times", name.to_string_lossy(), n), - } -} - -pub fn index_by_name(app: &TerminalApp, name: impl AsRef) -> TreeIndex { - index_by_name_and_size(app, name, None) -} - -pub struct WritableFixture { - pub root: PathBuf, -} - -impl Drop for WritableFixture { - fn drop(&mut self) { - delete_recursive(&self.root).ok(); - } -} - -fn delete_recursive(path: impl AsRef) -> Result<()> { - let mut files: Vec<_> = Vec::new(); - let mut dirs: Vec<_> = Vec::new(); - - for entry in WalkDir::new(&path) - .parallelism(jwalk::Parallelism::Serial) - .into_iter() - { - let entry: DirEntry<_> = entry?; - let p = entry.path(); - match p.is_dir() { - true => dirs.push(p), - false => files.push(p), - } - } - - files - .iter() - .map(|f| remove_file(f).map_err(Error::from)) - .chain( - dirs.iter() - .sorted_by_key(|p| p.components().count()) - .rev() - .map(|d| { - remove_dir(d) - .with_context(|| format!("Could not delete '{}'", d.display())) - .map_err(Error::from) - }), - ) - .collect::>() -} - -fn copy_recursive(src: impl AsRef, dst: impl AsRef) -> Result<(), Error> { - for entry in WalkDir::new(&src) - .parallelism(jwalk::Parallelism::Serial) - .into_iter() - { - let entry: DirEntry<_> = entry?; - let entry_path = entry.path(); - entry_path - .strip_prefix(&src) - .map_err(Error::from) - .and_then(|relative_entry_path| { - let dst = dst.as_ref().join(relative_entry_path); - if entry_path.is_dir() { - create_dir_all(dst).map_err(Into::into) - } else { - copy(&entry_path, dst) - .map(|_| ()) - .or_else(|e| match e.kind() { - ErrorKind::AlreadyExists => Ok(()), - _ => Err(e), - }) - .map_err(Into::into) - } - })?; - } - Ok(()) -} - -impl From<&'static str> for WritableFixture { - fn from(fixture_name: &str) -> Self { - const TEMP_TLD_DIRNAME: &'static str = "dua-unit"; - - let src = fixture(fixture_name); - let dst = temp_dir().join(TEMP_TLD_DIRNAME); - create_dir_all(&dst).unwrap(); - - let dst = dst.join(fixture_name); - copy_recursive(src, &dst).unwrap(); - WritableFixture { root: dst } - } -} - -impl AsRef for WritableFixture { - fn as_ref(&self) -> &Path { - &self.root - } -} - -pub fn fixture(p: impl AsRef) -> PathBuf { - Path::new(FIXTURE_PATH).join(p) -} - -pub fn fixture_str(p: impl AsRef) -> String { - fixture(p).to_str().unwrap().to_owned() -} - -pub fn initialized_app_and_terminal_with_closure>( - fixture_paths: &[P], - mut convert: impl FnMut(&Path) -> PathBuf, -) -> Result<(Terminal, TerminalApp), Error> { - let mut terminal = new_test_terminal()?; - std::env::set_current_dir(Path::new(env!("CARGO_MANIFEST_DIR")))?; - - let input = fixture_paths.iter().map(|c| convert(c.as_ref())).collect(); - let app = TerminalApp::initialize( - &mut terminal, - WalkOptions { - threads: 1, - byte_format: ByteFormat::Metric, - apparent_size: true, - count_hard_links: false, - sorting: TraversalSorting::AlphabeticalByFileName, - cross_filesystems: false, - }, - input, - Interaction::None, - )? - .map(|(_, app)| app); - Ok(( - terminal, - app.expect("app that didn't try to abort iteration"), - )) -} - -pub fn new_test_terminal() -> std::io::Result> { - Terminal::new(TestBackend::new(40, 20)) -} - -pub fn initialized_app_and_terminal_from_paths( - fixture_paths: &[PathBuf], -) -> Result<(Terminal, TerminalApp), Error> { - fn to_path_buf(p: &Path) -> PathBuf { - p.to_path_buf() - } - initialized_app_and_terminal_with_closure(fixture_paths, to_path_buf) -} - -pub fn initialized_app_and_terminal_from_fixture( - fixture_paths: &[&str], -) -> Result<(Terminal, TerminalApp), Error> { - initialized_app_and_terminal_with_closure(fixture_paths, |p| fixture(p)) -} - -pub fn sample_01_tree() -> Tree { - let mut t = Tree::new(); - { - let mut add_node = make_add_node(&mut t); - #[cfg(not(windows))] - let root_size = 1259070; - #[cfg(windows)] - let root_size = 1259069; - let r = add_node("", root_size, None); - { - let s = add_node(&fixture_str("sample-01"), root_size, Some(r)); - { - add_node(".hidden.666", 666, Some(s)); - add_node("a", 256, Some(s)); - add_node("b.empty", 0, Some(s)); - #[cfg(not(windows))] - add_node("c.lnk", 1, Some(s)); - #[cfg(windows)] - add_node("c.lnk", 0, Some(s)); - let d = add_node("dir", 1258024, Some(s)); - { - add_node("1000bytes", 1000, Some(d)); - add_node("dir-a.1mb", 1_000_000, Some(d)); - add_node("dir-a.kb", 1024, Some(d)); - let e = add_node("empty-dir", 0, Some(d)); - { - add_node(".gitkeep", 0, Some(e)); - } - let sub = add_node("sub", 256_000, Some(d)); - { - add_node("dir-sub-a.256kb", 256_000, Some(sub)); - } - } - add_node("z123.b", 123, Some(s)); - } - } - } - t -} - -pub fn sample_02_tree() -> Tree { - let mut t = Tree::new(); - { - let mut add_node = make_add_node(&mut t); - let root_size = 1540; - let r = add_node("", root_size, None); - { - let s = add_node( - Path::new(FIXTURE_PATH).join("sample-02").to_str().unwrap(), - root_size, - Some(r), - ); - { - add_node("a", 256, Some(s)); - add_node("b", 1, Some(s)); - let d = add_node("dir", 1283, Some(s)); - { - add_node("c", 257, Some(d)); - add_node("d", 2, Some(d)); - let e = add_node("empty-dir", 0, Some(d)); - { - add_node(".gitkeep", 0, Some(e)); - } - let sub = add_node("sub", 1024, Some(d)); - { - add_node("e", 1024, Some(sub)); - } - } - } - } - } - t -} - -pub fn make_add_node<'a>( - t: &'a mut Tree, -) -> impl FnMut(&str, u128, Option) -> NodeIndex + 'a { - move |name, size, maybe_from_idx| { - let n = t.add_node(EntryData { - name: PathBuf::from(name), - size, - metadata_io_error: false, - }); - if let Some(from) = maybe_from_idx { - t.add_edge(from, n, ()); - } - n - } -} - -pub fn debug(item: impl fmt::Debug) -> String { - format!("{:?}", item) -} diff --git a/src/interactive/mod.rs b/src/interactive/mod.rs index 07c2036..81f8fa3 100644 --- a/src/interactive/mod.rs +++ b/src/interactive/mod.rs @@ -1,7 +1,7 @@ mod app; -pub mod widgets; +pub use app::*; -pub use self::app::*; +pub mod widgets; mod utils { use dua::{ @@ -29,7 +29,4 @@ mod utils { }) } } -pub use utils::*; - -#[cfg(test)] -mod app_test; +pub use utils::path_of; -- cgit v1.2.3