use crate::DirectoryTree;
use crate::directory_tree::drag::DragMsg;
use crate::directory_tree::message::{DirectoryTreeEvent, LoadPayload};
use crate::directory_tree::node::{LoadedEntry, TreeNode};
use crate::directory_tree::selection::SelectionMode;
use std::path::PathBuf;
#[test]
fn toggled_on_nonexistent_path_is_noop() {
let mut tree = DirectoryTree::new(PathBuf::from("/definitely/not/there"));
let _task = tree.update(DirectoryTreeEvent::Toggled(PathBuf::from(
"/some/unrelated/elsewhere",
)));
}
#[test]
fn selection_is_single_under_replace_mode() {
let mut tree = DirectoryTree::new(PathBuf::from("/a"));
tree.root
.children
.push(TreeNode::new_root(PathBuf::from("/a/b")));
tree.root
.children
.push(TreeNode::new_root(PathBuf::from("/a/c")));
tree.root.is_loaded = true;
let _ = tree.update(DirectoryTreeEvent::Selected(
PathBuf::from("/a/b"),
true,
SelectionMode::Replace,
));
assert!(
tree.root
.find_mut(std::path::Path::new("/a/b"))
.unwrap()
.is_selected
);
assert_eq!(tree.selected_paths.len(), 1);
let _ = tree.update(DirectoryTreeEvent::Selected(
PathBuf::from("/a/c"),
true,
SelectionMode::Replace,
));
assert!(
!tree
.root
.find_mut(std::path::Path::new("/a/b"))
.unwrap()
.is_selected
);
assert!(
tree.root
.find_mut(std::path::Path::new("/a/c"))
.unwrap()
.is_selected
);
assert_eq!(tree.selected_paths.len(), 1);
}
#[test]
fn collapsing_keeps_children() {
let mut tree = DirectoryTree::new(PathBuf::from("/r"));
tree.root.is_loaded = true;
tree.root.is_expanded = true;
tree.root
.children
.push(TreeNode::new_root(PathBuf::from("/r/x")));
let _ = tree.update(DirectoryTreeEvent::Toggled(PathBuf::from("/r")));
assert!(!tree.root.is_expanded);
assert_eq!(
tree.root.children.len(),
1,
"children must survive collapse"
);
}
#[test]
fn stale_loaded_events_are_dropped() {
let mut tree = DirectoryTree::new(PathBuf::from("/r"));
tree.root.is_dir = true;
tree.root.is_expanded = true;
tree.generation = 5;
let stale = LoadPayload {
path: PathBuf::from("/r"),
generation: 4, depth: 0,
result: std::sync::Arc::new(Ok(vec![LoadedEntry {
path: PathBuf::from("/r/hacked"),
is_dir: false,
is_symlink: false,
is_hidden: false,
}])),
};
let _ = tree.update(DirectoryTreeEvent::Loaded(stale));
assert!(
tree.root.children.is_empty(),
"stale result must be ignored"
);
}
fn tree_with_three_siblings() -> DirectoryTree {
let mut tree = DirectoryTree::new(PathBuf::from("/r"));
tree.root.is_dir = true;
tree.root.is_expanded = true;
tree.root.is_loaded = true;
for name in &["a", "b", "c"] {
tree.root
.children
.push(TreeNode::new_root(PathBuf::from(format!("/r/{}", name))));
}
tree
}
fn sel(p: &str, mode: SelectionMode) -> DirectoryTreeEvent {
DirectoryTreeEvent::Selected(PathBuf::from(p), false, mode)
}
#[test]
fn replace_clears_previous_selection() {
let mut tree = tree_with_three_siblings();
let _ = tree.update(sel("/r/a", SelectionMode::Replace));
let _ = tree.update(sel("/r/b", SelectionMode::Replace));
assert_eq!(tree.selected_paths.len(), 1);
assert_eq!(tree.selected_paths[0], PathBuf::from("/r/b"));
assert_eq!(
tree.active_path.as_deref(),
Some(std::path::Path::new("/r/b"))
);
assert_eq!(
tree.anchor_path.as_deref(),
Some(std::path::Path::new("/r/b"))
);
}
#[test]
fn toggle_adds_then_removes() {
let mut tree = tree_with_three_siblings();
let _ = tree.update(sel("/r/a", SelectionMode::Replace));
let _ = tree.update(sel("/r/b", SelectionMode::Toggle));
let _ = tree.update(sel("/r/c", SelectionMode::Toggle));
assert_eq!(
tree.selected_paths.len(),
3,
"three paths after two toggles"
);
let _ = tree.update(sel("/r/b", SelectionMode::Toggle));
assert_eq!(tree.selected_paths.len(), 2);
assert!(
!tree
.selected_paths
.iter()
.any(|p| p == std::path::Path::new("/r/b"))
);
assert_eq!(
tree.anchor_path.as_deref(),
Some(std::path::Path::new("/r/b"))
);
}
#[test]
fn toggle_updates_per_node_flags() {
let mut tree = tree_with_three_siblings();
let _ = tree.update(sel("/r/a", SelectionMode::Replace));
let _ = tree.update(sel("/r/b", SelectionMode::Toggle));
assert!(
tree.root
.find_mut(std::path::Path::new("/r/a"))
.unwrap()
.is_selected
);
assert!(
tree.root
.find_mut(std::path::Path::new("/r/b"))
.unwrap()
.is_selected
);
assert!(
!tree
.root
.find_mut(std::path::Path::new("/r/c"))
.unwrap()
.is_selected
);
}
#[test]
fn extend_range_covers_visible_interval() {
let mut tree = tree_with_three_siblings();
let _ = tree.update(sel("/r/a", SelectionMode::Replace));
let _ = tree.update(sel("/r/c", SelectionMode::ExtendRange));
assert_eq!(tree.selected_paths.len(), 3);
let names: Vec<_> = tree
.selected_paths
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert_eq!(names, vec!["a", "b", "c"]);
assert_eq!(
tree.anchor_path.as_deref(),
Some(std::path::Path::new("/r/a"))
);
}
#[test]
fn extend_range_is_symmetric() {
let mut tree = tree_with_three_siblings();
let _ = tree.update(sel("/r/c", SelectionMode::Replace));
let _ = tree.update(sel("/r/a", SelectionMode::ExtendRange));
assert_eq!(tree.selected_paths.len(), 3);
assert_eq!(
tree.anchor_path.as_deref(),
Some(std::path::Path::new("/r/c"))
);
}
#[test]
fn extend_range_without_anchor_falls_back_to_replace() {
let mut tree = tree_with_three_siblings();
let _ = tree.update(sel("/r/b", SelectionMode::ExtendRange));
assert_eq!(tree.selected_paths.len(), 1);
assert_eq!(tree.selected_paths[0], PathBuf::from("/r/b"));
assert_eq!(
tree.anchor_path.as_deref(),
Some(std::path::Path::new("/r/b"))
);
}
#[test]
fn selection_on_stale_path_is_noop() {
let mut tree = tree_with_three_siblings();
let _ = tree.update(sel("/r/a", SelectionMode::Replace));
let _ = tree.update(sel("/completely/unrelated", SelectionMode::Replace));
assert_eq!(tree.selected_paths.len(), 1);
assert_eq!(tree.selected_paths[0], PathBuf::from("/r/a"));
}
fn tree_with_two_folders_and_a_file() -> DirectoryTree {
let mut tree = DirectoryTree::new(PathBuf::from("/r"));
tree.root.is_dir = true;
tree.root.is_expanded = true;
tree.root.is_loaded = true;
let mut x = TreeNode::new_root(PathBuf::from("/r/x"));
x.is_dir = true;
tree.root.children.push(x);
let mut y = TreeNode::new_root(PathBuf::from("/r/y"));
y.is_dir = true;
tree.root.children.push(y);
let mut f = TreeNode::new_root(PathBuf::from("/r/f"));
f.is_dir = false;
tree.root.children.push(f);
tree
}
#[test]
fn press_without_prior_selection_drags_only_pressed_row() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/f"),
false,
)));
assert!(tree.is_dragging());
assert_eq!(tree.drag_sources(), &[PathBuf::from("/r/f")]);
}
#[test]
fn press_on_selected_row_drags_whole_selection() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Selected(
PathBuf::from("/r/f"),
false,
SelectionMode::Replace,
));
let _ = tree.update(DirectoryTreeEvent::Selected(
PathBuf::from("/r/x"),
true,
SelectionMode::Toggle,
));
assert_eq!(tree.selected_paths().len(), 2);
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/f"),
false,
)));
assert_eq!(
tree.drag_sources().len(),
2,
"pressing a selected row drags the whole selection"
);
}
#[test]
fn entered_folder_sets_hover_to_folder() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/f"),
false,
)));
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Entered(PathBuf::from(
"/r/x",
))));
assert_eq!(tree.drop_target(), Some(std::path::Path::new("/r/x")));
}
#[test]
fn entered_file_leaves_hover_unset() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/x"),
true,
)));
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Entered(PathBuf::from(
"/r/f",
))));
assert_eq!(tree.drop_target(), None);
}
#[test]
fn entered_source_row_leaves_hover_unset() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/x"),
true,
)));
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Entered(PathBuf::from(
"/r/x",
))));
assert_eq!(tree.drop_target(), None);
}
#[test]
fn exited_target_clears_hover() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/f"),
false,
)));
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Entered(PathBuf::from(
"/r/x",
))));
assert_eq!(tree.drop_target(), Some(std::path::Path::new("/r/x")));
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Exited(PathBuf::from(
"/r/x",
))));
assert_eq!(tree.drop_target(), None);
}
#[test]
fn release_same_row_produces_delayed_selected() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/f"),
false,
)));
let _task = tree.update(DirectoryTreeEvent::Drag(DragMsg::Released(PathBuf::from(
"/r/f",
))));
assert!(!tree.is_dragging());
}
#[test]
fn release_over_valid_target_clears_drag_state() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/f"),
false,
)));
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Entered(PathBuf::from(
"/r/x",
))));
let _task = tree.update(DirectoryTreeEvent::Drag(DragMsg::Released(PathBuf::from(
"/r/x",
))));
assert!(!tree.is_dragging());
}
#[test]
fn release_without_hover_is_silent_cancel() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/f"),
false,
)));
let _task = tree.update(DirectoryTreeEvent::Drag(DragMsg::Released(PathBuf::from(
"/r/y",
))));
assert!(!tree.is_dragging());
assert!(tree.selected_paths().is_empty());
}
#[test]
fn explicit_cancelled_clears_drag() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Pressed(
PathBuf::from("/r/f"),
false,
)));
assert!(tree.is_dragging());
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Cancelled));
assert!(!tree.is_dragging());
}
#[test]
fn stray_events_without_press_are_noops() {
let mut tree = tree_with_two_folders_and_a_file();
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Entered(PathBuf::from(
"/r/x",
))));
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Exited(PathBuf::from(
"/r/x",
))));
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Released(PathBuf::from(
"/r/x",
))));
let _ = tree.update(DirectoryTreeEvent::Drag(DragMsg::Cancelled));
assert!(!tree.is_dragging());
}
fn tree_with_mixed_children() -> DirectoryTree {
let mut tree = DirectoryTree::new(PathBuf::from("/r"));
tree.root.is_dir = true;
tree.root.is_expanded = true;
tree.root.is_loaded = true;
for name in ["x", "y", "z"] {
let mut c = TreeNode::new_root(PathBuf::from(format!("/r/{name}")));
c.is_dir = true;
tree.root.children.push(c);
}
let mut f = TreeNode::new_root(PathBuf::from("/r/f"));
f.is_dir = false;
tree.root.children.push(f);
tree
}
#[test]
fn prefetch_disabled_by_default_returns_no_targets() {
let tree = tree_with_mixed_children();
assert_eq!(tree.config.prefetch_per_parent, 0);
assert!(
tree.select_prefetch_targets(&PathBuf::from("/r"))
.is_empty()
);
}
#[test]
fn prefetch_selects_folder_children_only() {
let mut tree = tree_with_mixed_children();
tree.config.prefetch_per_parent = 10;
let targets = tree.select_prefetch_targets(&PathBuf::from("/r"));
assert_eq!(targets.len(), 3);
assert!(targets.iter().any(|p| p == &PathBuf::from("/r/x")));
assert!(targets.iter().any(|p| p == &PathBuf::from("/r/y")));
assert!(targets.iter().any(|p| p == &PathBuf::from("/r/z")));
assert!(!targets.iter().any(|p| p == &PathBuf::from("/r/f")));
}
#[test]
fn prefetch_respects_the_limit() {
let mut tree = tree_with_mixed_children();
tree.config.prefetch_per_parent = 2;
let targets = tree.select_prefetch_targets(&PathBuf::from("/r"));
assert_eq!(targets.len(), 2);
}
#[test]
fn prefetch_skips_already_loaded_children() {
let mut tree = tree_with_mixed_children();
tree.config.prefetch_per_parent = 10;
tree.root
.children
.iter_mut()
.find(|c| c.path.as_path() == std::path::Path::new("/r/x"))
.unwrap()
.is_loaded = true;
let targets = tree.select_prefetch_targets(&PathBuf::from("/r"));
assert_eq!(targets.len(), 2);
assert!(!targets.iter().any(|p| p == &PathBuf::from("/r/x")));
}
#[test]
fn prefetch_respects_max_depth() {
let mut tree = tree_with_mixed_children();
tree.config.prefetch_per_parent = 10;
tree.config.max_depth = Some(0);
let targets = tree.select_prefetch_targets(&PathBuf::from("/r"));
assert!(
targets.is_empty(),
"max_depth=0 must suppress all prefetch of /r's children"
);
}
#[test]
fn on_loaded_drains_prefetching_paths_without_cascade() {
let mut tree = tree_with_mixed_children();
tree.config.prefetch_per_parent = 10;
tree.prefetching_paths.insert(PathBuf::from("/r/x"));
let next_gen = tree.generation.wrapping_add(1);
tree.generation = next_gen;
let payload = LoadPayload {
path: PathBuf::from("/r/x"),
generation: next_gen,
depth: 1,
result: std::sync::Arc::new(Ok(Vec::<LoadedEntry>::new())),
};
let targets = tree.on_loaded(payload);
assert!(
targets.is_empty(),
"prefetch-triggered on_loaded must not cascade"
);
assert!(
!tree.prefetching_paths.contains(&PathBuf::from("/r/x")),
"prefetching_paths must be drained on arrival"
);
}
#[test]
fn toggled_clears_a_pending_prefetch_entry() {
let mut tree = tree_with_mixed_children();
tree.prefetching_paths.insert(PathBuf::from("/r/x"));
let _ = tree.update(DirectoryTreeEvent::Toggled(PathBuf::from("/r/x")));
assert!(
!tree.prefetching_paths.contains(&PathBuf::from("/r/x")),
"user Toggled must remove the path from prefetching_paths \
so its eventual scan is treated as user-initiated"
);
}
fn tree_with_git_and_src() -> DirectoryTree {
let mut tree = DirectoryTree::new(PathBuf::from("/r"));
tree.root.is_dir = true;
tree.root.is_expanded = true;
tree.root.is_loaded = true;
for name in [".git", "src", "target", "notes"] {
let mut c = TreeNode::new_root(PathBuf::from(format!("/r/{name}")));
c.is_dir = true;
tree.root.children.push(c);
}
tree
}
#[test]
fn prefetch_default_skips_dot_git_and_target() {
let mut tree = tree_with_git_and_src();
tree.config.prefetch_per_parent = 10;
let targets = tree.select_prefetch_targets(&PathBuf::from("/r"));
let names: Vec<_> = targets
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert!(
!names.contains(&".git".to_string()),
"default skip list must exclude .git"
);
assert!(
!names.contains(&"target".to_string()),
"default skip list must exclude target"
);
assert!(names.contains(&"src".to_string()));
assert!(names.contains(&"notes".to_string()));
}
#[test]
fn custom_skip_list_replaces_defaults() {
let mut tree = tree_with_git_and_src();
tree.config.prefetch_per_parent = 10;
tree.config.prefetch_skip = vec!["notes".to_string()];
let targets = tree.select_prefetch_targets(&PathBuf::from("/r"));
let names: Vec<_> = targets
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert!(
names.contains(&".git".to_string()),
"custom skip list replaces the default — .git is no longer skipped"
);
assert!(
!names.contains(&"notes".to_string()),
"notes is explicitly in the custom skip list"
);
}
#[test]
fn empty_skip_list_disables_skipping() {
let mut tree = tree_with_git_and_src();
tree.config.prefetch_per_parent = 10;
tree.config.prefetch_skip = Vec::new();
let targets = tree.select_prefetch_targets(&PathBuf::from("/r"));
let names: Vec<_> = targets
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert_eq!(names.len(), 4);
assert!(names.contains(&".git".to_string()));
assert!(names.contains(&"target".to_string()));
}
#[test]
fn skip_list_match_is_case_insensitive_ascii() {
let mut tree = DirectoryTree::new(PathBuf::from("/r"));
tree.root.is_dir = true;
tree.root.is_expanded = true;
tree.root.is_loaded = true;
for name in [".Git", ".GIT", "Target"] {
let mut c = TreeNode::new_root(PathBuf::from(format!("/r/{name}")));
c.is_dir = true;
tree.root.children.push(c);
}
tree.config.prefetch_per_parent = 10;
let targets = tree.select_prefetch_targets(&PathBuf::from("/r"));
assert!(
targets.is_empty(),
"all three folders should match the default (ASCII-case-insensitive) \
skip entries .git and target"
);
}
#[test]
fn skip_list_match_is_exact_basename_not_substring() {
let mut tree = DirectoryTree::new(PathBuf::from("/r"));
tree.root.is_dir = true;
tree.root.is_expanded = true;
tree.root.is_loaded = true;
let mut c = TreeNode::new_root(PathBuf::from("/r/my-target-files"));
c.is_dir = true;
tree.root.children.push(c);
tree.config.prefetch_per_parent = 10;
let targets = tree.select_prefetch_targets(&PathBuf::from("/r"));
assert_eq!(
targets.len(),
1,
"exact-basename match: 'target' in skip list must not \
skip 'my-target-files'"
);
}
#[test]
fn user_click_expands_skipped_folder_normally() {
let mut tree = tree_with_git_and_src();
tree.config.prefetch_per_parent = 10;
let git = PathBuf::from("/r/.git");
let task = tree.update(DirectoryTreeEvent::Toggled(git.clone()));
assert_ne!(
task.units(),
0,
"user click on a skip-listed folder must still produce a real \
scan Task — the skip list governs prefetch only, not user-initiated \
expansion"
);
let node = tree.root.find_mut(&git).unwrap();
assert!(node.is_expanded);
}
#[test]
fn default_prefetch_skip_const_matches_default_field() {
use crate::DEFAULT_PREFETCH_SKIP;
let tree = DirectoryTree::new(PathBuf::from("/r"));
let got: &[String] = &tree.config.prefetch_skip;
assert_eq!(got.len(), DEFAULT_PREFETCH_SKIP.len());
for (a, b) in got.iter().zip(DEFAULT_PREFETCH_SKIP.iter()) {
assert_eq!(a, b);
}
}