From d6c58991c041549bae9860e51e73b1c44082140d Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Thu, 1 Feb 2024 16:29:10 +0000 Subject: [PATCH] Test helpers and unit tests (#26) * Implement a few utility functions for tests Most tests would require a test file hierarchy instead of operating on a live system. * Add unit tests for `tab::scan_path` Tests: * Works on a valid path * Returns an empty Vec on an invalid directory * Returns an empty Vec for an empty directory I also implemented a few test helpers that may be useful for other unit tests. * Less spammy test logs and placate Clippy. --- Cargo.lock | 24 ++++++++++ Cargo.toml | 7 +++ src/main.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/tab.rs | 70 +++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 9198d90..a383f76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1092,6 +1092,7 @@ dependencies = [ "chrono", "dirs", "env_logger", + "fastrand 2.0.1", "fork", "i18n-embed", "i18n-embed-fl", @@ -1104,6 +1105,8 @@ dependencies = [ "rust-embed", "serde", "systemicons", + "tempfile", + "test-log", "tokio", "trash", ] @@ -5014,6 +5017,27 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "test-log" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6159ab4116165c99fc88cce31f99fa2c9dbe08d3691cb38da02fc3b45f357d2b" +dependencies = [ + "env_logger", + "test-log-macros", +] + +[[package]] +name = "test-log-macros" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba277e77219e9eea169e8508942db1bf5d8a41ff2db9b20aab5a5aadc9fa25d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "thiserror" version = "1.0.56" diff --git a/Cargo.toml b/Cargo.toml index 4057982..e6a9636 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,3 +47,10 @@ debug = true [target.'cfg(unix)'.dependencies] fork = "0.1" + +[dev-dependencies] +# cap-std = "3" +# cap-tempfile = "3" +fastrand = "2" +tempfile = "3" +test-log = "0.2" diff --git a/src/main.rs b/src/main.rs index 9589d68..2c11acc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1137,3 +1137,130 @@ impl Application for App { Subscription::batch(subscriptions) } } + +// Utilities to build a temporary file hierarchy for tests. +// +// Ideally, tests would use the cap-std crate which limits path traversal. +#[cfg(test)] +mod test_utils { + use std::{ + cmp::Ordering, + fs::File, + io::{self, Write}, + iter, + path::Path, + }; + + use log::{debug, trace}; + use tempfile::{tempdir, TempDir}; + + use crate::tab::Item; + + use super::*; + + // Default number of files, directories, and nested directories for test file system + pub const NUM_FILES: usize = 2; + pub const NUM_DIRS: usize = 2; + pub const NUM_NESTED: usize = 1; + pub const NAME_LEN: usize = 5; + + /// Add `n` temporary files in `dir` + /// + /// Each file is assigned a numeric name from [0, n). + pub fn file_flat_hier>(dir: D, n: usize) -> io::Result> { + let dir = dir.as_ref(); + (0..n) + .map(|i| -> io::Result { + let name = i.to_string(); + let path = dir.join(&name); + + let mut file = File::create(path)?; + file.write_all(name.as_bytes())?; + + Ok(file) + }) + .collect() + } + + // Random alphanumeric String of length `len` + fn rand_string(len: usize) -> String { + (0..len).map(|_| fastrand::alphanumeric()).collect() + } + + /// Create a small, temporary file hierarchy. + pub fn simple_fs( + files: usize, + dirs: usize, + nested: usize, + name_len: usize, + ) -> io::Result { + // Files created inside of a TempDir are deleted with the directory + // TempDir won't leak resources as long as the destructor runs + let root = tempdir()?; + debug!("Root temp directory: {}", root.as_ref().display()); + + // All paths for directories and nested directories + let paths = (0..dirs).flat_map(|_| { + let root = root.as_ref(); + let current = rand_string(name_len); + + iter::once(root.join(¤t)).chain( + (0..nested).map(move |_| root.join(format!("{current}/{}", rand_string(name_len)))), + ) + }); + + // Create directories from `paths` and add a few files + for path in paths { + fs::create_dir_all(&path)?; + file_flat_hier(&path, files)?; + + for entry in path.read_dir()? { + let entry = entry?; + if entry.file_type()?.is_file() { + trace!("Created file: {}", entry.path().display()); + } + } + } + + Ok(root) + } + + /// Empty file hierarchy + pub fn empty_fs() -> io::Result { + tempdir() + } + + /// Sort files. + /// + /// Directories are placed before files. + /// Files are lexically sorted. + /// This is more or less copied right from the [Tab] code + pub fn sort_files(a: &Path, b: &Path) -> Ordering { + match (a.is_dir(), b.is_dir()) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => lexical_sort::natural_lexical_cmp( + a.file_name() + .expect("temp entries should have names") + .to_str() + .expect("temp entries should be valid UTF-8"), + b.file_name() + .expect("temp entries should have names") + .to_str() + .expect("temp entries should be valid UTF-8"), + ), + } + } + + /// Equality for [Path] and [Item]. + pub fn eq_path_item(path: &Path, item: &Item) -> bool { + let name = path + .file_name() + .expect("temp entries should have names") + .to_str() + .expect("temp entries should be valid UTF-8"); + let metadata = path.is_dir(); + + name == item.name && metadata == item.metadata.is_dir() && path == item.path + } +} diff --git a/src/tab.rs b/src/tab.rs index 5c35cc1..979fd35 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -1013,3 +1013,73 @@ impl Tab { .into() } } + +#[cfg(test)] +mod tests { + use std::io; + + use log::debug; + use test_log::test; + + use super::scan_path; + use crate::test_utils::{ + empty_fs, eq_path_item, simple_fs, sort_files, NAME_LEN, NUM_DIRS, NUM_FILES, NUM_NESTED, + }; + + #[test] + fn scan_path_succeeds_on_valid_path() -> io::Result<()> { + let fs = simple_fs(NUM_FILES, NUM_DIRS, NUM_NESTED, NAME_LEN)?; + let path = fs.path(); + + let mut entries: Vec<_> = path + .read_dir()? + .map(|maybe_entry| maybe_entry.map(|entry| entry.path())) + .collect::>()?; + entries.sort_by(|a, b| sort_files(a, b)); + + debug!("Calling scan_path(\"{}\")", path.display()); + let actual = scan_path(&path.to_owned()); + + // scan_path shouldn't skip any entries + assert_eq!(entries.len(), actual.len()); + + // Correct files should be scanned + assert!(entries + .into_iter() + .zip(actual.into_iter()) + .all(|(path, item)| eq_path_item(&path, &item))); + + Ok(()) + } + + #[test] + fn scan_path_returns_empty_vec_for_invalid_path() -> io::Result<()> { + let fs = simple_fs(NUM_FILES, NUM_DIRS, NUM_NESTED, NAME_LEN)?; + let path = fs.path(); + + // A nonexisting path within the temp dir + let invalid_path = path.join("ferris"); + assert!(!invalid_path.exists()); + + debug!("Calling scan_path(\"{}\")", invalid_path.display()); + let actual = scan_path(&invalid_path); + + assert!(actual.is_empty()); + + Ok(()) + } + + #[test] + fn scan_path_empty_dir_returns_empty_vec() -> io::Result<()> { + let fs = empty_fs()?; + let path = fs.path(); + + debug!("Calling scan_path(\"{}\")", path.display()); + let actual = scan_path(&path.to_owned()); + + assert_eq!(0, path.read_dir()?.count()); + assert_eq!(0, actual.len()); + + Ok(()) + } +}