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"
);
}