use super::*;
use crate::{
supervisor::Supervisor,
testutil::{temp, wait_until},
transport::LocalTransport,
ui::scroll_window,
};
impl App {
fn new_local(rows: u16, cols: u16) -> App {
App::assemble(rows, cols, |pr, c, _wait_tx| {
let mut sup = Supervisor::new(pr, c, 2000);
sup.set_launch_context(crate::protocol::LaunchContext::here());
Box::new(LocalTransport::new(sup))
})
}
fn new_local_with_ctx(rows: u16, cols: u16, ctx: crate::protocol::LaunchContext) -> App {
App::assemble(rows, cols, move |pr, c, _wait_tx| {
let mut sup = Supervisor::new(pr, c, 2000);
sup.set_launch_context(ctx);
Box::new(LocalTransport::new(sup))
})
}
fn pump(&mut self) {
self.sync();
}
fn spawn_in(&mut self, cmd: &str, cwd: PathBuf) {
self.spawn_checked(cmd, cwd, None);
}
fn spawn_grouped(&mut self, cmd: &str, cwd: PathBuf, group: &str) {
self.spawn_checked(cmd, cwd, Some(group));
}
fn spawn_checked(&mut self, cmd: &str, cwd: PathBuf, group: Option<&str>) {
self.pump();
let want = self.views.len() + 1;
for attempt in 0u64..5 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_millis(25 << attempt));
}
self.transport.send(Command::Spawn {
command: cmd.to_string(),
cwd: cwd.clone(),
group: group.map(str::to_string),
});
self.pump();
if self.views.len() == want {
if attempt > 0 {
self.status = None;
}
return;
}
}
panic!("spawn never landed: {:?}", self.status);
}
fn section_ids(&self) -> Vec<(String, Vec<u64>)> {
self.sections()
.into_iter()
.map(|(l, idxs)| (l, idxs.into_iter().map(|i| self.views[i].id).collect()))
.collect()
}
}
#[test]
fn selection_follows_task_across_reorder() {
let mut app = App::new_local(30, 100);
let dir = app.invocation_dir.clone();
app.spawn_in("sleep 5", dir.clone()); app.spawn_in("sleep 5", dir); app.pump();
app.resolve_selection();
assert_eq!(app.selected_id, Some(1));
app.transport.send(Command::Tag { id: 2, on: true });
app.pump();
let order = app.display_order();
assert_eq!(app.views[order[0]].id, 2, "tagged task should sort first");
assert_eq!(app.selected_id, Some(1));
assert_eq!(app.views[app.selected_task().unwrap()].id, 1);
}
#[test]
fn dir_mode_groups_by_cwd() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv); app.spawn_in("sleep 5", PathBuf::from("/tmp")); app.pump();
app.group_mode = GroupMode::State;
let s = app.sections();
assert_eq!(s.len(), 1);
assert_eq!(s[0].0, "Running");
assert_eq!(s[0].1.len(), 2);
app.group_mode = GroupMode::Dir;
let s = app.sections();
assert_eq!(s.len(), 2, "one section per distinct cwd");
assert_eq!(s[0].0, app.invocation_label, "invocation dir sorts first");
assert_eq!(s[1].0, "/tmp");
}
#[test]
fn group_mode_cycles_state_dir_custom() {
assert_eq!(GroupMode::State.next(), GroupMode::Dir);
assert_eq!(GroupMode::Dir.next(), GroupMode::Custom);
assert_eq!(GroupMode::Custom.next(), GroupMode::State);
let mut app = App::new_local(30, 100);
assert_eq!(app.group_mode, GroupMode::State);
for expect in [GroupMode::Dir, GroupMode::Custom, GroupMode::State] {
app.on_key_dashboard(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
assert_eq!(app.group_mode, expect);
}
}
#[test]
fn custom_mode_groups_by_name_with_unassigned_last() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "beta"); app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.spawn_in("sleep 5", inv); app.pump();
app.group_mode = GroupMode::Custom;
assert_eq!(
app.section_ids(),
vec![
("alpha".to_string(), vec![2]),
("beta".to_string(), vec![1]),
("Unassigned".to_string(), vec![3]),
]
);
}
#[test]
fn custom_mode_unassigned_tracks_membership() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.pump();
app.group_mode = GroupMode::Custom;
assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1])]);
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv); app.pump();
app.group_mode = GroupMode::Custom;
assert_eq!(app.section_ids(), vec![("Unassigned".to_string(), vec![1])]);
}
#[test]
fn custom_mode_tag_floats_within_group() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.group_mode = GroupMode::Custom;
assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1, 2])]);
app.transport.send(Command::Tag { id: 2, on: true });
app.pump();
assert_eq!(
app.section_ids(),
vec![("alpha".to_string(), vec![2, 1])],
"tag floats id 2 to the top of alpha, not into an In use section"
);
}
#[test]
fn custom_mode_selection_survives_group_move() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.spawn_grouped("sleep 5", inv, "beta"); app.pump();
app.group_mode = GroupMode::Custom;
app.resolve_selection();
assert_eq!(app.selected_id, Some(1));
app.transport.send(Command::SetGroup {
id: 1,
group: Some("zeta".to_string()),
});
app.pump();
assert_eq!(
app.section_ids(),
vec![("beta".to_string(), vec![2]), ("zeta".to_string(), vec![1]),]
);
assert_eq!(app.selected_id, Some(1));
assert_eq!(app.views[app.selected_task().unwrap()].id, 1);
}
#[test]
fn custom_mode_clusters_by_dir_within_group() {
let mut app = App::new_local(30, 100);
let base = temp("app_cg");
let (dir_a, dir_b) = (base.join("a"), base.join("b"));
std::fs::create_dir_all(&dir_a).unwrap();
std::fs::create_dir_all(&dir_b).unwrap();
app.spawn_grouped("sleep 5", dir_b.clone(), "alpha"); app.spawn_grouped("sleep 5", dir_a.clone(), "alpha"); app.spawn_grouped("sleep 5", dir_a, "alpha"); app.pump();
app.group_mode = GroupMode::Custom;
assert_eq!(
app.section_ids(),
vec![("alpha".to_string(), vec![2, 3, 1])],
"dir a's tasks cluster (in id order) ahead of dir b's"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn tagging_a_finished_task_moves_it_to_in_use() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("true", inv); wait_until(Duration::from_secs(5), || {
app.pump();
app.views
.first()
.map(|v| matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed))
.unwrap_or(false)
});
assert!(matches!(
app.views[0].lifecycle,
Lifecycle::Ok | Lifecycle::Failed
));
assert_eq!(app.sections()[0].0, "Completed");
let id = app.views[0].id;
app.transport.send(Command::Tag { id, on: true });
app.pump();
assert_eq!(app.sections()[0].0, "In use");
}
#[test]
fn parked_task_lands_in_idle_between_running_and_completed() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv.clone()); app.spawn_in("sleep 5", inv.clone()); app.spawn_in("sleep 5", inv.clone()); app.spawn_in("true", inv); wait_until(Duration::from_secs(5), || {
app.pump();
app.views
.iter()
.any(|v| v.id == 4 && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed))
});
app.transport.send(Command::Tag { id: 3, on: true });
app.pump();
let i = app.views.iter().position(|v| v.id == 2).unwrap();
app.views[i].parked = true;
assert_eq!(
app.section_ids(),
vec![
("In use".to_string(), vec![3]),
("Running".to_string(), vec![1]),
("Idle".to_string(), vec![2]),
("Completed".to_string(), vec![4]),
]
);
}
#[test]
fn tagged_parked_task_stays_in_use() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv.clone()); app.spawn_in("sleep 5", inv); app.pump();
app.transport.send(Command::Tag { id: 1, on: true });
app.pump();
let i = app.views.iter().position(|v| v.id == 1).unwrap();
app.views[i].parked = true;
assert_eq!(
app.section_ids(),
vec![
("In use".to_string(), vec![1]),
("Running".to_string(), vec![2]),
]
);
}
#[test]
fn idle_glyph_task_lands_in_idle_section() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv); app.pump();
let i = app.views.iter().position(|v| v.id == 1).unwrap();
app.views[i].lifecycle = Lifecycle::Idle;
app.views[i].parked = true;
assert_eq!(app.section_ids(), vec![("Idle".to_string(), vec![1])]);
}
#[test]
fn selection_follows_task_across_parked_rebucket() {
let mut app = App::new_local(30, 100);
let dir = app.invocation_dir.clone();
app.spawn_in("sleep 5", dir.clone()); app.spawn_in("sleep 5", dir); app.pump();
app.resolve_selection();
assert_eq!(app.selected_id, Some(1));
let i = app.views.iter().position(|v| v.id == 1).unwrap();
app.views[i].parked = true;
let order = app.display_order();
assert_eq!(app.views[order[0]].id, 2, "running task should sort first");
assert_eq!(app.selected_id, Some(1));
assert_eq!(app.views[app.selected_task().unwrap()].id, 1);
}
#[test]
fn custom_mode_parked_sinks_within_group() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.group_mode = GroupMode::Custom;
assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1, 2])]);
let i = app.views.iter().position(|v| v.id == 1).unwrap();
app.views[i].parked = true;
assert_eq!(
app.section_ids(),
vec![("alpha".to_string(), vec![2, 1])],
"parked id 1 sinks below running id 2 within alpha"
);
}
#[test]
fn rerun_key_is_gated_to_finished_tasks() {
let mut app = App::new_local(30, 100);
let dir = temp("app_rerun");
let marker = dir.join("marker");
app.spawn_in("sleep 30", dir.clone()); app.spawn_in(&format!("echo run >> {}", marker.display()), dir.clone()); wait_until(Duration::from_secs(5), || {
app.pump();
app.views
.iter()
.any(|v| v.id == 2 && matches!(v.lifecycle, Lifecycle::Ok))
});
app.selected_id = Some(1);
app.on_key_dashboard(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
app.pump();
assert!(app.status.is_none(), "no Restart should have been sent");
assert!(
app.views
.iter()
.any(|v| v.id == 1 && matches!(v.lifecycle, Lifecycle::Active | Lifecycle::Idle)),
"the running task must be untouched"
);
app.selected_id = Some(2);
app.on_key_dashboard(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
wait_until(Duration::from_secs(5), || {
app.pump();
std::fs::read_to_string(&marker)
.map(|s| s.lines().count() == 2)
.unwrap_or(false)
});
assert_eq!(
std::fs::read_to_string(&marker).unwrap().lines().count(),
2,
"rerun must re-execute the command"
);
assert!(
app.views.iter().any(|v| v.id == 2),
"rerun must keep the id"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn session_scratch(tag: &str, names: &[&str]) -> PathBuf {
let dir = temp(&format!("app_{tag}"));
std::fs::create_dir_all(dir.join("sessions")).unwrap();
for n in names {
std::fs::write(dir.join("sessions").join(format!("{n}.json")), "{}").unwrap();
}
dir
}
fn app_with_config_dir(dir: &Path) -> App {
App::new_local_with_ctx(
30,
100,
crate::protocol::LaunchContext {
env: vec![(
"FLEETCOM_CONFIG_DIR".into(),
dir.to_path_buf().into_os_string(),
)],
cwd: dir.to_path_buf(),
},
)
}
#[test]
fn o_key_round_trips_the_session_list_through_the_core() {
let dir = session_scratch("sess_list", &["b", "a"]);
let mut app = app_with_config_dir(&dir);
app.session_sel = 3;
app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
assert!(matches!(app.mode, Mode::LoadSession));
assert!(
app.session_names.is_empty(),
"the picker opens empty until the reply lands"
);
assert_eq!(
app.session_sel, 0,
"opening the picker resets the selection"
);
app.pump();
assert_eq!(
app.session_names,
vec!["a".to_string(), "b".to_string()],
"the Sessions reply populates the picker, sorted"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn session_selection_clamps_when_a_shorter_list_arrives() {
let dir = session_scratch("sess_clamp", &["a", "b", "c"]);
let mut app = app_with_config_dir(&dir);
app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
app.pump();
assert_eq!(app.session_names.len(), 3);
app.session_sel = 2;
std::fs::remove_file(dir.join("sessions").join("b.json")).unwrap();
std::fs::remove_file(dir.join("sessions").join("c.json")).unwrap();
app.transport.send(Command::ListSessions);
app.pump();
assert_eq!(app.session_names, vec!["a".to_string()]);
assert_eq!(app.session_sel, 0, "selection must clamp to the new length");
std::fs::remove_file(dir.join("sessions").join("a.json")).unwrap();
app.transport.send(Command::ListSessions);
app.pump();
assert!(app.session_names.is_empty());
assert_eq!(app.session_sel, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recent_dirs_are_distinct_and_newest_first() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", PathBuf::from("/tmp")); app.spawn_in("sleep 5", inv.clone()); app.spawn_in("sleep 5", PathBuf::from("/tmp")); app.pump();
let dirs = app.in_use_dirs();
assert_eq!(dirs.len(), 2, "duplicate dirs collapse");
assert_eq!(dirs[0], PathBuf::from("/tmp"), "newest first");
assert_eq!(dirs[1], inv);
}
#[test]
fn picker_puts_current_dir_first_and_selected() {
let mut app = App::new_local(30, 100);
app.dir_input.clear();
app.refresh_dir_candidates();
assert_eq!(app.dir_sel, 0, "current dir selected by default");
assert_eq!(app.dir_candidates[0].kind, DirKind::Use);
assert_eq!(app.dir_candidates[0].path, app.invocation_dir);
}
#[test]
fn focus_by_id_survives_index_shift() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv.clone()); app.spawn_in("sleep 5", inv); app.pump();
app.focused_id = Some(2);
assert_eq!(app.views[app.focused_task().unwrap()].id, 2);
app.transport.send(Command::Remove { id: 1 }); app.pump();
assert_eq!(app.views[app.focused_task().unwrap()].id, 2);
app.transport.send(Command::Remove { id: 2 });
app.pump();
assert!(app.focused_task().is_none());
}
#[test]
fn rows_interleave_headers_and_tasks() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("a", inv.clone()); app.spawn_in("b", PathBuf::from("/tmp")); app.pump();
app.group_mode = GroupMode::Dir;
let rows = app.list_rows();
assert_eq!(rows.len(), 4, "two sections, one task each");
assert_eq!(rows[0], Row::Section(app.invocation_label.clone()));
assert!(matches!(rows[1], Row::Task(i) if app.views[i].id == 1));
assert_eq!(rows[2], Row::Section("/tmp".to_string()));
assert!(matches!(rows[3], Row::Task(i) if app.views[i].id == 2));
}
#[test]
fn dashboard_selection_stays_in_scroll_window() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
for _ in 0..8 {
app.spawn_in("sleep 5", inv.clone());
}
app.pump();
app.resolve_selection();
let height = 4;
for step in 0..10 {
app.select_down();
let rows = app.list_rows();
let sel = app.selected_row(&rows).expect("selection always resolves");
let (start, count) = scroll_window(sel, rows.len(), height);
assert!(
sel >= start && sel < start + count,
"step {step}: row {sel} outside window ({start}, {count})"
);
}
for step in 0..10 {
app.select_up();
let rows = app.list_rows();
let sel = app.selected_row(&rows).expect("selection always resolves");
let (start, count) = scroll_window(sel, rows.len(), height);
assert!(
sel >= start && sel < start + count,
"step {step}: row {sel} outside window ({start}, {count})"
);
}
}
#[test]
fn selection_wraps_at_list_edges() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv.clone()); app.spawn_in("sleep 5", inv.clone()); app.spawn_in("sleep 5", inv); app.pump();
app.transport.send(Command::Tag { id: 2, on: true });
app.pump();
app.resolve_selection();
assert_eq!(app.section_ids().len(), 2, "tag splits the list in two");
let order = app.display_order();
let first = app.views[order[0]].id;
let last = app.views[*order.last().unwrap()].id;
assert_eq!(first, 2, "tagged task sorts first");
assert_eq!(app.selected_id, Some(first));
app.select_up();
assert_eq!(
app.selected_id,
Some(last),
"up from the first wraps to the last"
);
app.select_down();
assert_eq!(
app.selected_id,
Some(first),
"down from the last wraps to the first"
);
}
#[test]
fn selection_wrap_is_noop_with_one_task() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.pump();
app.resolve_selection();
assert_eq!(app.selected_id, Some(1));
app.select_up();
assert_eq!(app.selected_id, Some(1));
app.select_down();
assert_eq!(app.selected_id, Some(1));
}
fn app_with_two_sections() -> App {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
for _ in 0..4 {
app.spawn_in("sleep 5", inv.clone());
}
app.pump();
app.transport.send(Command::Tag { id: 3, on: true });
app.transport.send(Command::Tag { id: 4, on: true });
app.pump();
assert_eq!(
app.section_ids(),
vec![
("In use".to_string(), vec![3, 4]),
("Running".to_string(), vec![1, 2]),
],
"tags split the list into two two-task sections"
);
app
}
#[test]
fn tab_jumps_to_next_section_first_task() {
let mut app = app_with_two_sections();
app.selected_id = Some(4);
app.select_next_section();
assert_eq!(
app.selected_id,
Some(1),
"next section's first, not same offset"
);
app.selected_id = Some(2);
app.select_next_section();
assert_eq!(app.selected_id, Some(3), "forward from last section wraps");
}
#[test]
fn backtab_jumps_to_previous_section_first_task() {
let mut app = app_with_two_sections();
app.selected_id = Some(2);
app.select_prev_section();
assert_eq!(
app.selected_id,
Some(3),
"previous section's first, not current's"
);
app.selected_id = Some(3);
app.select_prev_section();
assert_eq!(
app.selected_id,
Some(1),
"backward from first section wraps"
);
}
#[test]
fn section_nav_is_noop_with_one_task() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.pump();
app.resolve_selection();
assert_eq!(app.selected_id, Some(1));
app.select_next_section();
assert_eq!(app.selected_id, Some(1));
app.select_prev_section();
assert_eq!(app.selected_id, Some(1));
}
#[test]
fn section_nav_defaults_without_selection() {
let mut app = app_with_two_sections();
app.selected_id = None;
app.select_next_section();
assert_eq!(
app.selected_id,
Some(3),
"no selection: first section's first"
);
app.selected_id = None;
app.select_prev_section();
assert_eq!(
app.selected_id,
Some(1),
"no selection: last section's first"
);
let mut empty = App::new_local(30, 100);
empty.select_next_section();
assert_eq!(empty.selected_id, None);
empty.select_prev_section();
assert_eq!(empty.selected_id, None);
}
#[test]
fn key_event_maps_to_semantic_key() {
assert_eq!(
key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
Some((Key::Char('a'), Mods::default()))
);
assert_eq!(
key_event_to_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)),
Some((Key::F(5), Mods::default()))
);
assert_eq!(
key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)),
Some((
Key::Char('a'),
Mods {
shift: true,
..Mods::default()
}
))
);
assert_eq!(
key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)),
Some((
Key::Char('a'),
Mods {
ctrl: true,
..Mods::default()
}
))
);
assert_eq!(
key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)),
Some((
Key::Char('a'),
Mods {
alt: true,
..Mods::default()
}
))
);
assert_eq!(
key_event_to_key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)),
Some((
Key::Left,
Mods {
alt: true,
..Mods::default()
}
))
);
}
#[test]
fn unencodable_modifiers_and_keys_are_dropped() {
assert_eq!(
key_event_to_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER)),
Some((Key::Char('a'), Mods::default()))
);
assert_eq!(
key_event_to_key(KeyEvent::new(KeyCode::CapsLock, KeyModifiers::NONE)),
None
);
assert_eq!(
key_event_to_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)),
None
);
}
#[test]
fn oversized_paste_is_refused_with_a_notice() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Attached;
app.focused_id = Some(1);
app.on_paste(&"x".repeat(MAX_PASTE + 1));
let status = app.status.clone().unwrap_or_default();
assert!(status.contains("paste dropped"), "status was {status:?}");
app.on_paste(&"x".repeat(MAX_PASTE));
assert!(app.status.is_none(), "boundary paste must not be refused");
}
#[test]
fn paste_into_text_entry_strips_controls() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Spawn;
app.on_paste("cargo\ttest\r\n --all");
assert_eq!(app.input.as_str(), "cargotest --all");
assert!(app.mode == Mode::Spawn, "paste must not submit");
}
#[test]
fn input_modes_match_screen_type() {
let screen = |wants_mouse, alt_screen, alt_scroll| ScreenView {
id: 1,
lines: Vec::new(),
formatted: Vec::new(),
cursor: (0, 0),
hide_cursor: false,
wants_mouse,
alt_screen,
alt_scroll,
scrollback: 0,
};
assert!(!desired_mouse_capture(None, false));
assert!(desired_mouse_capture(
Some(&screen(true, true, true)),
false
));
assert!(desired_mouse_capture(
Some(&screen(true, false, false)),
false
));
assert!(!desired_mouse_capture(
Some(&screen(false, true, true)),
false
));
assert!(desired_mouse_capture(
Some(&screen(false, true, false)),
false
));
assert!(desired_mouse_capture(
Some(&screen(false, false, false)),
false
));
assert!(desired_mouse_capture(
Some(&screen(false, false, false)),
true
));
assert!(desired_mouse_capture(None, true));
}
#[test]
fn wheel_up_enters_scroll_view_for_inline_children() {
let mut app = App::new_local(30, 100);
let dir = app.invocation_dir.clone();
app.spawn_in("sleep 5", dir);
app.pump();
app.resolve_selection();
app.attach();
let id = app.focused_id.expect("attached");
let screen = |wants_mouse| ScreenView {
id,
lines: Vec::new(),
formatted: Vec::new(),
cursor: (0, 0),
hide_cursor: false,
wants_mouse,
alt_screen: false,
alt_scroll: false,
scrollback: 0,
};
let wheel_up = MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
};
app.focused_screen = Some(screen(false));
app.on_mouse(wheel_up);
assert!(app.view_scroll, "wheel-up must open the scroll view");
app.view_scroll = false;
app.focused_screen = Some(screen(true));
app.on_mouse(wheel_up);
assert!(!app.view_scroll, "mouse-aware children keep their wheel");
}
#[test]
fn attached_wheel_honors_the_childs_1007_veto() {
let dir = temp("app_1007");
let wheel_up = MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
};
let run = |veto: bool, take: usize, out: PathBuf| -> Vec<u8> {
let mut app = App::new_local(30, 100);
let cwd = app.invocation_dir.clone();
let modes = if veto {
"\\033[?1049h\\033[?1007l"
} else {
"\\033[?1049h"
};
let cmd = format!(
"stty -icanon -echo min 1 time 0; printf '{modes}'; head -c {take} > {}",
out.display()
);
app.spawn_in(&cmd, cwd);
app.pump();
app.resolve_selection();
app.attach();
let id = app.focused_id.expect("attached");
app.set_watch(Some(id));
assert!(
wait_until(Duration::from_secs(5), || {
app.pump();
matches!(
app.screen_for(id),
Some(s) if s.alt_screen && s.alt_scroll != veto
)
}),
"gate state (veto: {veto}) never reached the client"
);
assert_eq!(desired_mouse_capture(app.screen_for(id), false), veto);
app.on_mouse(wheel_up);
if veto {
app.transport.send(Command::Input {
id,
bytes: b"zzz".to_vec(),
});
}
let mut got = Vec::new();
wait_until(Duration::from_secs(5), || {
got = std::fs::read(&out).unwrap_or_default();
got.len() >= take
});
got
};
assert_eq!(run(true, 3, dir.join("veto")), b"zzz".to_vec());
assert_eq!(
run(false, 9, dir.join("dflt")),
b"\x1b[A\x1b[A\x1b[A".to_vec()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scroll_view_entry_and_exit() {
let mut app = App::new_local(30, 100);
let dir = app.invocation_dir.clone();
app.spawn_in("sleep 5", dir);
app.pump();
app.resolve_selection();
app.attach();
assert!(app.mode == Mode::Attached);
let mut out = io::stdout();
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
app.on_key_attached(
&mut out,
KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT),
)
.unwrap();
assert!(app.view_scroll, "Shift+PageUp must enter the scroll view");
app.on_key_attached(&mut out, key(KeyCode::Esc)).unwrap();
assert!(!app.view_scroll, "Esc must return to live");
app.on_key_attached(
&mut out,
KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL),
)
.unwrap();
assert!(app.view_scroll, "Ctrl+PageUp is an entry fallback");
app.on_key_attached(&mut out, key(KeyCode::Char('x')))
.unwrap();
assert!(!app.view_scroll, "typing must snap back to live");
app.on_key_attached(&mut out, key(KeyCode::PageUp)).unwrap();
assert!(!app.view_scroll);
}
#[test]
fn wheel_moves_dashboard_selection() {
let mut app = App::new_local(30, 100);
let dir = app.invocation_dir.clone();
app.spawn_in("sleep 5", dir.clone()); app.spawn_in("sleep 5", dir); app.pump();
app.resolve_selection();
assert_eq!(app.selected_id, Some(1));
let wheel = |kind| MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(wheel(MouseEventKind::ScrollDown));
assert_eq!(app.selected_id, Some(2));
app.on_mouse(wheel(MouseEventKind::ScrollUp));
assert_eq!(app.selected_id, Some(1));
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
#[test]
fn group_picker_opens_on_g_only_with_a_selection() {
let mut app = App::new_local(30, 100);
app.on_key_dashboard(key(KeyCode::Char('g')));
assert!(app.mode == Mode::Dashboard, "no selection: g must no-op");
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('g')));
assert!(app.mode == Mode::PickGroup);
assert_eq!(app.group_target, Some(1));
}
#[test]
fn group_candidates_are_distinct_sorted_and_marked() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "beta"); app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.spawn_in("sleep 5", inv); app.pump();
app.selected_id = Some(1); app.on_key_dashboard(key(KeyCode::Char('g')));
let labels: Vec<&str> = app
.group_candidates
.iter()
.map(|c| c.label.as_str())
.collect();
assert_eq!(labels, vec!["Unassigned", "alpha", "beta (current)"]);
let groups: Vec<Option<&str>> = app
.group_candidates
.iter()
.map(|c| c.group.as_deref())
.collect();
assert_eq!(groups, vec![None, Some("alpha"), Some("beta")]);
assert_eq!(app.group_sel, 0, "nothing typed keeps the clear row");
app.on_key_pickgroup(key(KeyCode::Esc));
app.selected_id = Some(4);
app.on_key_dashboard(key(KeyCode::Char('g')));
assert_eq!(app.group_candidates[0].label, "Unassigned (current)");
}
#[test]
fn group_filter_narrows_and_preselects_the_first_match() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.spawn_grouped("sleep 5", inv, "beta"); app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('g')));
assert_eq!(app.group_candidates.len(), 3);
app.on_key_pickgroup(key(KeyCode::Char('B')));
let labels: Vec<&str> = app
.group_candidates
.iter()
.map(|c| c.label.as_str())
.collect();
assert_eq!(
labels,
vec!["Unassigned", "beta"],
"case-insensitive prefix"
);
assert_eq!(app.group_sel, 1, "filtering preselects the first match");
app.on_key_pickgroup(key(KeyCode::Char('z')));
assert_eq!(app.group_candidates.len(), 1, "\"Bz\" matches nothing");
assert_eq!(app.group_sel, 0);
app.on_key_pickgroup(key(KeyCode::Backspace));
assert_eq!(app.group_candidates.len(), 2, "backspace re-widens");
}
#[test]
fn group_enter_on_a_candidate_assigns_it() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.spawn_in("sleep 5", inv); app.pump();
app.selected_id = Some(2);
app.on_key_dashboard(key(KeyCode::Char('g')));
app.on_key_pickgroup(key(KeyCode::Char('a'))); app.on_key_pickgroup(key(KeyCode::Enter));
assert!(app.mode == Mode::Dashboard);
app.pump();
let v = app.views.iter().find(|v| v.id == 2).unwrap();
assert_eq!(v.group.as_deref(), Some("alpha"));
}
#[test]
fn group_enter_on_novel_text_creates_the_group() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('g')));
for c in "gamma".chars() {
app.on_key_pickgroup(key(KeyCode::Char(c)));
}
assert_eq!(app.group_candidates.len(), 1, "nothing matches");
app.on_key_pickgroup(key(KeyCode::Enter));
app.pump();
let v = app.views.iter().find(|v| v.id == 1).unwrap();
assert_eq!(v.group.as_deref(), Some("gamma"));
}
#[test]
fn group_enter_on_empty_input_clears_to_unassigned() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('g')));
assert_eq!(app.group_sel, 0, "empty input highlights the clear row");
app.on_key_pickgroup(key(KeyCode::Enter));
app.pump();
let v = app.views.iter().find(|v| v.id == 1).unwrap();
assert_eq!(v.group, None);
}
#[test]
fn group_esc_cancels_without_sending() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('g')));
for c in "gamma".chars() {
app.on_key_pickgroup(key(KeyCode::Char(c)));
}
app.on_key_pickgroup(key(KeyCode::Esc));
assert!(app.mode == Mode::Dashboard);
assert!(app.group_input.is_empty() && app.group_candidates.is_empty());
assert_eq!(app.group_target, None);
app.pump();
let v = app.views.iter().find(|v| v.id == 1).unwrap();
assert_eq!(v.group.as_deref(), Some("alpha"), "Esc must send nothing");
}
#[test]
fn rename_prompt_opens_on_shift_r_only_with_a_selection() {
let mut app = App::new_local(30, 100);
app.on_key_dashboard(key(KeyCode::Char('R')));
assert!(app.mode == Mode::Dashboard, "no selection: R must no-op");
assert_eq!(app.rename_target, None);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('R')));
assert!(app.mode == Mode::Rename);
assert_eq!(app.rename_target, Some(1));
assert_eq!(app.input.as_str(), "", "an unnamed task prefills empty");
app.on_key_rename(key(KeyCode::Esc));
app.transport.send(Command::SetName {
id: 1,
name: Some("api".to_string()),
});
app.pump();
app.on_key_dashboard(key(KeyCode::Char('R')));
assert_eq!(app.input.as_str(), "api");
}
#[test]
fn rename_enter_sends_the_typed_name() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('R')));
for c in "api server".chars() {
app.on_key_rename(key(KeyCode::Char(c)));
}
app.on_key_rename(key(KeyCode::Enter));
assert!(app.mode == Mode::Dashboard);
assert!(app.input.is_empty() && app.rename_target.is_none());
app.pump();
let v = app.views.iter().find(|v| v.id == 1).unwrap();
assert_eq!(v.name.as_deref(), Some("api server"));
}
#[test]
fn rename_enter_on_empty_input_clears_the_name() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.transport.send(Command::SetName {
id: 1,
name: Some("api".to_string()),
});
app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('R')));
assert_eq!(app.input.as_str(), "api");
for _ in 0.."api".len() {
app.on_key_rename(key(KeyCode::Backspace));
}
app.on_key_rename(key(KeyCode::Enter));
app.pump();
let v = app.views.iter().find(|v| v.id == 1).unwrap();
assert_eq!(v.name, None);
}
#[test]
fn rename_esc_cancels_without_sending() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.transport.send(Command::SetName {
id: 1,
name: Some("api".to_string()),
});
app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('R')));
for c in "junk".chars() {
app.on_key_rename(key(KeyCode::Char(c)));
}
app.on_key_rename(key(KeyCode::Esc));
assert!(app.mode == Mode::Dashboard);
assert!(app.input.is_empty());
assert_eq!(app.rename_target, None);
app.pump();
let v = app.views.iter().find(|v| v.id == 1).unwrap();
assert_eq!(v.name.as_deref(), Some("api"), "Esc must send nothing");
}
#[test]
fn rename_caret_keys_edit_mid_name() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.transport.send(Command::SetName {
id: 1,
name: Some("api".to_string()),
});
app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('R')));
assert_eq!(app.input.as_str(), "api");
app.on_key_rename(key(KeyCode::Left));
app.on_key_rename(key(KeyCode::Left));
app.on_key_rename(key(KeyCode::Char('x')));
assert_eq!(app.input.as_str(), "axpi");
app.on_key_rename(ctrl(KeyCode::Char('a')));
app.on_key_rename(key(KeyCode::Char('z')));
assert_eq!(app.input.as_str(), "zaxpi");
app.on_key_rename(ctrl(KeyCode::Char('e')));
app.on_key_rename(key(KeyCode::Char('!')));
assert_eq!(app.input.as_str(), "zaxpi!");
app.on_key_rename(key(KeyCode::Left));
app.on_key_rename(key(KeyCode::Backspace));
assert_eq!(app.input.as_str(), "zaxp!");
app.on_key_rename(key(KeyCode::Home));
app.on_key_rename(key(KeyCode::Char('0')));
app.on_key_rename(key(KeyCode::End));
app.on_key_rename(key(KeyCode::Char('9')));
assert_eq!(app.input.as_str(), "0zaxp!9");
}
#[test]
fn ctrl_chords_never_insert_their_letter() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('w')));
app.on_key_savesession(ctrl(KeyCode::Char('k')));
assert!(app.input.is_empty(), "Ctrl-K must not insert into a prompt");
app.on_key_savesession(key(KeyCode::Esc));
app.on_key_dashboard(key(KeyCode::Char('@')));
app.on_key_pickdir(ctrl(KeyCode::Char('k')));
assert!(app.dir_input.is_empty(), "Ctrl-K must not filter the dirs");
app.on_key_pickdir(key(KeyCode::Esc));
app.on_key_dashboard(key(KeyCode::Char('g')));
let before = app.group_candidates.len();
app.on_key_pickgroup(ctrl(KeyCode::Char('k')));
assert!(
app.group_input.is_empty(),
"Ctrl-K must not filter the groups"
);
assert_eq!(app.group_candidates.len(), before);
}
#[test]
fn pickdir_right_descends_only_from_the_end() {
let dir = temp("caret_pickdir");
std::fs::create_dir_all(dir.join("alpha")).unwrap();
let mut app = App::new_local(30, 100);
app.invocation_dir = dir.clone();
app.on_key_dashboard(key(KeyCode::Char('@')));
for c in "al".chars() {
app.on_key_pickdir(key(KeyCode::Char(c)));
}
assert_eq!(app.dir_sel, 1, "the fragment preselects alpha");
app.on_key_pickdir(key(KeyCode::Left));
app.on_key_pickdir(key(KeyCode::Right));
assert_eq!(
app.dir_input.as_str(),
"al",
"Right off-end must not descend"
);
app.on_key_pickdir(key(KeyCode::Right));
assert!(
app.dir_input.ends_with("alpha/"),
"Right at end descends: {:?}",
app.dir_input.as_str()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pickdir_refreshes_on_edits_not_caret_motion() {
let dir = temp("caret_pickdir_refresh");
std::fs::create_dir_all(dir.join("alpha")).unwrap();
let mut app = App::new_local(30, 100);
app.invocation_dir = dir.clone();
app.on_key_dashboard(key(KeyCode::Char('@')));
app.on_key_pickdir(key(KeyCode::Char('l')));
assert_eq!(app.dir_candidates.len(), 1, "\"l\" matches nothing");
app.on_key_pickdir(key(KeyCode::Home));
assert_eq!(app.dir_candidates.len(), 1, "caret motion must not refresh");
app.on_key_pickdir(key(KeyCode::Char('a')));
assert_eq!(app.dir_input.as_str(), "al");
assert!(
app.dir_candidates.iter().any(|c| c.label == "alpha"),
"an edit at the caret refreshes the candidates"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pickgroup_caret_edits_refresh_the_filter() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "alpha"); app.spawn_grouped("sleep 5", inv, "beta"); app.pump();
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('g')));
app.on_key_pickgroup(key(KeyCode::Char('b')));
assert_eq!(app.group_candidates.len(), 2, "\"b\" matches beta");
app.on_key_pickgroup(key(KeyCode::Left));
assert_eq!(
app.group_candidates.len(),
2,
"caret motion must not refresh"
);
app.on_key_pickgroup(key(KeyCode::Char('a')));
assert_eq!(app.group_input.as_str(), "ab");
assert_eq!(app.group_candidates.len(), 1, "the caret edit re-filtered");
}
#[test]
fn paste_lands_at_the_caret() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Spawn;
for c in "cargo t".chars() {
app.on_key_spawn(key(KeyCode::Char(c)));
}
app.on_key_spawn(key(KeyCode::Left)); app.on_paste("x\ny"); assert_eq!(app.input.as_str(), "cargo xyt");
app.on_key_spawn(key(KeyCode::Char('z')));
assert_eq!(
app.input.as_str(),
"cargo xyzt",
"caret sits after the paste"
);
}
#[test]
fn multibyte_chars_edit_cleanly_in_a_prompt() {
let mut app = App::new_local(30, 100);
app.on_key_dashboard(key(KeyCode::Char('w')));
app.on_key_savesession(key(KeyCode::Char('é')));
app.on_key_savesession(key(KeyCode::Left));
app.on_key_savesession(key(KeyCode::Char('日')));
assert_eq!(app.input.as_str(), "日é");
app.on_key_savesession(key(KeyCode::Backspace));
assert_eq!(app.input.as_str(), "é");
app.on_key_savesession(key(KeyCode::Right));
app.on_key_savesession(key(KeyCode::Backspace));
assert!(app.input.is_empty());
}
#[test]
fn custom_mode_spawn_inherits_the_selected_group() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.group_mode = GroupMode::Custom;
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('n')));
assert!(app.mode == Mode::Spawn);
assert_eq!(app.spawn_group.as_deref(), Some("alpha"));
for c in "sleep 5".chars() {
app.on_key_spawn(key(KeyCode::Char(c)));
}
app.on_key_spawn(key(KeyCode::Enter));
app.pump();
let v = app.views.iter().find(|v| v.id == 2).unwrap();
assert_eq!(
v.group.as_deref(),
Some("alpha"),
"the spawn must carry the inherited group"
);
}
#[test]
fn dir_picker_handoff_inherits_the_selected_group_in_custom_mode() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.group_mode = GroupMode::Custom;
app.resolve_selection();
app.on_key_dashboard(key(KeyCode::Char('@')));
assert!(app.mode == Mode::PickDir);
app.on_key_pickdir(key(KeyCode::Enter));
assert!(app.mode == Mode::Spawn);
assert_eq!(app.spawn_group.as_deref(), Some("alpha"));
}
#[test]
fn state_and_dir_mode_spawns_stay_unassigned() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.resolve_selection();
for mode in [GroupMode::State, GroupMode::Dir] {
app.group_mode = mode;
app.spawn_group = Some("stale".to_string());
app.on_key_dashboard(key(KeyCode::Char('n')));
assert_eq!(app.spawn_group, None, "{mode:?} must not inherit");
app.on_key_spawn(key(KeyCode::Esc));
}
app.on_key_dashboard(key(KeyCode::Char('n')));
for c in "sleep 5".chars() {
app.on_key_spawn(key(KeyCode::Char(c)));
}
app.on_key_spawn(key(KeyCode::Enter));
app.pump();
let v = app.views.iter().find(|v| v.id == 2).unwrap();
assert_eq!(v.group, None);
}