use super::*;
use crossterm::event::{KeyEvent, KeyModifiers};
use std::fs;
use tempfile::{TempDir, tempdir};
fn temp_dir_with_files() -> TempDir {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(dir.path().join("ubuntu.iso"), b"fake iso content").unwrap();
fs::write(dir.path().join("debian.img"), b"fake img content").unwrap();
fs::write(dir.path().join("readme.txt"), b"some text").unwrap();
fs::create_dir(dir.path().join("subdir")).unwrap();
dir
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn new_loads_entries() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
assert!(
explorer
.entries
.iter()
.any(|e| e.name == "subdir" && e.is_dir)
);
assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
assert!(explorer.entries.iter().any(|e| e.name == "debian.img"));
assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
}
#[test]
fn no_filter_shows_all_files() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
}
#[test]
fn dirs_listed_before_files() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let first_file_idx = explorer
.entries
.iter()
.position(|e| !e.is_dir)
.unwrap_or(usize::MAX);
let last_dir_idx = explorer.entries.iter().rposition(|e| e.is_dir).unwrap_or(0);
assert!(
last_dir_idx < first_file_idx,
"all dirs must appear before any file"
);
}
#[test]
fn move_down_increments_cursor() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.move_down();
assert_eq!(explorer.cursor, 1);
}
#[test]
fn move_up_clamps_at_zero() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.move_up();
assert_eq!(explorer.cursor, 0);
}
#[test]
fn move_down_clamps_at_last() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let last = explorer.entries.len() - 1;
explorer.cursor = last;
explorer.move_down();
assert_eq!(explorer.cursor, last);
}
#[test]
fn handle_key_down_moves_cursor() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let before = explorer.cursor;
explorer.handle_key(key(KeyCode::Down));
assert_eq!(explorer.cursor, before + 1);
}
#[test]
fn handle_key_esc_dismisses() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(
explorer.handle_key(key(KeyCode::Esc)),
ExplorerOutcome::Dismissed
);
}
#[test]
fn handle_key_enter_on_dir_descends() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let dir_idx = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("no dir in fixture");
explorer.cursor = dir_idx;
let expected_path = explorer.entries[dir_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Enter));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.current_dir, expected_path);
}
#[test]
fn handle_key_enter_on_valid_file_selects() {
let tmp = temp_dir_with_files();
let mut explorer =
FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into(), "img".into()]);
let file_idx = explorer
.entries
.iter()
.position(|e| !e.is_dir)
.expect("no file in fixture");
explorer.cursor = file_idx;
let expected = explorer.entries[file_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Enter));
assert_eq!(outcome, ExplorerOutcome::Selected(expected));
}
#[test]
fn handle_key_backspace_ascends() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, tmp.path());
}
#[test]
fn toggle_hidden_changes_visibility() {
let tmp = temp_dir_with_files();
fs::write(tmp.path().join(".hidden_file"), b"").unwrap();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.entries.iter().any(|e| e.name == ".hidden_file"));
explorer.set_show_hidden(true);
assert!(explorer.entries.iter().any(|e| e.name == ".hidden_file"));
}
#[test]
fn fmt_size_formats_bytes() {
assert_eq!(fmt_size(512), "512 B");
assert_eq!(fmt_size(1_536), "1.5 KB");
assert_eq!(fmt_size(2_097_152), "2.0 MB");
assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
}
#[test]
fn extension_filter_only_shows_matching_files() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
assert!(
explorer.entries.iter().any(|e| e.name == "ubuntu.iso"),
"iso file should appear in entries"
);
assert!(
!explorer.entries.iter().any(|e| e.name == "debian.img"),
"img file should be excluded by filter"
);
assert!(
explorer.entries.iter().any(|e| e.is_dir),
"directories should always be visible"
);
assert!(
explorer
.entries
.iter()
.filter(|e| !e.is_dir)
.all(|e| e.extension == "iso"),
"all visible files must match the active filter"
);
}
#[test]
fn navigate_to_resets_cursor_and_scroll() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 2;
explorer.scroll_offset = 1;
explorer.navigate_to(tmp.path().to_path_buf());
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn current_entry_returns_highlighted() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 0;
let entry = explorer.current_entry().expect("should have entry");
assert_eq!(entry, explorer.entries.first().unwrap());
}
#[test]
fn unrecognised_key_returns_unhandled() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(
explorer.handle_key(key(KeyCode::F(5))),
ExplorerOutcome::Unhandled
);
}
#[test]
fn slash_activates_search_mode() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.search_active);
explorer.handle_key(key(KeyCode::Char('/')));
assert!(explorer.search_active);
assert_eq!(explorer.search_query(), "");
}
#[test]
fn search_active_chars_append_to_query() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
explorer.handle_key(key(KeyCode::Char('u')));
explorer.handle_key(key(KeyCode::Char('b')));
explorer.handle_key(key(KeyCode::Char('u')));
assert_eq!(explorer.search_query(), "ubu");
assert!(explorer.search_active);
}
#[test]
fn search_filters_entries_by_name() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
for c in "ubu".chars() {
explorer.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(explorer.entries.len(), 1);
assert_eq!(explorer.entries[0].name, "ubuntu.iso");
}
#[test]
fn search_backspace_pops_last_char() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
explorer.handle_key(key(KeyCode::Char('u')));
explorer.handle_key(key(KeyCode::Char('b')));
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.search_query(), "u");
assert!(explorer.search_active);
}
#[test]
fn search_backspace_on_empty_deactivates() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
assert!(explorer.search_active);
explorer.handle_key(key(KeyCode::Backspace));
assert!(!explorer.search_active);
assert_eq!(explorer.search_query(), "");
}
#[test]
fn search_esc_clears_and_deactivates_returns_pending() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
explorer.handle_key(key(KeyCode::Char('u')));
let outcome = explorer.handle_key(key(KeyCode::Esc));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Esc should clear search, not dismiss"
);
assert!(!explorer.search_active);
assert_eq!(explorer.search_query(), "");
}
#[test]
fn esc_when_not_searching_dismisses() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.search_active);
assert_eq!(
explorer.handle_key(key(KeyCode::Esc)),
ExplorerOutcome::Dismissed
);
}
#[test]
fn search_clears_on_directory_descend() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.search_active = true;
explorer.search_query = "sub".into();
explorer.cursor = explorer.entries.iter().position(|e| e.is_dir).unwrap();
explorer.handle_key(key(KeyCode::Enter));
assert!(!explorer.search_active);
assert_eq!(explorer.search_query(), "");
}
#[test]
fn search_clears_on_ascend() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
explorer.search_active = true;
explorer.search_query = "foo".into();
explorer.ascend();
assert!(
!explorer.search_active,
"search must be deactivated after ascend"
);
assert_eq!(
explorer.search_query(),
"",
"query must be cleared after ascend"
);
assert_eq!(
explorer.current_dir,
tmp.path(),
"must have ascended to parent"
);
}
#[test]
fn backspace_in_search_pops_char_not_ascend() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir.clone(), vec![]);
explorer.search_active = true;
explorer.search_query = "foo".into();
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, subdir, "must NOT have ascended");
assert_eq!(
explorer.search_query(),
"fo",
"Backspace should pop last char"
);
assert!(explorer.search_active, "search must still be active");
}
#[test]
fn default_sort_mode_is_name() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(explorer.sort_mode(), SortMode::Name);
}
#[test]
fn sort_mode_cycles_on_s_key() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(explorer.sort_mode(), SortMode::Name);
explorer.handle_key(key(KeyCode::Char('s')));
assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
explorer.handle_key(key(KeyCode::Char('s')));
assert_eq!(explorer.sort_mode(), SortMode::Extension);
explorer.handle_key(key(KeyCode::Char('s')));
assert_eq!(explorer.sort_mode(), SortMode::Name);
}
#[test]
fn show_sizes_defaults_to_true() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(explorer.show_sizes);
}
#[test]
fn z_key_toggles_show_sizes() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(explorer.show_sizes);
explorer.handle_key(key(KeyCode::Char('z')));
assert!(!explorer.show_sizes);
explorer.handle_key(key(KeyCode::Char('z')));
assert!(explorer.show_sizes);
}
#[test]
fn builder_show_sizes_false_disables_sizes() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::builder(tmp.path().to_path_buf())
.show_sizes(false)
.build();
assert!(!explorer.show_sizes);
}
#[test]
fn load_leaves_item_counts_lazy_then_fills_visible_rows() {
let tmp = temp_dir_with_files();
fs::write(tmp.path().join("subdir").join("a.txt"), b"x").unwrap();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let idx = explorer
.entries
.iter()
.position(|e| e.name == "subdir")
.unwrap();
assert_eq!(
explorer.entries[idx].item_count, None,
"read_dir must stay cheap"
);
explorer.fill_item_counts(0..explorer.entries.len());
assert_eq!(explorer.entries[idx].item_count, Some(1));
}
#[test]
fn item_counts_outside_the_requested_range_are_not_read() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.fill_item_counts(0..0);
assert!(explorer.entries.iter().all(|e| e.item_count.is_none()));
}
#[test]
fn request_dir_sizes_computes_in_background_and_caches() {
let tmp = temp_dir_with_files();
fs::write(tmp.path().join("subdir").join("a.txt"), vec![0u8; 100]).unwrap();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let n = explorer.entries.len();
explorer.fill_item_counts(0..n);
explorer.request_dir_sizes(0..n);
assert!(explorer.has_pending_work() || explorer.dir_size_cache.contains_key(&subdir));
explorer.wait_for_background(std::time::Duration::from_secs(5));
let &(bytes, partial, count) = explorer.dir_size_cache.get(&subdir).expect("cached");
assert_eq!((bytes, partial, count), (100, false, 1));
assert!(!explorer.has_pending_work());
}
#[test]
fn request_dir_sizes_skips_fresh_cache_entries() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let n = explorer.entries.len();
explorer.fill_item_counts(0..n);
explorer.dir_size_cache.insert(subdir, (7, false, 0));
explorer.request_dir_sizes(0..n);
assert!(
!explorer.has_pending_work(),
"fresh cache entry must not respawn"
);
}
#[test]
fn request_dir_sizes_requires_a_known_item_count() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let n = explorer.entries.len();
explorer.request_dir_sizes(0..n);
assert!(!explorer.has_pending_work());
}
#[test]
fn clear_dir_size_cache_drops_inflight_jobs() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let n = explorer.entries.len();
explorer.fill_item_counts(0..n);
explorer.request_dir_sizes(0..n);
explorer.clear_dir_size_cache();
assert!(!explorer.has_pending_work());
assert!(explorer.dir_size_cache.is_empty());
}
#[test]
fn sort_size_desc_orders_largest_first() {
let tmp = tempfile::tempdir().expect("temp dir");
fs::write(tmp.path().join("small.txt"), vec![0u8; 10]).unwrap();
fs::write(tmp.path().join("large.txt"), vec![0u8; 10_000]).unwrap();
fs::write(tmp.path().join("medium.txt"), vec![0u8; 1_000]).unwrap();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.set_sort_mode(SortMode::SizeDesc);
let sizes: Vec<u64> = explorer.entries.iter().filter_map(|e| e.size).collect();
let mut sorted_desc = sizes.clone();
sorted_desc.sort_by(|a, b| b.cmp(a));
assert_eq!(sizes, sorted_desc, "files should be sorted largest-first");
}
#[test]
fn sort_extension_groups_by_ext() {
let tmp = tempfile::tempdir().expect("temp dir");
fs::write(tmp.path().join("b.toml"), b"").unwrap();
fs::write(tmp.path().join("a.rs"), b"").unwrap();
fs::write(tmp.path().join("c.toml"), b"").unwrap();
fs::write(tmp.path().join("z.rs"), b"").unwrap();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.set_sort_mode(SortMode::Extension);
let exts: Vec<&str> = explorer
.entries
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.extension.as_str())
.collect();
let rs_last = exts.iter().rposition(|&e| e == "rs").unwrap_or(0);
let toml_first = exts.iter().position(|&e| e == "toml").unwrap_or(usize::MAX);
assert!(rs_last < toml_first, "rs group must precede toml group");
}
#[test]
fn builder_sort_mode_applied() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::builder(tmp.path().to_path_buf())
.sort_mode(SortMode::SizeDesc)
.build();
assert_eq!(explorer.sort_mode(), SortMode::SizeDesc);
}
#[test]
fn set_sort_mode_reloads() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.set_sort_mode(SortMode::Extension);
assert_eq!(explorer.sort_mode(), SortMode::Extension);
assert!(!explorer.entries.is_empty());
}
#[test]
fn j_key_moves_cursor_down() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let before = explorer.cursor;
explorer.handle_key(key(KeyCode::Char('j')));
assert_eq!(explorer.cursor, before + 1);
}
#[test]
fn k_key_moves_cursor_up() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 2;
explorer.handle_key(key(KeyCode::Char('k')));
assert_eq!(explorer.cursor, 1);
}
#[test]
fn h_key_ascends_to_parent() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
explorer.handle_key(key(KeyCode::Char('h')));
assert_eq!(explorer.current_dir, tmp.path());
}
#[test]
fn l_key_descends_into_dir() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let dir_idx = explorer.entries.iter().position(|e| e.is_dir).unwrap();
explorer.cursor = dir_idx;
let expected = explorer.entries[dir_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Char('l')));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.current_dir, expected);
}
#[test]
fn right_arrow_descends_into_dir() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let dir_idx = explorer.entries.iter().position(|e| e.is_dir).unwrap();
explorer.cursor = dir_idx;
let expected = explorer.entries[dir_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Right));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Right arrow should descend into directory"
);
assert_eq!(
explorer.current_dir, expected,
"Right arrow should change into the selected directory"
);
}
#[test]
fn right_arrow_on_file_moves_down_not_exits() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let file_idx = explorer.entries.iter().position(|e| !e.is_dir).unwrap();
assert!(
file_idx + 1 < explorer.entries.len(),
"fixture must have an entry after the first file"
);
explorer.cursor = file_idx;
let original_dir = explorer.current_dir.clone();
let outcome = explorer.handle_key(key(KeyCode::Right));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Right arrow on a file must never exit (always Pending)"
);
assert_eq!(
explorer.current_dir, original_dir,
"Right arrow on a file must not change directory"
);
assert_eq!(
explorer.cursor,
file_idx + 1,
"Right arrow on a file must advance the cursor by one"
);
}
#[test]
fn right_arrow_on_file_at_last_entry_does_not_overflow() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let last = explorer.entries.len() - 1;
explorer.cursor = last;
explorer.handle_key(key(KeyCode::Right));
assert_eq!(
explorer.cursor, last,
"Right arrow at the last entry must not overflow past it"
);
}
#[test]
fn enter_on_file_still_confirms_and_exits() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let file_idx = explorer.entries.iter().position(|e| !e.is_dir).unwrap();
explorer.cursor = file_idx;
let expected = explorer.entries[file_idx].path.clone();
let outcome = explorer.handle_key(key(KeyCode::Enter));
assert_eq!(
outcome,
ExplorerOutcome::Selected(expected),
"Enter on a file should confirm (select) it and exit"
);
}
#[test]
fn left_arrow_ascends_to_parent() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
let outcome = explorer.handle_key(key(KeyCode::Left));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Left arrow should return Pending after ascending"
);
assert_eq!(
explorer.current_dir,
tmp.path(),
"Left arrow should ascend to the parent directory"
);
}
#[test]
fn right_arrow_clears_search_on_dir_descend() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.search_active = true;
explorer.search_query = "sub".to_string();
explorer.reload();
let dir_idx = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture subdir must match 'sub'");
explorer.cursor = dir_idx;
explorer.handle_key(key(KeyCode::Right));
assert!(
!explorer.search_active,
"navigate() must deactivate search on directory descend"
);
assert!(
explorer.search_query.is_empty(),
"navigate() must clear search query on directory descend"
);
}
#[test]
fn right_arrow_clears_marks_on_dir_descend() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let dir_idx = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture has a subdir");
explorer.toggle_mark();
assert!(
!explorer.marked.is_empty(),
"should have a mark before descend"
);
explorer.cursor = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture has a subdir");
explorer.handle_key(key(KeyCode::Right));
assert!(
explorer.marked.is_empty(),
"navigate() must clear marks on directory descend"
);
let _ = dir_idx;
}
#[test]
fn backspace_still_ascends() {
let tmp = temp_dir_with_files();
let subdir = tmp.path().join("subdir");
let mut explorer = FileExplorer::new(subdir, vec![]);
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, tmp.path());
}
#[test]
fn q_key_dismisses() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(
explorer.handle_key(key(KeyCode::Char('q'))),
ExplorerOutcome::Dismissed
);
}
#[test]
fn page_down_advances_cursor_by_ten() {
let tmp = tempfile::tempdir().unwrap();
for i in 0..15 {
fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
}
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 0;
explorer.handle_key(key(KeyCode::PageDown));
assert_eq!(explorer.cursor, 10);
}
#[test]
fn page_up_retreats_cursor_by_ten() {
let tmp = tempfile::tempdir().unwrap();
for i in 0..15 {
fs::write(tmp.path().join(format!("file{i:02}.txt")), b"").unwrap();
}
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 12;
explorer.handle_key(key(KeyCode::PageUp));
assert_eq!(explorer.cursor, 2);
}
#[test]
fn home_key_jumps_to_top() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = explorer.entries.len() - 1;
explorer.handle_key(key(KeyCode::Home));
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn g_key_jumps_to_top() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = explorer.entries.len() - 1;
explorer.handle_key(key(KeyCode::Char('g')));
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn end_key_jumps_to_bottom() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 0;
explorer.handle_key(key(KeyCode::End));
assert_eq!(explorer.cursor, explorer.entries.len() - 1);
}
#[test]
fn capital_g_key_jumps_to_bottom() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.cursor = 0;
let key_g = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
explorer.handle_key(key_g);
assert_eq!(explorer.cursor, explorer.entries.len() - 1);
}
#[test]
fn ascend_at_root_sets_status() {
let root = std::path::PathBuf::from("/");
let mut explorer = FileExplorer::new(root.clone(), vec![]);
assert!(explorer.is_at_root());
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, root);
assert!(
!explorer.status().is_empty(),
"status should report already at root"
);
}
#[test]
fn is_at_root_false_for_subdir() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.is_at_root());
}
#[test]
fn is_empty_reflects_visible_entries() {
let empty_dir = tempfile::tempdir().unwrap();
let explorer = FileExplorer::new(empty_dir.path().to_path_buf(), vec![]);
assert!(explorer.is_empty());
let tmp = temp_dir_with_files();
let explorer2 = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer2.is_empty());
}
#[test]
fn entry_count_matches_entries_len() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(explorer.entry_count(), explorer.entries.len());
assert!(explorer.entry_count() > 0);
}
#[test]
fn search_query_empty_when_not_searching() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.is_searching());
assert_eq!(explorer.search_query(), "");
}
#[test]
fn search_is_case_insensitive() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
for c in "UBU".chars() {
explorer.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(explorer.entries.len(), 1);
assert_eq!(explorer.entries[0].name, "ubuntu.iso");
}
#[test]
fn extension_filter_is_case_insensitive() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("disk.ISO"), b"data").unwrap();
fs::write(tmp.path().join("other.txt"), b"text").unwrap();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec!["iso".into()]);
assert!(
explorer.entries.iter().any(|e| e.name == "disk.ISO"),
"upper-case extension should be matched by lower-case filter"
);
assert!(
!explorer.entries.iter().any(|e| e.name == "other.txt"),
"non-matching extension should be excluded"
);
}
#[test]
fn builder_allow_extension_filters_entries() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::builder(tmp.path().to_path_buf())
.allow_extension("iso")
.build();
assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
assert!(!explorer.entries.iter().any(|e| e.name == "debian.img"));
assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
}
#[test]
fn builder_show_hidden_shows_dotfiles() {
let tmp = temp_dir_with_files();
fs::write(tmp.path().join(".dotfile"), b"").unwrap();
let hidden_explorer = FileExplorer::builder(tmp.path().to_path_buf())
.show_hidden(true)
.build();
assert!(hidden_explorer.entries.iter().any(|e| e.name == ".dotfile"));
let normal_explorer = FileExplorer::builder(tmp.path().to_path_buf())
.show_hidden(false)
.build();
assert!(!normal_explorer.entries.iter().any(|e| e.name == ".dotfile"));
}
#[test]
fn set_extension_filter_updates_entries() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
explorer.set_extension_filter(["iso"]);
assert!(explorer.entries.iter().any(|e| e.name == "ubuntu.iso"));
assert!(!explorer.entries.iter().any(|e| e.name == "readme.txt"));
}
#[test]
fn entry_icon_directory() {
let entry = FsEntry {
name: "mydir".into(),
path: std::path::PathBuf::from("/mydir"),
is_dir: true,
size: None,
item_count: Some(0),
extension: String::new(),
};
assert_eq!(entry_icon(&entry), "📁");
}
#[test]
fn entry_icon_recognises_known_extensions() {
let make = |name: &str, ext: &str| FsEntry {
name: name.into(),
path: std::path::PathBuf::from(name),
is_dir: false,
size: Some(0),
item_count: None,
extension: ext.into(),
};
assert_eq!(entry_icon(&make("archive.zip", "zip")), "📦");
assert_eq!(entry_icon(&make("doc.pdf", "pdf")), "📕");
assert_eq!(entry_icon(&make("notes.md", "md")), "📝");
assert_eq!(entry_icon(&make("config.toml", "toml")), "⚙ ");
assert_eq!(entry_icon(&make("main.rs", "rs")), "🦀");
assert_eq!(entry_icon(&make("script.py", "py")), "🐍");
assert_eq!(entry_icon(&make("page.html", "html")), "🌐");
assert_eq!(entry_icon(&make("image.png", "png")), "🖼 ");
assert_eq!(entry_icon(&make("video.mp4", "mp4")), "🎬");
assert_eq!(entry_icon(&make("song.mp3", "mp3")), "🎵");
assert_eq!(entry_icon(&make("unknown.xyz", "xyz")), "📄");
}
#[test]
fn fmt_size_exact_boundaries() {
assert_eq!(fmt_size(1_024), "1.0 KB");
assert_eq!(fmt_size(1_048_576), "1.0 MB");
assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
assert_eq!(fmt_size(1_023), "1023 B");
assert_eq!(fmt_size(1_047_552), "1023.0 KB"); }
#[test]
fn toggle_mark_adds_entry_to_marked_set() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(!explorer.entries.is_empty(), "need at least one entry");
explorer.toggle_mark();
assert_eq!(explorer.marked.len(), 1, "one entry should be marked");
}
#[test]
fn toggle_mark_removes_already_marked_entry() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.toggle_mark(); let cursor_after_first = explorer.cursor;
explorer.cursor = 0; explorer.toggle_mark();
assert!(
explorer.marked.is_empty(),
"second toggle on same entry should unmark it"
);
let _ = cursor_after_first; }
#[test]
fn toggle_mark_advances_cursor_down() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(
explorer.entries.len() >= 2,
"fixture must have at least 2 entries"
);
let before = explorer.cursor;
explorer.toggle_mark();
assert_eq!(
explorer.cursor,
before + 1,
"cursor should advance by one after toggle_mark"
);
}
#[test]
fn toggle_mark_at_last_entry_does_not_overflow() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = explorer.entries.len() - 1;
explorer.toggle_mark();
assert_eq!(
explorer.cursor,
explorer.entries.len() - 1,
"cursor should stay at the last entry, not overflow"
);
}
#[test]
fn clear_marks_empties_marked_set() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.toggle_mark();
assert!(
!explorer.marked.is_empty(),
"should have a mark before clear"
);
explorer.clear_marks();
assert!(
explorer.marked.is_empty(),
"marked set should be empty after clear_marks"
);
}
#[test]
fn space_key_marks_current_entry() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(!explorer.entries.is_empty(), "need at least one entry");
let outcome = explorer.handle_key(key(KeyCode::Char(' ')));
assert_eq!(
outcome,
ExplorerOutcome::Pending,
"Space should return Pending"
);
assert_eq!(
explorer.marked.len(),
1,
"Space should mark the current entry"
);
}
#[test]
fn space_key_toggles_mark_off() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char(' '))); explorer.cursor = 0; explorer.handle_key(key(KeyCode::Char(' ')));
assert!(
explorer.marked.is_empty(),
"second Space on same entry should unmark it"
);
}
#[test]
fn marks_cleared_when_ascending_to_parent() {
let dir = temp_dir_with_files();
let sub = dir.path().join("subdir");
fs::write(sub.join("inner.txt"), b"inner").unwrap();
let mut explorer = FileExplorer::new(sub.clone(), vec![]);
explorer.toggle_mark();
assert!(
!explorer.marked.is_empty(),
"should have a mark before ascend"
);
explorer.handle_key(key(KeyCode::Backspace));
assert!(
explorer.marked.is_empty(),
"marks should be cleared after ascending to parent"
);
}
#[test]
fn marks_cleared_when_descending_into_directory() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
let sub_idx = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture has a subdir");
explorer.cursor = sub_idx;
explorer.toggle_mark();
assert!(
!explorer.marked.is_empty(),
"should have a mark before descend"
);
explorer.cursor = explorer
.entries
.iter()
.position(|e| e.is_dir)
.expect("fixture has a subdir");
explorer.handle_key(key(KeyCode::Enter));
assert!(
explorer.marked.is_empty(),
"marks should be cleared after descending into a directory"
);
}
#[test]
fn can_mark_multiple_entries() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
let total = explorer.entries.len();
assert!(total >= 2, "fixture must have at least 2 entries");
for _ in 0..total {
explorer.toggle_mark();
}
assert_eq!(explorer.marked.len(), total, "all entries should be marked");
}
#[test]
fn move_up_at_top_does_not_underflow() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = 0;
explorer.handle_key(key(KeyCode::Up));
assert_eq!(explorer.cursor, 0);
}
#[test]
fn move_down_at_bottom_does_not_overflow() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
let last = explorer.entries.len().saturating_sub(1);
explorer.cursor = last;
explorer.handle_key(key(KeyCode::Down));
assert_eq!(explorer.cursor, last);
}
#[test]
fn move_down_on_empty_dir_does_not_panic() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(explorer.entries.is_empty());
explorer.handle_key(key(KeyCode::Down));
assert_eq!(explorer.cursor, 0);
}
#[test]
fn move_up_on_empty_dir_does_not_panic() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(explorer.entries.is_empty());
explorer.handle_key(key(KeyCode::Up));
assert_eq!(explorer.cursor, 0);
}
#[test]
fn page_down_at_bottom_does_not_overflow() {
let dir = tempdir().expect("tempdir");
for i in 0..5 {
fs::write(dir.path().join(format!("{i}.txt")), b"x").unwrap();
}
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
let last = explorer.entries.len().saturating_sub(1);
explorer.cursor = last;
explorer.handle_key(key(KeyCode::PageDown));
assert_eq!(explorer.cursor, last);
}
#[test]
fn page_up_at_top_does_not_underflow() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = 0;
explorer.handle_key(key(KeyCode::PageUp));
assert_eq!(explorer.cursor, 0);
}
#[test]
fn ascend_at_root_does_not_panic() {
let mut explorer = FileExplorer::new(std::path::PathBuf::from("/"), vec![]);
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.current_dir, std::path::PathBuf::from("/"));
}
#[test]
fn cursor_clamped_after_reload_with_fewer_entries() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
fs::write(dir.path().join("b.txt"), b"b").unwrap();
fs::write(dir.path().join("c.txt"), b"c").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = explorer.entries.len() - 1;
explorer.set_extension_filter(["a"]);
assert!(
explorer.cursor < explorer.entries.len().max(1),
"cursor {} out of range for {} entries",
explorer.cursor,
explorer.entries.len()
);
}
#[test]
fn scroll_offset_clamped_after_reload_with_empty_entries() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("test.rs"), b"fn main(){}").unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.scroll_offset = 5; explorer.cursor = 0;
explorer.set_extension_filter(["xyz"]);
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn marked_paths_returns_reference_to_marked_set() {
let dir = temp_dir_with_files();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.toggle_mark();
assert_eq!(
explorer.marked_paths().len(),
explorer.marked.len(),
"marked_paths() should reflect the same set as the field"
);
}
fn make_file_entry(name: &str) -> crate::types::FsEntry {
let ext = std::path::Path::new(name)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
crate::types::FsEntry {
name: name.to_string(),
path: std::path::PathBuf::from(name),
is_dir: false,
size: None,
item_count: None,
extension: ext,
}
}
macro_rules! assert_entry_icon {
($( $test_name:ident : $filename:expr => $icon:expr ),+ $(,)?) => {
$(
#[test]
fn $test_name() {
let e = make_file_entry($filename);
assert_eq!(entry_icon(&e), $icon);
}
)+
};
}
assert_entry_icon! {
entry_icon_iso_returns_disc: "release.iso" => "💿",
entry_icon_dmg_returns_disc: "app.dmg" => "💿",
entry_icon_zip_returns_package: "archive.zip" => "📦",
entry_icon_tar_returns_package: "src.tar" => "📦",
entry_icon_gz_returns_package: "data.gz" => "📦",
entry_icon_pdf_returns_book: "manual.pdf" => "📕",
entry_icon_md_returns_memo: "README.md" => "📝",
entry_icon_toml_returns_gear: "Cargo.toml" => "⚙ ",
entry_icon_json_returns_gear: "config.json" => "⚙ ",
entry_icon_lock_returns_lock: "Cargo.lock" => "🔒",
entry_icon_py_returns_snake: "script.py" => "🐍",
entry_icon_html_returns_globe: "index.html" => "🌐",
entry_icon_css_returns_palette: "style.css" => "🎨",
entry_icon_svg_returns_palette: "logo.svg" => "🎨",
entry_icon_png_returns_image: "photo.png" => "🖼 ",
entry_icon_jpg_returns_image: "photo.jpg" => "🖼 ",
entry_icon_mp4_returns_film: "video.mp4" => "🎬",
entry_icon_mp3_returns_music: "song.mp3" => "🎵",
entry_icon_ttf_returns_font: "font.ttf" => "🔤",
entry_icon_exe_returns_gear: "setup.exe" => "⚙ ",
entry_icon_unknown_extension_returns_document: "mystery.xyz" => "📄",
}
#[test]
fn entry_icon_no_extension_returns_document() {
let e = crate::types::FsEntry {
name: "Makefile".into(),
path: std::path::PathBuf::from("Makefile"),
is_dir: false,
size: None,
item_count: None,
extension: String::new(),
};
assert_eq!(entry_icon(&e), "📄");
}
#[test]
fn fmt_size_zero_bytes() {
assert_eq!(fmt_size(0), "0 B");
}
#[test]
fn fmt_size_one_byte() {
assert_eq!(fmt_size(1), "1 B");
}
#[test]
fn fmt_size_1023_bytes_stays_bytes() {
assert_eq!(fmt_size(1_023), "1023 B");
}
#[test]
fn fmt_size_exactly_1_kb() {
assert_eq!(fmt_size(1_024), "1.0 KB");
}
#[test]
fn fmt_size_1_5_kb() {
assert_eq!(fmt_size(1_536), "1.5 KB");
}
#[test]
fn fmt_size_1_mb_boundary() {
assert_eq!(fmt_size(1_048_576), "1.0 MB");
}
#[test]
fn fmt_size_2_mb() {
assert_eq!(fmt_size(2_097_152), "2.0 MB");
}
#[test]
fn fmt_size_1_gb_boundary() {
assert_eq!(fmt_size(1_073_741_824), "1.0 GB");
}
#[test]
fn fmt_size_large_value() {
assert_eq!(fmt_size(10 * 1_073_741_824), "10.0 GB");
}
#[test]
fn navigate_to_accepts_str_slice() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.navigate_to(sub.to_str().unwrap());
assert_eq!(explorer.current_dir, sub);
}
#[test]
fn navigate_to_accepts_path_ref() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("sub2");
fs::create_dir(&sub).unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.navigate_to(sub.as_path());
assert_eq!(explorer.current_dir, sub);
}
#[test]
fn navigate_to_resets_cursor_to_zero() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("sub3");
fs::create_dir(&sub).unwrap();
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.cursor = 99;
explorer.scroll_offset = 5;
explorer.navigate_to(sub.as_path());
assert_eq!(explorer.cursor, 0);
assert_eq!(explorer.scroll_offset, 0);
}
#[test]
fn is_searching_false_by_default() {
let dir = tempdir().expect("tempdir");
let explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(!explorer.is_searching());
}
#[test]
fn is_searching_true_after_slash_key() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
assert!(explorer.is_searching());
}
#[test]
fn is_searching_false_after_esc_cancels_search() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('/')));
explorer.handle_key(key(KeyCode::Esc));
assert!(!explorer.is_searching());
}
#[test]
fn status_is_empty_on_fresh_explorer() {
let dir = tempdir().expect("tempdir");
let explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(explorer.status().is_empty());
}
#[test]
fn status_cleared_after_reload() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.status = "stale message".into();
explorer.reload();
assert!(
explorer.status().is_empty(),
"reload should clear the status message"
);
}
#[test]
fn load_entries_empty_dir_returns_empty_vec() {
let dir = tempdir().expect("tempdir");
let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
assert!(
entries.is_empty(),
"empty directory should produce no entries"
);
}
#[test]
fn load_entries_hidden_excluded_by_default() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join(".hidden"), b"h").unwrap();
fs::write(dir.path().join("visible.txt"), b"v").unwrap();
let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "visible.txt");
}
#[test]
fn load_entries_hidden_included_when_show_hidden_true() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join(".hidden"), b"h").unwrap();
fs::write(dir.path().join("visible.txt"), b"v").unwrap();
let entries = load_entries(dir.path(), true, &[], crate::types::SortMode::Name, "");
assert_eq!(entries.len(), 2);
}
#[test]
fn load_entries_nonexistent_dir_returns_empty_vec() {
let entries = load_entries(
std::path::Path::new("/nonexistent/path/that/does/not/exist"),
false,
&[],
crate::types::SortMode::Name,
"",
);
assert!(entries.is_empty());
}
#[test]
fn load_entries_search_query_is_case_insensitive() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("README.md"), b"r").unwrap();
fs::write(dir.path().join("main.rs"), b"m").unwrap();
let entries = load_entries(
dir.path(),
false,
&[],
crate::types::SortMode::Name,
"readme",
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "README.md");
}
#[test]
fn load_entries_dirs_always_precede_files() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("z_file.txt"), b"z").unwrap();
fs::create_dir(dir.path().join("a_dir")).unwrap();
let entries = load_entries(dir.path(), false, &[], crate::types::SortMode::Name, "");
assert!(entries[0].is_dir, "directory must come before file");
assert!(!entries[1].is_dir);
}
#[test]
fn load_entries_ext_filter_excludes_non_matching_files() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("main.rs"), b"r").unwrap();
fs::write(dir.path().join("Cargo.toml"), b"t").unwrap();
let filter = vec!["rs".to_string()];
let entries = load_entries(dir.path(), false, &filter, crate::types::SortMode::Name, "");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].extension, "rs");
}
#[test]
fn load_entries_ext_filter_always_includes_dirs() {
let dir = tempdir().expect("tempdir");
fs::create_dir(dir.path().join("subdir")).unwrap();
fs::write(dir.path().join("file.txt"), b"t").unwrap();
let filter = vec!["rs".to_string()];
let entries = load_entries(dir.path(), false, &filter, crate::types::SortMode::Name, "");
assert_eq!(entries.len(), 1);
assert!(entries[0].is_dir);
}
#[test]
fn r_key_activates_rename_mode_with_prefilled_name() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let idx = explorer
.entries
.iter()
.position(|e| e.name == "readme.txt")
.expect("readme.txt present");
explorer.cursor = idx;
let outcome = explorer.handle_key(key(KeyCode::Char('r')));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(explorer.is_rename_active());
assert_eq!(explorer.rename_input(), "readme.txt");
}
#[test]
fn r_key_on_empty_dir_does_not_activate_rename() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
assert!(explorer.entries.is_empty());
let outcome = explorer.handle_key(key(KeyCode::Char('r')));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(!explorer.is_rename_active());
}
#[test]
fn rename_mode_chars_append_to_input() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('r')));
assert!(explorer.is_rename_active());
let original_len = explorer.rename_input().len();
for _ in 0..original_len {
explorer.handle_key(key(KeyCode::Backspace));
}
explorer.handle_key(key(KeyCode::Char('n')));
explorer.handle_key(key(KeyCode::Char('e')));
explorer.handle_key(key(KeyCode::Char('w')));
assert_eq!(explorer.rename_input(), "new");
assert!(explorer.is_rename_active());
}
#[test]
fn rename_mode_backspace_pops_last_char() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('r')));
let original_len = explorer.rename_input().len();
for _ in 0..original_len {
explorer.handle_key(key(KeyCode::Backspace));
}
explorer.handle_key(key(KeyCode::Char('a')));
explorer.handle_key(key(KeyCode::Char('b')));
assert_eq!(explorer.rename_input(), "ab");
explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(explorer.rename_input(), "a");
}
#[test]
fn rename_mode_esc_cancels_without_renaming() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let idx = explorer
.entries
.iter()
.position(|e| e.name == "readme.txt")
.expect("readme.txt present");
explorer.cursor = idx;
explorer.handle_key(key(KeyCode::Char('r')));
assert!(explorer.is_rename_active());
let outcome = explorer.handle_key(key(KeyCode::Esc));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(!explorer.is_rename_active());
assert_eq!(explorer.rename_input(), "");
assert!(tmp.path().join("readme.txt").exists());
}
#[test]
fn rename_mode_enter_renames_file_and_returns_rename_completed() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let idx = explorer
.entries
.iter()
.position(|e| e.name == "readme.txt")
.expect("readme.txt present");
explorer.cursor = idx;
explorer.handle_key(key(KeyCode::Char('r')));
let prefill_len = explorer.rename_input().len();
for _ in 0..prefill_len {
explorer.handle_key(key(KeyCode::Backspace));
}
for c in "notes.txt".chars() {
explorer.handle_key(key(KeyCode::Char(c)));
}
let outcome = explorer.handle_key(key(KeyCode::Enter));
assert!(!explorer.is_rename_active());
assert_eq!(explorer.rename_input(), "");
assert!(tmp.path().join("notes.txt").exists(), "new name must exist");
assert!(
!tmp.path().join("readme.txt").exists(),
"old name must be gone"
);
assert!(
matches!(outcome, ExplorerOutcome::RenameCompleted { ref to, .. } if to.file_name().unwrap() == "notes.txt")
);
}
#[test]
fn rename_mode_cursor_moves_to_renamed_entry() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let idx = explorer
.entries
.iter()
.position(|e| e.name == "readme.txt")
.expect("readme.txt present");
explorer.cursor = idx;
explorer.handle_key(key(KeyCode::Char('r')));
let prefill_len = explorer.rename_input().len();
for _ in 0..prefill_len {
explorer.handle_key(key(KeyCode::Backspace));
}
for c in "zzz_last.txt".chars() {
explorer.handle_key(key(KeyCode::Char(c)));
}
explorer.handle_key(key(KeyCode::Enter));
let new_idx = explorer
.entries
.iter()
.position(|e| e.name == "zzz_last.txt")
.expect("renamed entry in list");
assert_eq!(explorer.cursor, new_idx);
}
#[test]
fn rename_mode_enter_with_empty_input_is_noop() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let idx = explorer
.entries
.iter()
.position(|e| e.name == "readme.txt")
.expect("readme.txt present");
explorer.cursor = idx;
explorer.handle_key(key(KeyCode::Char('r')));
let prefill_len = explorer.rename_input().len();
for _ in 0..prefill_len {
explorer.handle_key(key(KeyCode::Backspace));
}
assert_eq!(explorer.rename_input(), "");
let outcome = explorer.handle_key(key(KeyCode::Enter));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(!explorer.is_rename_active());
assert!(tmp.path().join("readme.txt").exists());
}
#[test]
fn rename_mode_can_rename_directory() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let idx = explorer
.entries
.iter()
.position(|e| e.name == "subdir" && e.is_dir)
.expect("subdir present");
explorer.cursor = idx;
explorer.handle_key(key(KeyCode::Char('r')));
let prefill_len = explorer.rename_input().len();
for _ in 0..prefill_len {
explorer.handle_key(key(KeyCode::Backspace));
}
for c in "renamed_dir".chars() {
explorer.handle_key(key(KeyCode::Char(c)));
}
let outcome = explorer.handle_key(key(KeyCode::Enter));
assert!(tmp.path().join("renamed_dir").exists());
assert!(!tmp.path().join("subdir").exists());
assert!(matches!(outcome, ExplorerOutcome::RenameCompleted { .. }));
}
#[test]
fn rename_mode_unrecognised_key_returns_pending_without_cancelling() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_key(key(KeyCode::Char('r')));
assert!(explorer.is_rename_active());
let outcome = explorer.handle_key(key(KeyCode::F(1)));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(explorer.is_rename_active(), "rename mode must stay active");
}
#[test]
fn is_rename_active_false_by_default() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.is_rename_active());
}
#[test]
fn rename_input_empty_by_default() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert_eq!(explorer.rename_input(), "");
}
#[test]
fn mkdir_mode_char_pushes_to_input_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Mkdir, "");
explorer.open_prompt(PromptKind::Mkdir, "");
let outcome = explorer.handle_key(key(KeyCode::Char('a')));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.mkdir_input(), "a");
assert!(
explorer.is_mkdir_active(),
"mode must remain active after Char"
);
}
#[test]
fn mkdir_mode_backspace_pops_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Mkdir, "");
explorer.open_prompt(PromptKind::Mkdir, "ab");
let outcome = explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.mkdir_input(), "a");
assert!(explorer.is_mkdir_active());
}
#[test]
fn mkdir_mode_esc_cancels_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Mkdir, "");
explorer.open_prompt(PromptKind::Mkdir, "half");
let outcome = explorer.handle_key(key(KeyCode::Esc));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(
!explorer.is_mkdir_active(),
"mode must be deactivated by Esc"
);
assert!(
explorer.mkdir_input().is_empty(),
"input must be cleared by Esc"
);
}
#[test]
fn mkdir_mode_unknown_key_returns_pending_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Mkdir, "");
explorer.open_prompt(PromptKind::Mkdir, "foo");
let outcome = explorer.handle_key(key(KeyCode::F(2)));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(explorer.is_mkdir_active());
assert_eq!(explorer.mkdir_input(), "foo");
}
#[test]
fn touch_mode_char_pushes_to_input_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Touch, "");
explorer.open_prompt(PromptKind::Touch, "");
let outcome = explorer.handle_key(key(KeyCode::Char('z')));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.touch_input(), "z");
assert!(explorer.is_touch_active());
}
#[test]
fn touch_mode_backspace_pops_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Touch, "");
explorer.open_prompt(PromptKind::Touch, "xy");
let outcome = explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.touch_input(), "x");
assert!(explorer.is_touch_active());
}
#[test]
fn touch_mode_esc_cancels_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Touch, "");
explorer.open_prompt(PromptKind::Touch, "half");
let outcome = explorer.handle_key(key(KeyCode::Esc));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(!explorer.is_touch_active());
assert!(explorer.touch_input().is_empty());
}
#[test]
fn touch_mode_unknown_key_returns_pending_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Touch, "");
explorer.open_prompt(PromptKind::Touch, "bar");
let outcome = explorer.handle_key(key(KeyCode::F(3)));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(explorer.is_touch_active());
assert_eq!(explorer.touch_input(), "bar");
}
#[test]
fn rename_mode_char_pushes_to_input_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Rename, "");
explorer.open_prompt(PromptKind::Rename, "");
let outcome = explorer.handle_key(key(KeyCode::Char('r')));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.rename_input(), "r");
assert!(explorer.is_rename_active());
}
#[test]
fn rename_mode_backspace_pops_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Rename, "");
explorer.open_prompt(PromptKind::Rename, "cd");
let outcome = explorer.handle_key(key(KeyCode::Backspace));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert_eq!(explorer.rename_input(), "c");
assert!(explorer.is_rename_active());
}
#[test]
fn rename_mode_esc_cancels_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Rename, "");
explorer.open_prompt(PromptKind::Rename, "draft");
let outcome = explorer.handle_key(key(KeyCode::Esc));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(!explorer.is_rename_active());
assert!(explorer.rename_input().is_empty());
}
#[test]
fn rename_mode_unknown_key_returns_pending_via_macro() {
let dir = tempdir().expect("tempdir");
let mut explorer = FileExplorer::new(dir.path().to_path_buf(), vec![]);
explorer.open_prompt(PromptKind::Rename, "");
explorer.open_prompt(PromptKind::Rename, "baz");
let outcome = explorer.handle_key(key(KeyCode::F(4)));
assert_eq!(outcome, ExplorerOutcome::Pending);
assert!(explorer.is_rename_active());
assert_eq!(explorer.rename_input(), "baz");
}
#[test]
fn directory_selection_mode_returns_directory() {
let dir = tempdir().expect("tempdir");
let child = dir.path().join("child");
fs::create_dir(&child).unwrap();
let mut explorer = FileExplorer::builder(dir.path().to_path_buf())
.selection_mode(SelectionMode::Directories)
.build();
assert_eq!(
explorer.handle_key(key(KeyCode::Enter)),
ExplorerOutcome::Selected(child)
);
}
#[test]
fn directory_selection_mode_rejects_files() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("file.txt"), b"x").unwrap();
let mut explorer = FileExplorer::builder(dir.path().to_path_buf())
.selection_mode(SelectionMode::Directories)
.build();
assert_eq!(
explorer.handle_key(key(KeyCode::Enter)),
ExplorerOutcome::Pending
);
assert!(explorer.status().contains("Only directories"));
}
#[test]
fn try_build_returns_typed_error_for_missing_directory() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("missing");
let result = FileExplorer::builder(path.clone()).try_build();
let error = result.unwrap_err();
assert_eq!(error.path, path);
assert_eq!(error.source.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn shift_z_toggles_dir_sizes_and_leaves_file_sizes_alone() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(explorer.show_sizes && explorer.show_dir_sizes);
let shift_z = KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT);
assert_eq!(explorer.handle_key(shift_z), ExplorerOutcome::Pending);
assert!(!explorer.show_dir_sizes);
assert!(explorer.show_sizes, "Z must not touch file sizes");
explorer.handle_key(key(KeyCode::Char('Z')));
assert!(explorer.show_dir_sizes);
}
#[test]
fn toggle_dir_sizes_command_matches_key() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
explorer.handle_command(ExplorerCommand::ToggleDirSizes);
assert!(!explorer.show_dir_sizes);
}
#[test]
fn builder_show_dir_sizes_false_is_respected() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::builder(tmp.path().to_path_buf())
.show_dir_sizes(false)
.build();
assert!(!explorer.show_dir_sizes);
assert!(explorer.show_sizes);
}
#[test]
fn every_command_has_a_canonical_key_that_maps_back() {
use ExplorerCommand as C;
for command in [
C::MoveUp,
C::MoveDown,
C::PageUp,
C::PageDown,
C::Top,
C::Bottom,
C::Ascend,
C::Navigate,
C::Confirm,
C::ToggleHidden,
C::ToggleSizes,
C::ToggleDirSizes,
C::Search,
C::CycleSort,
C::ToggleMark,
C::NewDirectory,
C::NewFile,
C::Rename,
C::Dismiss,
] {
assert_eq!(
ExplorerCommand::from_key(Keymap::builtin().canonical_key(command)),
Some(command),
"{command:?} must round-trip through the keymap"
);
}
}
#[test]
fn plain_bindings_ignore_ctrl_chords() {
let ctrl_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
assert_eq!(ExplorerCommand::from_key(ctrl_s), None);
}
#[test]
fn key_aliases_resolve_to_same_command() {
for code in [KeyCode::Left, KeyCode::Backspace, KeyCode::Char('h')] {
assert_eq!(
ExplorerCommand::from_key(key(code)),
Some(ExplorerCommand::Ascend)
);
}
}
#[test]
fn release_events_are_ignored() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
let mut ev = key(KeyCode::Char('z'));
ev.kind = crossterm::event::KeyEventKind::Release;
explorer.handle_key(ev);
assert!(explorer.show_sizes, "release must not toggle");
}
use std::sync::{Arc, Condvar, Mutex};
#[derive(Debug, Clone)]
struct GatedFs {
gate: Arc<(Mutex<bool>, Condvar)>,
}
impl GatedFs {
fn closed() -> Self {
Self {
gate: Arc::new((Mutex::new(false), Condvar::new())),
}
}
fn open(&self) {
*self.gate.0.lock().unwrap() = true;
self.gate.1.notify_all();
}
}
impl crate::FileSystem for GatedFs {
fn read_dir(&self, path: &Path) -> std::io::Result<Vec<FsEntry>> {
let mut open = self.gate.0.lock().unwrap();
while !*open {
open = self.gate.1.wait(open).unwrap();
}
drop(open);
crate::StdFileSystem.read_dir(path)
}
fn create_dir_all(&self, p: &Path) -> std::io::Result<()> {
crate::StdFileSystem.create_dir_all(p)
}
fn create_file(&self, p: &Path) -> std::io::Result<()> {
crate::StdFileSystem.create_file(p)
}
fn rename(&self, a: &Path, b: &Path) -> std::io::Result<()> {
crate::StdFileSystem.rename(a, b)
}
fn copy_file(&self, a: &Path, b: &Path) -> std::io::Result<()> {
crate::StdFileSystem.copy_file(a, b)
}
fn copy_dir(&self, a: &Path, b: &Path) -> std::io::Result<()> {
crate::StdFileSystem.copy_dir(a, b)
}
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
crate::StdFileSystem.remove_file(p)
}
fn remove_dir_all(&self, p: &Path) -> std::io::Result<()> {
crate::StdFileSystem.remove_dir_all(p)
}
fn exists(&self, p: &Path) -> bool {
p.exists()
}
fn is_dir(&self, p: &Path) -> bool {
p.is_dir()
}
fn disk_usage(&self, _: &Path) -> Option<DiskUsage> {
None
}
fn dir_size(&self, p: &Path) -> (u64, bool) {
crate::StdFileSystem.dir_size(p)
}
}
fn slow_explorer(dir: &Path) -> (FileExplorer, GatedFs) {
let fs = GatedFs::closed();
let explorer = FileExplorer::builder(dir.to_path_buf())
.filesystem(Arc::new(fs.clone()))
.async_loading(true)
.build();
(explorer, fs)
}
fn settle(explorer: &mut FileExplorer) {
explorer.wait_for_background(std::time::Duration::from_secs(5));
}
#[test]
fn async_reload_of_a_fast_directory_completes_synchronously() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::builder(tmp.path().to_path_buf())
.async_loading(true)
.build();
assert!(!explorer.is_loading());
assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
explorer.reload();
assert!(!explorer.is_loading());
}
#[test]
fn slow_load_shows_loading_then_fills_in() {
let tmp = temp_dir_with_files();
let (mut explorer, fs) = slow_explorer(tmp.path());
assert!(
explorer.is_loading(),
"read is blocked: must not wait for it"
);
assert!(explorer.entries.is_empty());
assert!(explorer.has_pending_work());
fs.open();
settle(&mut explorer);
assert!(!explorer.is_loading());
assert!(explorer.entries.iter().any(|e| e.name == "readme.txt"));
}
#[test]
fn keys_do_nothing_while_the_list_is_loading() {
let tmp = temp_dir_with_files();
let (mut explorer, fs) = slow_explorer(tmp.path());
assert_eq!(
explorer.handle_key(key(KeyCode::Enter)),
ExplorerOutcome::Pending
);
assert_eq!(
explorer.handle_key(key(KeyCode::Down)),
ExplorerOutcome::Pending
);
fs.open();
settle(&mut explorer);
}
#[test]
fn go_to_a_file_selects_it_once_the_slow_load_finishes() {
let tmp = temp_dir_with_files();
let (mut explorer, fs) = slow_explorer(tmp.path());
explorer.go_to(&tmp.path().join("ubuntu.iso"));
assert!(explorer.is_loading());
fs.open();
settle(&mut explorer);
assert_eq!(explorer.current_entry().unwrap().name, "ubuntu.iso");
}
#[test]
fn ascend_lands_on_the_directory_we_came_from_after_a_slow_load() {
let tmp = temp_dir_with_files();
let sub = tmp.path().join("subdir");
let (mut explorer, fs) = slow_explorer(&sub);
explorer.handle_key(key(KeyCode::Left));
assert_eq!(explorer.current_dir, tmp.path());
assert!(explorer.is_loading());
fs.open();
settle(&mut explorer);
assert_eq!(explorer.current_entry().unwrap().name, "subdir");
}
#[test]
fn cursor_row_survives_a_slow_reload() {
let tmp = temp_dir_with_files();
let (mut explorer, fs) = slow_explorer(tmp.path());
fs.open();
settle(&mut explorer);
explorer.cursor = 2;
let blocked = GatedFs::closed();
explorer.filesystem = Arc::new(blocked.clone());
explorer.reload();
assert!(explorer.is_loading());
assert_eq!(explorer.cursor, 2, "cursor untouched while loading");
blocked.open();
settle(&mut explorer);
assert_eq!(explorer.cursor, 2);
}
#[test]
fn a_newer_reload_supersedes_an_older_slow_one() {
let tmp = temp_dir_with_files();
let other = tempdir().unwrap();
fs::write(other.path().join("only-here.txt"), "x").unwrap();
let (mut explorer, fs) = slow_explorer(tmp.path());
explorer.current_dir = other.path().to_path_buf();
explorer.reload();
fs.open();
settle(&mut explorer);
let names: Vec<_> = explorer.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(
names,
["only-here.txt"],
"stale listing must not overwrite the new one"
);
}
#[test]
fn async_load_error_sets_status_and_clears_entries() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::builder(tmp.path().to_path_buf())
.async_loading(true)
.build();
explorer.current_dir = tmp.path().join("does-not-exist");
explorer.reload();
settle(&mut explorer);
assert!(explorer.entries.is_empty());
assert!(
explorer.status.contains("does-not-exist"),
"{}",
explorer.status
);
}
#[test]
fn try_reload_supersedes_a_pending_background_read() {
let tmp = temp_dir_with_files();
let (mut explorer, fs) = slow_explorer(tmp.path());
assert!(explorer.is_loading());
explorer.filesystem = Arc::new(crate::StdFileSystem);
explorer.try_reload().unwrap();
assert!(!explorer.is_loading());
assert!(!explorer.entries.is_empty());
fs.open();
}
#[test]
fn title_says_loading_while_a_read_is_pending() {
use ratatui::{Terminal, backend::TestBackend};
let tmp = temp_dir_with_files();
let (mut explorer, fs) = slow_explorer(tmp.path());
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|f| crate::render(&mut explorer, f, f.area()))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("loading"), "{text}");
fs.open();
settle(&mut explorer);
}
#[test]
fn synchronous_mode_is_unchanged_by_default() {
let tmp = temp_dir_with_files();
let explorer = FileExplorer::new(tmp.path().to_path_buf(), vec![]);
assert!(!explorer.async_loading);
assert!(!explorer.is_loading());
}
#[test]
fn huge_page_size_page_down_terminates_and_clamps() {
let tmp = temp_dir_with_files();
let mut explorer = FileExplorer::builder(tmp.path().to_path_buf())
.page_size(usize::MAX)
.build();
let last = explorer.entries.len() - 1;
explorer.handle_command(crate::ExplorerCommand::PageDown);
assert_eq!(explorer.cursor, last);
explorer.handle_command(crate::ExplorerCommand::PageUp);
assert_eq!(explorer.cursor, 0);
}