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.
This commit is contained in:
Joshua Megnauth 2024-02-01 16:29:10 +00:00 committed by GitHub
parent 60eeb724d1
commit d6c58991c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 228 additions and 0 deletions

24
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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<D: AsRef<Path>>(dir: D, n: usize) -> io::Result<Vec<File>> {
let dir = dir.as_ref();
(0..n)
.map(|i| -> io::Result<File> {
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<TempDir> {
// 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(&current)).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> {
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
}
}

View file

@ -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::<io::Result<_>>()?;
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(())
}
}