use super::*;
#[test]
fn selected_sync_target_id_resolves_repo_worktree_and_stash() {
let mut list = make_list(&["/a", "/b"]);
list.state.select(Some(0));
assert_eq!(
list.selected_sync_target_id(),
Some(RepoId(PathBuf::from("/a")))
);
let mut status = empty_status("main");
status.worktree_info = vec![worktree_entry("one")];
list.update_status(1, status);
list.state.select(Some(2)); assert_eq!(
list.selected_sync_target_id(),
Some(RepoId(PathBuf::from("/wt/one")))
);
let mut stash_status = empty_status("main");
stash_status.stashes = vec![stash_entry(0)];
list.expanded_stashes.insert(RepoId(PathBuf::from("/a")));
list.update_status(0, stash_status);
let stash_row = list
.display_rows
.iter()
.position(|r| matches!(r, DisplayRow::Stash(_, _)))
.expect("stash row present");
list.state.select(Some(stash_row));
assert_eq!(list.selected_sync_target_id(), None);
}
#[test]
fn selected_menu_targets_the_selected_row_at_its_right_edge() {
let mut list = make_list(&["/a", "/b"]);
list.render_area = Rect::new(2, 4, 40, 20);
list.state.select(Some(0));
assert_eq!(
list.selected_menu(),
Some((RepoId(PathBuf::from("/a")), false, 41, 5))
);
let mut status = empty_status("main");
status.worktree_info = vec![worktree_entry("one")];
list.update_status(1, status);
list.state.select(Some(2)); assert_eq!(
list.selected_menu(),
Some((RepoId(PathBuf::from("/wt/one")), true, 41, 7))
);
let mut stash_status = empty_status("main");
stash_status.stashes = vec![stash_entry(0)];
list.expanded_stashes.insert(RepoId(PathBuf::from("/a")));
list.update_status(0, stash_status);
let stash_row = list
.display_rows
.iter()
.position(|r| matches!(r, DisplayRow::Stash(_, _)))
.expect("stash row present");
list.state.select(Some(stash_row));
assert_eq!(list.selected_menu(), None);
}
#[test]
fn sync_paths_noop_when_set_unchanged() {
let mut list = make_list(&["/a", "/b"]);
let diff = list.sync_paths(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
assert!(diff.is_empty());
assert_eq!(list.repos.len(), 2);
}
#[test]
fn sync_paths_ignores_discovery_order() {
let mut list = make_list(&["/a", "/b"]);
let diff = list.sync_paths(vec![PathBuf::from("/b"), PathBuf::from("/a")]);
assert!(diff.is_empty());
assert_eq!(list.repos[0].path, PathBuf::from("/a"));
assert_eq!(list.repos[1].path, PathBuf::from("/b"));
}
#[test]
fn sync_paths_reports_added_paths() {
let mut list = make_list(&["/a"]);
let diff = list.sync_paths(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
assert!(!diff.is_empty());
assert_eq!(diff.added, vec![PathBuf::from("/b")]);
assert!(diff.removed.is_empty());
assert_eq!(list.repos.len(), 2);
}
#[test]
fn sync_paths_reports_removed_paths_and_prunes_expansion() {
let mut list = make_list(&["/a", "/b"]);
list.worktrees_toggled.insert(RepoId(PathBuf::from("/b")));
list.expanded_stashes.insert(RepoId(PathBuf::from("/b")));
let diff = list.sync_paths(vec![PathBuf::from("/a")]);
assert_eq!(diff.removed, vec![PathBuf::from("/b")]);
assert!(diff.added.is_empty());
assert_eq!(list.repos.len(), 1);
assert!(
!list
.worktrees_toggled
.contains(&RepoId(PathBuf::from("/b")))
);
assert!(!list.expanded_stashes.contains(&RepoId(PathBuf::from("/b"))));
}
#[test]
fn sync_paths_preserves_existing_entry_status() {
let mut list = make_list(&["/a"]);
list.repos[0].git_op = true;
let diff = list.sync_paths(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
assert_eq!(diff.added, vec![PathBuf::from("/b")]);
let a = list
.repos
.iter()
.find(|r| r.path == std::path::Path::new("/a"))
.unwrap();
assert!(a.git_op);
let b = list
.repos
.iter()
.find(|r| r.path == std::path::Path::new("/b"))
.unwrap();
assert!(!b.git_op);
assert!(b.status.is_none());
}
#[test]
fn sync_paths_keeps_selection_when_repo_set_changes() {
let mut list = make_list(&["/a", "/b"]);
list.select_repo_row(1);
list.sync_paths(vec![
PathBuf::from("/new"),
PathBuf::from("/a"),
PathBuf::from("/b"),
]);
assert_eq!(list.selected_repo().unwrap().path, PathBuf::from("/b"));
}
fn list_with_expanded_worktree() -> RepoList {
let mut list = make_list(&["/r"]);
let mut status = empty_status("main");
status.worktree_info.push(worktree_entry("feature"));
list.repos[0].status = Some(status);
list.rebuild_display_rows();
list.render_area = Rect::new(0, 0, 40, 10);
list
}
fn right_click(row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 5,
row,
modifiers: crossterm::event::KeyModifiers::empty(),
}
}
#[test]
fn resolve_target_maps_worktree_path_to_its_branch_and_parent() {
let list = list_with_expanded_worktree();
let target = list
.resolve_target(&RepoId(PathBuf::from("/wt/feature")))
.expect("worktree path resolves to a target");
assert_eq!(target.exec_path, PathBuf::from("/wt/feature"));
assert_eq!(target.branch, "feature");
assert_eq!(target.parent_index, 0);
assert!(!target.has_submodules);
}
#[test]
fn resolve_target_maps_repo_path_to_itself() {
let list = list_with_expanded_worktree();
let target = list
.resolve_target(&RepoId(PathBuf::from("/r")))
.expect("repo path resolves to a target");
assert_eq!(target.exec_path, PathBuf::from("/r"));
assert_eq!(target.branch, "main");
assert_eq!(target.parent_index, 0);
}
#[test]
fn resolve_target_returns_none_for_unknown_path() {
let list = list_with_expanded_worktree();
assert!(
list.resolve_target(&RepoId(PathBuf::from("/nope")))
.is_none()
);
}
#[test]
fn right_click_on_worktree_row_opens_menu_targeting_the_worktree() {
let mut list = list_with_expanded_worktree();
let action = list
.handle_mouse_event(right_click(2))
.expect("handler ok")
.expect("worktree right-click yields an action");
match action {
Action::ShowContextMenu { id, .. } => {
assert_eq!(id, RepoId(PathBuf::from("/wt/feature")));
}
other => panic!("expected ShowContextMenu, got {other:?}"),
}
}
#[test]
fn right_click_on_repo_row_opens_menu_targeting_the_repo() {
let mut list = list_with_expanded_worktree();
let action = list
.handle_mouse_event(right_click(1))
.expect("handler ok")
.expect("repo right-click yields an action");
match action {
Action::ShowContextMenu { id, .. } => {
assert_eq!(id, RepoId(PathBuf::from("/r")));
}
other => panic!("expected ShowContextMenu, got {other:?}"),
}
}
#[test]
fn right_click_on_stash_row_opens_no_menu() {
let mut list = make_list(&["/r"]);
let mut status = empty_status("main");
status.stashes.push(stash_entry(0));
list.repos[0].status = Some(status);
list.expanded_stashes.insert(RepoId(PathBuf::from("/r")));
list.rebuild_display_rows();
list.render_area = Rect::new(0, 0, 40, 10);
let action = list.handle_mouse_event(right_click(2)).expect("handler ok");
assert!(action.is_none(), "stash rows have no context menu");
}
#[test]
fn selected_worktree_reports_worktree_when_worktree_row_is_selected() {
let mut list = list_with_expanded_worktree();
list.state.select(Some(1));
let (repo_id, wt) = list.selected_worktree().expect("worktree selected");
assert_eq!(repo_id, RepoId(PathBuf::from("/r")));
assert_eq!(wt.path, PathBuf::from("/wt/feature"));
assert_eq!(wt.branch, "feature");
list.state.select(Some(0));
assert!(list.selected_worktree().is_none());
}
#[test]
fn update_status_keeps_selection_when_subrows_appear_above() {
let mut list = make_list(&["/a", "/b"]);
list.select_repo_row(1);
assert_eq!(list.selected_repo().unwrap().path, PathBuf::from("/b"));
let mut status = empty_status("main");
status.worktree_info = vec![worktree_entry("one"), worktree_entry("two")];
list.update_status(0, status);
assert_eq!(list.selected_repo().unwrap().path, PathBuf::from("/b"));
}
#[test]
fn resync_rows_follows_the_selected_repo_across_a_reorder() {
let mut list = make_list(&["/a", "/b", "/c"]);
list.select_repo_row(2);
let keep = list.selected_row_id();
list.repos.reverse();
list.resync_rows(keep);
assert_eq!(list.selected_repo().unwrap().path, PathBuf::from("/c"));
assert_eq!(list.selected_index(), Some(0), "/c moved to the top");
}
#[test]
fn resync_rows_restores_worktree_subrow_then_falls_back_to_parent() {
let mut list = make_list(&["/a", "/b"]);
let mut status = empty_status("main");
status.worktree_info = vec![worktree_entry("one")];
list.update_status(1, status);
list.state.select(Some(2));
assert!(list.selected_worktree().is_some());
let keep = list.selected_row_id();
list.repos.swap(0, 1);
list.resync_rows(keep);
let (parent, wt) = list.selected_worktree().expect("worktree row survives");
assert_eq!(parent.0, PathBuf::from("/b"));
assert_eq!(wt.name, "one");
list.update_status(0, empty_status("main"));
assert!(list.selected_worktree().is_none());
assert_eq!(list.selected_repo().unwrap().path, PathBuf::from("/b"));
}
fn press_w(list: &mut RepoList) {
use crossterm::event::{KeyCode, KeyEvent};
list.handle_key_event(KeyEvent::from(KeyCode::Char('w')))
.unwrap();
}
fn worktree_rows(list: &RepoList) -> usize {
list.display_rows
.iter()
.filter(|row| matches!(row, DisplayRow::Worktree(..)))
.count()
}
#[test]
fn worktrees_show_by_default_once_status_arrives() {
let mut list = make_list(&["/a"]);
assert_eq!(worktree_rows(&list), 0, "no status yet, so no subrows");
let mut status = empty_status("main");
status.worktree_info = vec![worktree_entry("one"), worktree_entry("two")];
list.update_status(0, status);
assert_eq!(worktree_rows(&list), 2);
}
#[test]
fn expand_worktrees_disabled_starts_collapsed_and_still_toggles() {
let mut list = make_list_with_expand(&["/a"], false);
let mut status = empty_status("main");
status.worktree_info = vec![worktree_entry("one")];
list.update_status(0, status);
assert_eq!(worktree_rows(&list), 0);
list.select_repo_row(0);
press_w(&mut list);
assert_eq!(worktree_rows(&list), 1);
press_w(&mut list);
assert_eq!(worktree_rows(&list), 0);
}
#[test]
fn expand_repo_reverses_a_session_collapse() {
let mut list = make_list(&["/a"]);
let mut status = empty_status("main");
status.worktree_info = vec![worktree_entry("one")];
list.update_status(0, status);
list.select_repo_row(0);
press_w(&mut list);
assert_eq!(worktree_rows(&list), 0);
let mut status = empty_status("main");
status.worktree_info = vec![worktree_entry("one")];
list.update_status(0, status);
assert_eq!(worktree_rows(&list), 0);
list.expand_repo(&PathBuf::from("/a"));
assert_eq!(worktree_rows(&list), 1);
}