use super::*;
use crate::{
protocol::{Preview, PreviewSource},
supervisor::Supervisor,
testutil::{Scratch, temp, wait_until},
transport::LocalTransport,
ui::scroll_window,
};
impl App {
fn new_local(rows: u16, cols: u16) -> Self {
Self::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) -> Self {
Self::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()
}
fn attached(rows: u16, cols: u16, cmd: &str) -> (Self, u64) {
let mut app = Self::new_local(rows, cols);
let dir = app.invocation_dir.clone();
app.spawn_in(cmd, dir);
app.pump();
app.resolve_selection();
app.attach();
let id = app.focused_id.expect("attached");
(app, id)
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn shift(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::SHIFT)
}
fn view(id: u64, cwd: PathBuf, tagged: bool, group: Option<&str>) -> TaskView {
TaskView {
id,
command: "true".to_string(),
cwd,
tagged,
group: group.map(str::to_string),
name: None,
lifecycle: Lifecycle::Active,
parked: false,
preview: Preview::floor(String::new()),
started_ago: Duration::ZERO,
quiet_ago: Some(Duration::ZERO),
finished_ago: None,
}
}
#[test]
fn selection_follows_task_across_reorder() {
let mut app = App::new_local(30, 100);
let dir = app.invocation_dir.clone();
app.views = vec![view(1, dir.clone(), false, None), view(2, dir, false, None)];
app.resolve_selection();
assert_eq!(app.selected_id, Some(1));
app.views[1].tagged = true;
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.views = vec![
view(1, inv, false, None), view(2, PathBuf::from("/tmp"), false, None), ];
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 custom_sections_collate_case_insensitively() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "zebra"); app.spawn_grouped("sleep 5", inv.clone(), "API"); app.spawn_grouped("sleep 5", inv.clone(), "Review"); app.spawn_grouped("sleep 5", inv, "api"); app.pump();
app.group_mode = GroupMode::Custom;
assert_eq!(
app.section_ids(),
vec![
("API".to_string(), vec![2]),
("api".to_string(), vec![4]),
("Review".to_string(), vec![3]),
("zebra".to_string(), vec![1]),
],
"folded order, with API and api adjacent and still two sections"
);
}
#[test]
fn dir_sections_collate_case_insensitively() {
let mut app = App::new_local(30, 100);
let base = temp("app_dir_collate");
let (upper, lower) = (base.join("Zed"), base.join("apple"));
std::fs::create_dir_all(&upper).unwrap();
std::fs::create_dir_all(&lower).unwrap();
app.spawn_in("sleep 5", upper.clone()); app.spawn_in("sleep 5", lower.clone()); app.pump();
app.group_mode = GroupMode::Dir;
let (z, a) = (path::abbreviate(&upper), path::abbreviate(&lower));
assert!(z < a, "byte order must put Zed first for this test to bite");
assert_eq!(
app.section_ids(),
vec![(a, vec![2]), (z, vec![1])],
"apple before Zed once the label folds"
);
}
#[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(key(KeyCode::Char('s')));
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"
);
}
#[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_task_holds_its_row() {
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![1, 2])],
"parked id 1 keeps its row above id 2 within alpha"
);
}
#[test]
fn dir_mode_parked_task_holds_its_row() {
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.group_mode = GroupMode::Dir;
let label = app.invocation_label.clone();
assert_eq!(app.section_ids(), vec![(label.clone(), 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![(label, vec![1, 2])],
"parked id 1 keeps its row above id 2 within its directory"
);
}
#[test]
fn parked_round_trip_leaves_row_order_identical() {
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.clone(), "alpha"); app.spawn_grouped("sleep 5", inv, "alpha"); app.pump();
app.group_mode = GroupMode::Custom;
let want = vec![("alpha".to_string(), vec![1, 2, 3])];
assert_eq!(app.section_ids(), want);
let i = app.views.iter().position(|v| v.id == 2).unwrap();
app.views[i].parked = true;
assert_eq!(app.section_ids(), want, "quiet does not move id 2");
app.views[i].parked = false;
assert_eq!(app.section_ids(), want, "waking does not move id 2 back");
}
#[test]
fn custom_mode_finished_sinks_within_group() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("true", inv.clone(), "alpha"); app.spawn_grouped("sleep 30", inv, "alpha"); wait_until(Duration::from_secs(5), || {
app.pump();
app.views
.iter()
.any(|v| v.id == 1 && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed))
});
app.group_mode = GroupMode::Custom;
assert_eq!(
app.section_ids(),
vec![("alpha".to_string(), vec![2, 1])],
"finished id 1 sinks below live id 2 within alpha"
);
}
#[test]
fn dir_mode_finished_sinks_within_section() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("true", inv.clone()); app.spawn_in("sleep 30", inv); wait_until(Duration::from_secs(5), || {
app.pump();
app.views
.iter()
.any(|v| v.id == 1 && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed))
});
app.group_mode = GroupMode::Dir;
assert_eq!(
app.section_ids(),
vec![(app.invocation_label.clone(), vec![2, 1])],
"finished id 1 sinks below live id 2 within its directory"
);
}
#[test]
fn custom_mode_tagged_task_floats_and_holds_while_parked() {
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.transport.send(Command::Tag { id: 2, on: true });
app.pump();
app.group_mode = GroupMode::Custom;
assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![2, 1])]);
let i = app.views.iter().position(|v| v.id == 2).unwrap();
app.views[i].parked = true;
assert_eq!(
app.section_ids(),
vec![("alpha".to_string(), vec![2, 1])],
"tagged id 2 stays at the top of alpha while parked"
);
}
#[test]
fn state_mode_ordering_survives_the_row_key_split() {
let mut app = App::new_local(30, 100);
let base = temp("app_state_order");
let (dir_a, dir_b) = (base.join("apple"), base.join("Zed"));
std::fs::create_dir_all(&dir_a).unwrap();
std::fs::create_dir_all(&dir_b).unwrap();
app.spawn_in("sleep 30", dir_b.clone()); app.spawn_in("sleep 30", dir_a.clone()); app.spawn_in("sleep 30", dir_b.clone()); app.spawn_in("sleep 30", dir_a.clone()); app.spawn_in("sleep 30", dir_b.clone()); app.spawn_in("sleep 30", dir_a.clone()); app.spawn_in("true", dir_b.clone()); app.spawn_in("true", dir_a.clone()); wait_until(Duration::from_secs(5), || {
app.pump();
[7u64, 8].iter().all(|id| {
app.views
.iter()
.any(|v| v.id == *id && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed))
})
});
for id in [5u64, 6] {
app.transport.send(Command::Tag { id, on: true });
}
app.pump();
for (id, parked) in [
(1u64, false),
(2, false),
(3, true),
(4, true),
(5, false),
(6, true),
] {
let i = app.views.iter().position(|v| v.id == id).unwrap();
app.views[i].parked = parked;
}
let (a, b) = (path::abbreviate(&dir_a), path::abbreviate(&dir_b));
assert!(
b < a,
"byte order must put dir b first, or this test cannot see the collation"
);
assert_eq!(
app.section_ids(),
vec![
("In use".to_string(), vec![6, 5]),
("Running".to_string(), vec![2, 1]),
("Idle".to_string(), vec![4, 3]),
("Completed".to_string(), vec![8, 7]),
],
"four sections in state order; within each, dir a before dir b, then id"
);
}
#[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.to_path_buf()); app.spawn_in(
&format!("echo run >> {}", marker.display()),
dir.to_path_buf(),
); 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(key(KeyCode::Char('r')));
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(key(KeyCode::Char('r')));
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"
);
}
fn session_scratch(tag: &str, names: &[&str]) -> Scratch {
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(key(KeyCode::Char('o')));
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"
);
}
#[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(key(KeyCode::Char('o')));
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);
}
fn write_recovery(dir: &Path, stem: &str, label: &str, cmds: &[&str]) {
let rec = dir.join("sessions").join("recovery");
std::fs::create_dir_all(&rec).unwrap();
let list: Vec<String> = cmds.iter().map(|c| format!("{c:?}")).collect();
std::fs::write(
rec.join(format!("{stem}.json")),
format!(
r#"{{"version":1,"name":"{label}","dirs":{{".":[{}]}}}}"#,
list.join(",")
),
)
.unwrap();
}
#[test]
fn sessions_reply_populates_and_clamps_both_lists() {
let dir = session_scratch("rec_lists", &["a"]);
write_recovery(
&dir,
"20260101-000000-1",
"autosaved 2026-01-01 00:00",
&["sleep 5"],
);
write_recovery(
&dir,
"20260102-000000-1",
"autosaved 2026-01-02 00:00",
&["sleep 5"],
);
let mut app = app_with_config_dir(&dir);
app.on_key_dashboard(key(KeyCode::Char('o')));
app.pump();
assert_eq!(app.session_names, vec!["a".to_string()]);
assert_eq!(
app.session_recovery
.iter()
.map(|e| e.stem.as_str())
.collect::<Vec<_>>(),
vec!["20260102-000000-1", "20260101-000000-1"],
"recovery entries list newest first"
);
app.recovery_sel = 1;
std::fs::remove_file(
dir.join("sessions")
.join("recovery")
.join("20260101-000000-1.json"),
)
.unwrap();
app.transport.send(Command::ListSessions);
app.pump();
assert_eq!(app.session_recovery.len(), 1);
assert_eq!(app.recovery_sel, 0, "recovery selection must clamp");
assert_eq!(app.session_sel, 0);
}
#[test]
fn o_key_resets_the_picker_to_the_saved_page() {
let dir = session_scratch("rec_reset", &["a"]);
write_recovery(
&dir,
"20260101-000000-1",
"autosaved 2026-01-01 00:00",
&["sleep 5"],
);
let mut app = app_with_config_dir(&dir);
app.on_key_dashboard(key(KeyCode::Char('o')));
app.pump();
app.on_key_loadsession(key(KeyCode::Tab));
assert_eq!(app.session_page, SessionPage::Recovery);
app.on_key_loadsession(key(KeyCode::Esc));
app.on_key_dashboard(key(KeyCode::Char('o')));
assert_eq!(app.session_page, SessionPage::Saved);
assert_eq!(app.recovery_sel, 0);
assert!(
app.session_recovery.is_empty(),
"the picker opens empty until the reply lands"
);
}
#[test]
fn tab_is_a_no_op_without_recovery_entries() {
let dir = session_scratch("rec_notab", &["a"]);
let mut app = app_with_config_dir(&dir);
app.on_key_dashboard(key(KeyCode::Char('o')));
app.pump();
assert!(app.session_recovery.is_empty());
app.on_key_loadsession(key(KeyCode::Tab));
assert_eq!(app.session_page, SessionPage::Saved);
app.on_key_loadsession(key(KeyCode::BackTab));
assert_eq!(app.session_page, SessionPage::Saved);
}
#[test]
fn tab_toggles_pages_and_selections_stay_independent() {
let dir = session_scratch("rec_tab", &["a", "b"]);
write_recovery(
&dir,
"20260101-000000-1",
"autosaved 2026-01-01 00:00",
&["sleep 5"],
);
write_recovery(
&dir,
"20260102-000000-1",
"autosaved 2026-01-02 00:00",
&["sleep 5"],
);
let mut app = app_with_config_dir(&dir);
app.on_key_dashboard(key(KeyCode::Char('o')));
app.pump();
app.on_key_loadsession(key(KeyCode::Down)); app.on_key_loadsession(key(KeyCode::Tab));
assert_eq!(app.session_page, SessionPage::Recovery);
app.on_key_loadsession(key(KeyCode::Down)); assert_eq!(app.recovery_sel, 1);
app.on_key_loadsession(key(KeyCode::Tab));
assert_eq!(app.session_page, SessionPage::Saved);
assert_eq!(app.session_sel, 1, "the saved selection survives the flip");
app.on_key_loadsession(key(KeyCode::BackTab));
assert_eq!(app.session_page, SessionPage::Recovery);
assert_eq!(app.recovery_sel, 1, "the recovery selection survives too");
}
#[test]
fn emptied_recovery_list_returns_to_the_saved_page() {
let dir = session_scratch("rec_empty", &["a"]);
write_recovery(
&dir,
"20260101-000000-1",
"autosaved 2026-01-01 00:00",
&["sleep 5"],
);
let mut app = app_with_config_dir(&dir);
app.on_key_dashboard(key(KeyCode::Char('o')));
app.pump();
app.on_key_loadsession(key(KeyCode::Tab));
assert_eq!(app.session_page, SessionPage::Recovery);
std::fs::remove_file(
dir.join("sessions")
.join("recovery")
.join("20260101-000000-1.json"),
)
.unwrap();
app.transport.send(Command::ListSessions);
app.pump();
assert!(app.session_recovery.is_empty());
assert_eq!(app.session_page, SessionPage::Saved);
}
#[test]
fn enter_on_the_recovery_page_loads_the_selected_stem() {
let dir = session_scratch("rec_load", &[]);
write_recovery(
&dir,
"20260101-000000-1",
"autosaved 2026-01-01 00:00",
&["sleep 7"],
);
write_recovery(
&dir,
"20260102-000000-1",
"autosaved 2026-01-02 00:00",
&["sleep 9"],
);
let mut app = app_with_config_dir(&dir);
app.on_key_dashboard(key(KeyCode::Char('o')));
app.pump();
app.on_key_loadsession(key(KeyCode::Tab));
app.on_key_loadsession(key(KeyCode::Down));
app.on_key_loadsession(key(KeyCode::Enter));
assert!(
matches!(app.mode, Mode::Dashboard),
"Enter closes the picker"
);
app.pump();
assert_eq!(
app.status.as_deref(),
Some("loaded recovery snapshot; save to name it"),
"the daemon's notice must arrive unedited"
);
assert_eq!(app.views.len(), 1);
assert_eq!(app.views[0].command, "sleep 7");
}
#[test]
fn esc_closes_the_picker_from_the_recovery_page() {
let dir = session_scratch("rec_esc", &[]);
write_recovery(
&dir,
"20260101-000000-1",
"autosaved 2026-01-01 00:00",
&["sleep 5"],
);
let mut app = app_with_config_dir(&dir);
app.on_key_dashboard(key(KeyCode::Char('o')));
app.pump();
app.on_key_loadsession(key(KeyCode::Tab));
assert_eq!(app.session_page, SessionPage::Recovery);
app.on_key_loadsession(key(KeyCode::Esc));
assert!(matches!(app.mode, Mode::Dashboard));
}
#[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 list_dirs_collates_case_insensitively() {
let base = temp("app_list_dirs_collate");
for name in ["Zed", "apple", "Beta", "cider"] {
std::fs::create_dir_all(base.join(name)).unwrap();
}
std::fs::write(base.join("Alpha.txt"), b"x").unwrap();
std::fs::create_dir_all(base.join(".hidden")).unwrap();
assert_eq!(
list_dirs(&base, ""),
vec!["apple", "Beta", "cider", "Zed"],
"byte order would read Beta, Zed, apple, cider"
);
}
fn recents_fixture(tag: &str) -> (App, Scratch) {
let root = temp(tag);
let rust = root.join("Documents/Code/Rust");
for name in ["fleetcom", "Logria", "crabapple", "crabstep"] {
std::fs::create_dir_all(rust.join(name)).unwrap();
}
std::fs::create_dir_all(rust.join("fleetcom/docs")).unwrap();
let mut app = App::new_local(30, 100);
app.invocation_dir = rust.join("fleetcom");
for name in ["crabstep", "crabapple", "Logria"] {
app.spawn_in("sleep 5", rust.join(name));
}
app.pump();
(app, root)
}
fn type_pickdir(app: &mut App, fragment: &str) {
app.on_key_dashboard(key(KeyCode::Char('@')));
for c in fragment.chars() {
app.on_key_pickdir(key(KeyCode::Char(c)));
}
}
fn jump_paths(app: &App) -> Vec<PathBuf> {
app.dir_candidates
.iter()
.filter(|c| c.kind == DirKind::Jump)
.map(|c| c.path.clone())
.collect()
}
#[test]
fn pickdir_fragment_surfaces_a_sibling_recent() {
let (mut app, root) = recents_fixture("pickdir_recent_sibling");
let rust = root.join("Documents/Code/Rust");
type_pickdir(&mut app, "log");
assert_eq!(app.dir_candidates[0].kind, DirKind::Use);
assert_eq!(app.dir_candidates[0].path, app.invocation_dir);
assert_eq!(
jump_paths(&app),
vec![rust.join("Logria")],
"`log` matches the Logria leaf"
);
assert!(
!rust.join("Logria").starts_with(&app.invocation_dir),
"the match must be a sibling, not a subdirectory"
);
assert_eq!(app.dir_sel, 1, "the recent is preselected for Enter");
}
#[test]
fn pickdir_recent_match_folds_case() {
let (mut app, root) = recents_fixture("pickdir_recent_case");
let rust = root.join("Documents/Code/Rust");
type_pickdir(&mut app, "LOG");
assert_eq!(app.dir_candidates[0].kind, DirKind::Use);
assert_eq!(
jump_paths(&app),
vec![rust.join("Logria")],
"an uppercase fragment matches a capitalized name"
);
app.on_key_pickdir(key(KeyCode::Esc));
type_pickdir(&mut app, "ria");
assert_eq!(jump_paths(&app), vec![rust.join("Logria")]);
}
#[test]
fn pickdir_fragment_surfaces_every_matching_recent() {
let (mut app, root) = recents_fixture("pickdir_recent_many");
let rust = root.join("Documents/Code/Rust");
type_pickdir(&mut app, "crab");
assert_eq!(app.dir_candidates[0].kind, DirKind::Use);
assert_eq!(
jump_paths(&app),
vec![rust.join("crabapple"), rust.join("crabstep")],
"recents keep their newest-first order"
);
}
#[test]
fn pickdir_middle_component_matches_no_recent() {
let (mut app, _root) = recents_fixture("pickdir_recent_middle");
type_pickdir(&mut app, "doc");
assert_eq!(app.dir_candidates[0].kind, DirKind::Use);
assert!(
jump_paths(&app).is_empty(),
"a shared parent must not flood the panel"
);
assert_eq!(app.dir_candidates.len(), 2);
assert_eq!(app.dir_candidates[1].kind, DirKind::Into);
assert_eq!(app.dir_candidates[1].label, "docs");
assert_eq!(app.dir_sel, 1, "the subdirectory keeps row 1");
}
#[test]
fn pickdir_slash_suppresses_recents() {
let (mut app, root) = recents_fixture("pickdir_recent_slash");
let rust = root.join("Documents/Code/Rust");
type_pickdir(&mut app, "../");
assert_eq!(app.dir_candidates[0].kind, DirKind::Use);
assert_eq!(app.dir_candidates[0].path, rust);
assert!(jump_paths(&app).is_empty(), "a `/` drops the recents");
assert!(
app.dir_candidates[1..]
.iter()
.all(|c| c.kind == DirKind::Into)
);
assert_eq!(
app.dir_candidates
.iter()
.filter(|c| c.path == rust.join("Logria"))
.count(),
1,
"Logria is listed once, as a subdirectory"
);
for c in "log".chars() {
app.on_key_pickdir(key(KeyCode::Char(c)));
}
assert!(jump_paths(&app).is_empty());
assert_eq!(app.dir_candidates[1].path, rust.join("Logria"));
assert_eq!(app.dir_candidates[1].kind, DirKind::Into);
}
#[test]
fn pickdir_empty_input_lists_every_recent() {
let (mut app, root) = recents_fixture("pickdir_recent_empty");
let rust = root.join("Documents/Code/Rust");
type_pickdir(&mut app, "");
assert_eq!(app.dir_candidates[0].kind, DirKind::Use);
assert_eq!(app.dir_candidates[0].path, app.invocation_dir);
assert_eq!(
jump_paths(&app),
vec![
rust.join("Logria"),
rust.join("crabapple"),
rust.join("crabstep"),
]
);
assert_eq!(app.dir_sel, 0, "an empty field keeps the current dir");
}
#[test]
fn pickdir_dedupes_a_recent_that_is_also_a_subdirectory() {
let (mut app, root) = recents_fixture("pickdir_recent_dedupe");
let docs = root.join("Documents/Code/Rust/fleetcom/docs");
app.spawn_in("sleep 5", docs.clone());
app.pump();
type_pickdir(&mut app, "doc");
assert_eq!(app.dir_candidates[0].kind, DirKind::Use);
assert_eq!(
app.dir_candidates.iter().filter(|c| c.path == docs).count(),
1,
"one row per directory"
);
assert_eq!(app.dir_candidates.len(), 2);
assert_eq!(app.dir_candidates[1].kind, DirKind::Jump);
assert_eq!(app.dir_candidates[1].path, docs);
}
#[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);
}
fn app_with_tagged_pair() -> 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: 2, on: true });
app.transport.send(Command::Tag { id: 4, on: true });
app.pump();
assert_eq!(
app.section_ids(),
vec![
("In use".to_string(), vec![2, 4]),
("Running".to_string(), vec![1, 3]),
],
"tagging reorders: the cycle runs 2 -> 4, then wraps"
);
app
}
fn app_with_tags_split_across_groups() -> App {
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.clone(), "alpha"); app.spawn_grouped("sleep 5", inv.clone(), "beta"); app.spawn_grouped("sleep 5", inv, "beta"); app.pump();
app.group_mode = GroupMode::Custom;
app.transport.send(Command::Tag { id: 1, on: true });
app.transport.send(Command::Tag { id: 3, on: true });
app.pump();
assert_eq!(
app.section_ids(),
vec![
("alpha".to_string(), vec![1, 2]),
("beta".to_string(), vec![3, 4]),
],
"tags head their own groups: display order is 1, 2, 3, 4"
);
app
}
#[test]
fn cycle_tagged_advances_and_wraps() {
let mut app = app_with_tagged_pair();
app.resolve_selection();
assert_eq!(app.selected_id, Some(2), "first row is the first tag");
app.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(app.selected_id, Some(4), "forward to the second tag");
app.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(
app.selected_id,
Some(2),
"past the last tag wraps to the first"
);
}
#[test]
fn cycle_tagged_skips_untagged_tasks() {
let mut app = app_with_tags_split_across_groups();
app.selected_id = Some(1);
app.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(app.selected_id, Some(3), "untagged id 2 is skipped");
app.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(
app.selected_id,
Some(1),
"untagged id 4 is skipped on the wrap"
);
}
#[test]
fn cycle_tagged_is_noop_without_tags() {
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.resolve_selection();
assert_eq!(app.selected_id, Some(1));
app.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(app.selected_id, Some(1), "no tags: the selection stands");
assert!(app.mode == Mode::Dashboard, "no tags: the mode stands");
assert!(app.notice().is_none() && app.status.is_none());
}
#[test]
fn cycle_tagged_from_untagged_selection_jumps_forward() {
let mut app = app_with_tags_split_across_groups();
app.selected_id = Some(2);
app.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(app.selected_id, Some(3), "next tag after the untagged row");
app.selected_id = Some(4);
app.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(app.selected_id, Some(1), "no tag below: wrap to the first");
}
#[test]
fn cycle_tagged_with_one_tag_holds_the_selection() {
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.selected_id, Some(2), "the only tag heads the list");
app.on_key_dashboard(key(KeyCode::Char('M')));
app.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(app.selected_id, Some(2), "selection is held, not cleared");
}
#[test]
fn cycle_tagged_without_selection_takes_the_first_tag() {
let mut app = app_with_tagged_pair();
app.selected_id = None;
app.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(app.selected_id, Some(2), "no selection: first tag in order");
let mut empty = App::new_local(30, 100);
empty.on_key_dashboard(key(KeyCode::Char('M')));
assert_eq!(empty.selected_id, None, "empty fleet: nothing to select");
}
#[test]
fn cycle_tagged_mutates_no_task_state() {
let mut app = app_with_tags_split_across_groups();
app.resolve_selection();
let before: Vec<_> = app
.views
.iter()
.map(|v| (v.id, v.tagged, v.group.clone(), v.lifecycle))
.collect();
for _ in 0..5 {
app.on_key_dashboard(key(KeyCode::Char('M')));
}
app.pump();
let after: Vec<_> = app
.views
.iter()
.map(|v| (v.id, v.tagged, v.group.clone(), v.lifecycle))
.collect();
assert_eq!(before, after, "tags, groups, and lifecycles are untouched");
assert_eq!(
app.section_ids(),
vec![
("alpha".to_string(), vec![1, 2]),
("beta".to_string(), vec![3, 4]),
],
"order is unchanged, so nothing reordered the list"
);
assert!(app.notice().is_none() && app.status.is_none());
assert!(app.mode == Mode::Dashboard);
assert_eq!(app.selected_id, Some(3));
}
#[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_eq!(status, "paste dropped: 8 MiB exceeds the 8 MiB limit");
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, id) = App::attached(30, 100, "sleep 5");
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 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()
);
let (mut app, id) = App::attached(30, 100, &cmd);
app.set_watch(Some((id, true)));
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()
);
}
#[test]
fn scroll_view_entry_and_exit() {
let (mut app, _) = App::attached(30, 100, "sleep 5");
assert!(app.mode == Mode::Attached);
let mut out = io::stdout();
app.on_key_attached(&mut out, shift(KeyCode::PageUp));
assert!(app.view_scroll, "Shift+PageUp must enter the scroll view");
app.on_key_attached(&mut out, key(KeyCode::Esc));
assert!(!app.view_scroll, "Esc must return to live");
app.on_key_attached(&mut out, ctrl(KeyCode::PageUp));
assert!(app.view_scroll, "Ctrl+PageUp is an entry fallback");
app.on_key_attached(&mut out, key(KeyCode::Char('x')));
assert!(!app.view_scroll, "typing must snap back to live");
app.on_key_attached(&mut out, key(KeyCode::PageUp));
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));
}
#[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 { target: 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_candidates_collate_case_insensitively() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "zebra"); app.spawn_grouped("sleep 5", inv.clone(), "API"); app.spawn_grouped("sleep 5", inv.clone(), "Review"); app.spawn_grouped("sleep 5", inv.clone(), "api"); app.spawn_grouped("sleep 5", inv, "api"); app.pump();
app.selected_id = Some(1);
app.on_key_dashboard(key(KeyCode::Char('g')));
let groups: Vec<Option<&str>> = app
.group_candidates
.iter()
.map(|c| c.group.as_deref())
.collect();
assert_eq!(
groups,
vec![
None,
Some("API"),
Some("api"),
Some("Review"),
Some("zebra")
],
"Unassigned pinned first; byte order would read API, Review, api, zebra"
);
}
#[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());
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");
}
fn find_ids(app: &App) -> Vec<u64> {
app.find_candidates.clone()
}
fn find_type(app: &mut App, text: &str) {
for c in text.chars() {
app.on_key_find(key(KeyCode::Char(c)));
}
}
#[test]
fn find_palette_opens_on_slash_only_with_tasks() {
let mut app = App::new_local(30, 100);
app.on_key_dashboard(key(KeyCode::Char('/')));
assert!(app.mode == Mode::Dashboard, "empty fleet: / must no-op");
assert!(app.find_candidates.is_empty());
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.pump();
assert_eq!(app.selected_id, None, "nothing selected yet");
app.on_key_dashboard(key(KeyCode::Char('/')));
assert!(app.mode == Mode::Find);
assert_eq!(find_ids(&app), vec![1]);
}
#[test]
fn find_candidates_follow_display_order() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.views = vec![
view(1, inv.clone(), false, None),
view(2, inv.clone(), false, None),
view(3, inv, true, None),
];
let order: Vec<u64> = app
.display_order()
.into_iter()
.map(|i| app.views[i].id)
.collect();
assert_eq!(order, vec![3, 1, 2], "tag floats id 3 first");
app.on_key_dashboard(key(KeyCode::Char('/')));
assert_eq!(find_ids(&app), order);
assert_eq!(app.find_sel, 0, "the first match is highlighted");
}
#[test]
fn find_empty_input_lists_every_task() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.views = vec![
view(1, inv.clone(), false, None),
view(2, inv, false, Some("alpha")),
];
app.on_key_dashboard(key(KeyCode::Char('/')));
assert_eq!(find_ids(&app), vec![1, 2]);
find_type(&mut app, "alpha");
assert_eq!(find_ids(&app), vec![2]);
for _ in 0.."alpha".len() {
app.on_key_find(key(KeyCode::Backspace));
}
assert_eq!(find_ids(&app), vec![1, 2]);
}
#[test]
fn find_matches_case_insensitive_substrings() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv.clone()); app.spawn_in("true", inv); app.pump();
app.on_key_dashboard(key(KeyCode::Char('/')));
find_type(&mut app, "SLEEP");
assert_eq!(find_ids(&app), vec![1], "case-insensitive");
app.on_key_find(key(KeyCode::Esc));
app.on_key_dashboard(key(KeyCode::Char('/')));
find_type(&mut app, "eep");
assert_eq!(find_ids(&app), vec![1], "matches mid-command, not a prefix");
app.on_key_find(key(KeyCode::Esc));
app.on_key_dashboard(key(KeyCode::Char('/')));
find_type(&mut app, "ru");
assert_eq!(find_ids(&app), vec![2], "matches mid-command of \"true\"");
}
#[test]
fn find_matches_a_named_task_on_both_fields() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv.clone()); app.spawn_in("true", inv); app.transport.send(Command::SetName {
id: 1,
name: Some("api tests".to_string()),
});
app.pump();
app.on_key_dashboard(key(KeyCode::Char('/')));
find_type(&mut app, "api");
assert_eq!(find_ids(&app), vec![1], "matches the name");
app.on_key_find(key(KeyCode::Esc));
app.on_key_dashboard(key(KeyCode::Char('/')));
find_type(&mut app, "sleep");
assert_eq!(find_ids(&app), vec![1], "still matches the command");
}
#[test]
fn find_matches_group_names() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_grouped("sleep 5", inv.clone(), "backend"); app.spawn_in("sleep 5", inv); app.pump();
app.on_key_dashboard(key(KeyCode::Char('/')));
find_type(&mut app, "backend");
assert_eq!(find_ids(&app), vec![1]);
}
#[test]
fn find_does_not_match_the_directory() {
let mut app = App::new_local(30, 100);
let dir = temp("findpalettedir");
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
app.spawn_in("sleep 5", dir.to_path_buf()); app.pump();
assert!(name.contains("findpalettedir"), "scratch dir name: {name}");
app.on_key_dashboard(key(KeyCode::Char('/')));
find_type(&mut app, "findpalettedir");
assert!(
find_ids(&app).is_empty(),
"the directory must not match: {:?}",
find_ids(&app)
);
app.on_key_find(key(KeyCode::Esc));
app.on_key_dashboard(key(KeyCode::Char('/')));
find_type(&mut app, "sleep");
assert_eq!(find_ids(&app), vec![1]);
}
#[test]
fn find_enter_jumps_the_selection() {
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.resolve_selection();
assert_eq!(app.selected_id, Some(1));
app.on_key_dashboard(key(KeyCode::Char('/')));
app.on_key_find(key(KeyCode::Down));
app.on_key_find(key(KeyCode::Down));
assert_eq!(app.find_sel, 2);
app.on_key_find(key(KeyCode::Enter));
assert!(
app.mode == Mode::Dashboard,
"Enter jumps, it never attaches"
);
assert_eq!(app.focused_id, None);
assert_eq!(app.selected_id, Some(3));
assert!(app.find_input.is_empty() && app.find_candidates.is_empty());
}
#[test]
fn find_esc_leaves_the_selection_alone() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv.clone()); app.spawn_in("true", inv); app.pump();
app.resolve_selection();
assert_eq!(app.selected_id, Some(1));
app.on_key_dashboard(key(KeyCode::Char('/')));
find_type(&mut app, "true");
assert_eq!(find_ids(&app), vec![2]);
app.on_key_find(key(KeyCode::Esc));
assert!(app.mode == Mode::Dashboard);
assert_eq!(app.selected_id, Some(1), "Esc must not move the selection");
assert!(app.find_input.is_empty() && app.find_candidates.is_empty());
assert_eq!(app.find_sel, 0);
}
#[test]
fn find_enter_without_candidates_keeps_the_panel_open() {
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('/')));
find_type(&mut app, "zzz");
assert!(find_ids(&app).is_empty());
app.on_key_find(key(KeyCode::Enter));
assert!(app.mode == Mode::Find, "no match: Enter must not close");
assert_eq!(app.selected_id, Some(1), "selection is untouched");
for _ in 0.."zzz".len() {
app.on_key_find(key(KeyCode::Backspace));
}
find_type(&mut app, "sleep");
assert_eq!(find_ids(&app), vec![1]);
}
#[test]
fn find_panel_rows_name_the_task_and_its_section() {
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 tests".to_string()),
});
app.pump();
app.on_key_dashboard(key(KeyCode::Char('/')));
let mut out = Vec::new();
crate::ui::render(&mut out, &mut app).unwrap();
let frame = String::from_utf8_lossy(&out).into_owned();
assert!(frame.contains("✻ api tests · Running"), "{frame:?}");
assert!(frame.contains("enter jump · ↑↓ pick · esc"), "{frame:?}");
find_type(&mut app, "zzz");
app.last_frame.clear();
let mut out = Vec::new();
crate::ui::render(&mut out, &mut app).unwrap();
let frame = String::from_utf8_lossy(&out).into_owned();
assert!(frame.contains("(no matching tasks)"), "{frame:?}");
}
#[test]
fn find_paste_filters_the_candidates() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv.clone()); app.spawn_in("true", inv); app.pump();
app.on_key_dashboard(key(KeyCode::Char('/')));
app.on_paste("true");
assert_eq!(app.find_input.as_str(), "true");
assert_eq!(find_ids(&app), vec![2]);
}
fn painted(app: &mut App) -> String {
app.last_frame.clear(); let mut out = Vec::new();
crate::ui::render(&mut out, app).unwrap();
String::from_utf8_lossy(&out).into_owned()
}
#[test]
fn controls_overlay_opens_on_question_and_closes_on_peeks_key_set() {
let mut app = App::new_local(30, 100);
for close in [KeyCode::Char('?'), KeyCode::Esc, KeyCode::Char('q')] {
app.on_key_dashboard(key(KeyCode::Char('?')));
assert!(app.mode == Mode::Controls, "? must open the overlay");
app.on_key_controls(key(close));
assert!(app.mode == Mode::Dashboard, "{close:?} must close it");
}
}
#[test]
fn controls_overlay_accepts_both_spellings_of_the_chord() {
let mut app = App::new_local(30, 100);
for chord in [key(KeyCode::Char('?')), shift(KeyCode::Char('/'))] {
app.on_key_dashboard(chord);
assert!(
app.mode == Mode::Controls,
"{chord:?} must open the overlay"
);
app.on_key_controls(chord);
assert!(app.mode == Mode::Dashboard, "{chord:?} must close it");
}
}
#[test]
fn plain_slash_still_opens_the_find_palette() {
let mut app = App::new_local(30, 100);
let inv = app.invocation_dir.clone();
app.spawn_in("sleep 5", inv);
app.pump();
app.on_key_dashboard(key(KeyCode::Char('/')));
assert!(app.mode == Mode::Find);
}
#[test]
fn controls_overlay_ignores_dashboard_keys() {
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('?')));
app.on_key_controls(key(KeyCode::Char('m')));
app.on_key_controls(key(KeyCode::Char('n')));
app.pump();
assert!(app.mode == Mode::Controls, "neither key closes the overlay");
assert!(!app.views[0].tagged, "m must not reach the task");
assert!(app.input.as_str().is_empty(), "n must not open the prompt");
app.on_key_controls(key(KeyCode::Esc));
app.on_key_dashboard(key(KeyCode::Char('m')));
app.pump();
assert!(app.views[0].tagged);
}
#[test]
fn controls_overlay_groups_its_entries_when_the_terminal_is_tall() {
let mut app = App::new_local(30, 100);
app.on_key_dashboard(key(KeyCode::Char('?')));
let f = painted(&mut app);
assert!(f.contains("┌─ controls "), "{f:?}");
assert!(f.contains("Navigate"), "{f:?}");
assert!(f.contains("w save session"), "{f:?}");
assert!(f.contains("? esc close"), "{f:?}");
}
#[test]
fn controls_overlay_drops_the_group_headers_before_any_entry() {
let mut app = App::new_local(20, 100);
app.on_key_dashboard(key(KeyCode::Char('?')));
let f = painted(&mut app);
assert!(!f.contains("Navigate"), "the headers go first: {f:?}");
assert!(
f.contains("w save session"),
"no entry is hidden: {f:?}"
);
}
#[test]
fn controls_overlay_reports_clipped_entries_on_its_border() {
let mut app = App::new_local(12, 100);
app.on_key_dashboard(key(KeyCode::Char('?')));
let f = painted(&mut app);
assert!(f.contains("? esc close · +7 more"), "{f:?}");
assert!(!f.contains("save session"), "the tail is clipped: {f:?}");
}
#[test]
fn controls_overlay_names_the_foreground_exit_a_quit() {
let mut app = App::new_local(30, 100);
app.on_key_dashboard(key(KeyCode::Char('?')));
let f = painted(&mut app);
assert!(!app.daemon_backed);
assert!(f.contains("q quit "), "{f:?}");
assert!(!f.contains("detach"), "{f:?}");
app.daemon_backed = true;
let f = painted(&mut app);
assert!(f.contains("q detach"), "{f:?}");
}
#[test]
fn dashboard_hints_defer_the_long_tail_to_the_overlay() {
let mut app = App::new_local(30, 100);
let f = painted(&mut app);
assert!(f.contains(" ❯ n run · @ dir · / find · s sort"), "{f:?}");
assert!(
f.contains(" ↑↓ select · enter attach · space peek · ? controls"),
"{f:?}"
);
assert!(
!f.contains("w save"),
"the tail moved to the overlay: {f:?}"
);
}
#[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");
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(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.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());
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.to_path_buf();
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()
);
}
#[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.to_path_buf();
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"
);
}
#[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);
}
#[test]
fn attached_clipboard_store_emits_the_osc52_envelope() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Attached;
app.focused_id = Some(1);
app.on_clipboard_copy(1, ClipboardKind::Clipboard, "hello".to_string());
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x07");
assert_eq!(app.notice(), Some("copied 5 chars"));
assert!(
app.status.is_none(),
"the copy confirmation is ephemeral; it must not occupy the status"
);
}
#[test]
fn selection_store_emits_its_own_kind_byte() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Attached;
app.focused_id = Some(1);
app.on_clipboard_copy(1, ClipboardKind::Selection, "hello".to_string());
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
assert_eq!(out, b"\x1b]52;s;aGVsbG8=\x07");
}
#[test]
fn primary_store_emits_its_own_kind_byte() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Attached;
app.focused_id = Some(1);
app.on_clipboard_copy(1, ClipboardKind::Primary, "hello".to_string());
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
assert_eq!(out, b"\x1b]52;p;aGVsbG8=\x07");
}
#[test]
fn clipboard_payload_bytes_never_reach_the_terminal_raw() {
let payload = "line1\nline2\x1b[31mred\x1b]52;c;evil\x07";
let mut app = App::new_local(30, 100);
app.mode = Mode::Attached;
app.focused_id = Some(1);
app.on_clipboard_copy(1, ClipboardKind::Clipboard, payload.to_string());
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
assert!(out.starts_with(b"\x1b]52;c;"));
assert!(out.ends_with(b"\x07"));
let body = &out[b"\x1b]52;c;".len()..out.len() - 1];
assert!(
body.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')),
"only base64 may sit between the prefix and the BEL"
);
assert_eq!(B64.decode(body).unwrap(), payload.as_bytes());
assert!(
!out.windows(payload.len()).any(|w| w == payload.as_bytes()),
"the raw payload must not appear in the output"
);
}
#[test]
fn clipboard_stores_outside_attached_mode_are_dropped() {
for mode in [Mode::Peek, Mode::Dashboard] {
let mut app = App::new_local(30, 100);
app.mode = mode;
app.focused_id = Some(1);
app.on_clipboard_copy(1, ClipboardKind::Clipboard, "hello".to_string());
assert!(app.pending_clipboard.is_empty(), "nothing may buffer");
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
assert!(out.is_empty(), "nothing may emit");
assert!(app.notice().is_none(), "no notice without an emission");
}
}
#[test]
fn mismatched_id_clipboard_store_drops_at_receipt() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Attached;
app.focused_id = Some(7);
app.on_clipboard_copy(3, ClipboardKind::Clipboard, "stale".to_string());
assert!(
app.pending_clipboard.is_empty(),
"an in-flight copy from another task must not buffer"
);
app.on_clipboard_copy(7, ClipboardKind::Clipboard, "fresh".to_string());
assert_eq!(
app.pending_clipboard,
vec![(ClipboardKind::Clipboard, "fresh".to_string())]
);
}
#[test]
fn set_watch_resends_on_kind_change_with_the_same_id() {
let dir = temp("app_watch_kind");
let flag = dir.join("flag");
let mut app = App::new_local(30, 100);
let cwd = app.invocation_dir.clone();
let cmd = format!(
"until [ -e {f} ]; do sleep 0.05; done; printf '\\033]52;c;cG9zdA==\\007'; sleep 30",
f = flag.display()
);
app.spawn_in(&cmd, cwd);
app.pump();
let id = app.views[0].id;
app.set_watch(Some((id, false)));
app.set_watch(Some((id, true)));
app.mode = Mode::Attached;
app.focused_id = Some(id);
std::fs::write(&flag, b"").unwrap();
let ok = wait_until(Duration::from_secs(5), || {
app.pump();
!app.pending_clipboard.is_empty()
});
assert!(
ok,
"the post-attach store never forwarded: the kind change never reached the core"
);
assert_eq!(
app.pending_clipboard,
vec![(ClipboardKind::Clipboard, "post".to_string())]
);
}
#[test]
fn pending_stores_emit_in_order_and_notice_counts_last_entry_chars() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Attached;
app.focused_id = Some(1);
app.on_clipboard_copy(1, ClipboardKind::Clipboard, "first".to_string());
app.on_clipboard_copy(1, ClipboardKind::Primary, "second".to_string());
app.on_clipboard_copy(1, ClipboardKind::Selection, "héllo日".to_string());
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
let expected = format!(
"\x1b]52;c;{}\x07\x1b]52;p;{}\x07\x1b]52;s;{}\x07",
B64.encode("first"),
B64.encode("second"),
B64.encode("héllo日")
);
assert_eq!(out, expected.as_bytes());
assert_eq!(app.notice(), Some("copied 6 chars"));
assert!(
app.pending_clipboard.is_empty(),
"the flush drains the buffer"
);
}
#[test]
fn empty_clipboard_flush_writes_nothing() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Attached;
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
assert!(out.is_empty());
assert!(app.notice().is_none());
}
#[test]
fn notice_expires_lazily_after_the_ttl() {
let mut app = App::new_local(30, 100);
app.set_notice("copied 5 chars".to_string(), NoticeLevel::Info);
assert_eq!(app.notice(), Some("copied 5 chars"));
let past = Instant::now()
.checked_sub(NOTICE_TTL)
.expect("system uptime exceeds NOTICE_TTL");
app.notice = Some(("copied 5 chars".to_string(), NoticeLevel::Info, past));
assert_eq!(app.notice(), None, "an aged-out notice must not render");
}
#[test]
fn warning_notice_survives_the_copy_confirmation() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Attached;
app.focused_id = Some(1);
app.set_notice("clipboard copy dropped".to_string(), NoticeLevel::Warning);
app.on_clipboard_copy(1, ClipboardKind::Clipboard, "hello".to_string());
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x07", "the copy must still emit");
assert_eq!(app.notice(), Some("clipboard copy dropped"));
}
#[test]
fn info_notice_replaces_info() {
let mut app = App::new_local(30, 100);
app.set_notice("copied 5 chars".to_string(), NoticeLevel::Info);
app.set_notice("copied 2 chars".to_string(), NoticeLevel::Info);
assert_eq!(app.notice(), Some("copied 2 chars"));
}
#[test]
fn warning_notice_replaces_info() {
let mut app = App::new_local(30, 100);
app.set_notice("copied 5 chars".to_string(), NoticeLevel::Info);
app.set_notice("spawn failed".to_string(), NoticeLevel::Warning);
assert_eq!(app.notice(), Some("spawn failed"));
app.set_notice("recovery failed".to_string(), NoticeLevel::Warning);
assert_eq!(
app.notice(),
Some("recovery failed"),
"warning over warning"
);
}
#[test]
fn expired_warning_yields_to_info() {
let mut app = App::new_local(30, 100);
let past = Instant::now()
.checked_sub(NOTICE_TTL)
.expect("system uptime exceeds NOTICE_TTL");
app.notice = Some(("old warning".to_string(), NoticeLevel::Warning, past));
app.set_notice("copied 5 chars".to_string(), NoticeLevel::Info);
assert_eq!(app.notice(), Some("copied 5 chars"));
}
#[test]
fn attached_status_event_mirrors_into_the_notice() {
let dir = session_scratch("status_mirror", &[]);
let mut app = app_with_config_dir(&dir);
app.mode = Mode::Attached;
app.save_session("mirror");
app.pump();
assert_eq!(app.status.as_deref(), Some("saved 'mirror': 0 command(s)"));
assert_eq!(app.notice(), Some("saved 'mirror': 0 command(s)"));
}
#[test]
fn dashboard_status_event_sets_only_the_status() {
let dir = session_scratch("status_dash", &[]);
let mut app = app_with_config_dir(&dir);
app.save_session("dash");
app.pump();
assert_eq!(app.status.as_deref(), Some("saved 'dash': 0 command(s)"));
assert!(app.notice().is_none(), "no mirror outside attached mode");
}
fn left(kind: MouseEventKind, row: u16, col: u16) -> MouseEvent {
MouseEvent {
kind,
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn press(row: u16, col: u16) -> MouseEvent {
left(MouseEventKind::Down(MouseButton::Left), row, col)
}
fn drag_to(row: u16, col: u16) -> MouseEvent {
left(MouseEventKind::Drag(MouseButton::Left), row, col)
}
fn release(row: u16, col: u16) -> MouseEvent {
left(MouseEventKind::Up(MouseButton::Left), row, col)
}
impl App {
fn attached_with_lines(lines: &[&str]) -> Self {
let (mut app, id) = Self::attached(30, 100, "sleep 5");
let mut lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
lines.resize(app.pane_rows() as usize, String::new());
app.focused_screen = Some(ScreenView {
id,
lines,
formatted: Vec::new(),
cursor: (0, 0),
hide_cursor: false,
wants_mouse: false,
alt_screen: false,
alt_scroll: false,
scrollback: 0,
});
app.mouse_captured = true;
app
}
fn attached_watching(cmd: &str, ready: impl Fn(&ScreenView) -> bool) -> (Self, u64) {
let (mut app, id) = Self::attached(30, 100, cmd);
app.set_watch(Some((id, true)));
assert!(
wait_until(Duration::from_secs(5), || {
app.pump();
app.screen_for(id).is_some_and(&ready)
}),
"the expected screen never reached the client"
);
app.mouse_captured = true;
(app, id)
}
}
#[test]
fn drag_copy_gesture_emits_the_selection_via_osc52() {
let mut app = App::attached_with_lines(&["hello world", "second row"]);
app.on_mouse(press(0, 0));
app.on_mouse(drag_to(0, 4));
app.on_mouse(release(0, 4));
assert_eq!(
app.pending_clipboard,
vec![(ClipboardKind::Clipboard, "hello".to_string())]
);
assert!(app.selection.is_none(), "release must clear the selection");
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x07");
assert_eq!(app.notice(), Some("copied 5 chars"));
}
#[test]
fn drag_copy_spans_rows() {
let mut app = App::attached_with_lines(&["hello world", "second row"]);
app.on_mouse(press(0, 6));
app.on_mouse(drag_to(1, 5));
app.on_mouse(release(1, 5));
assert_eq!(
app.pending_clipboard,
vec![(ClipboardKind::Clipboard, "world\nsecond".to_string())]
);
}
#[test]
fn click_without_drag_copies_nothing() {
let mut app = App::attached_with_lines(&["hello world"]);
app.on_mouse(press(0, 3));
app.on_mouse(release(0, 3));
assert!(app.pending_clipboard.is_empty());
assert!(app.selection.is_none());
let mut out = Vec::new();
app.flush_clipboard(&mut out).unwrap();
assert!(out.is_empty());
assert_eq!(app.notice(), None);
}
#[test]
fn all_whitespace_selection_copies_nothing() {
let mut app = App::attached_with_lines(&["abc ", " "]);
app.on_mouse(press(0, 5));
app.on_mouse(drag_to(1, 8));
app.on_mouse(release(1, 8));
assert!(app.pending_clipboard.is_empty());
assert!(app.selection.is_none());
}
#[test]
fn bar_row_press_starts_no_selection() {
let mut app = App::attached_with_lines(&["hello world"]);
let bar = app.rows - 1;
app.on_mouse(press(bar, 3));
assert!(app.selection.is_none(), "the bar row is not selectable");
app.on_mouse(drag_to(bar, 8));
app.on_mouse(release(bar, 8));
assert!(app.pending_clipboard.is_empty());
}
#[test]
fn wheel_cancels_a_live_drag_and_keeps_its_function() {
let mut app = App::attached_with_lines(&["hello world"]);
app.on_mouse(press(0, 0));
app.on_mouse(drag_to(0, 4));
assert!(app.selection.is_some());
app.on_mouse(left(MouseEventKind::ScrollUp, 0, 0));
assert!(app.selection.is_none(), "wheel must drop the drag");
assert!(app.view_scroll, "wheel-up must still enter scrollback");
assert!(app.pending_clipboard.is_empty(), "a cancel is not a copy");
}
#[test]
fn coordinate_invalidation_clears_the_selection() {
let start = |app: &mut App| {
app.on_mouse(press(0, 0));
app.on_mouse(drag_to(0, 4));
assert!(app.selection.is_some(), "the drag must be live");
};
let mut out = io::stdout();
let mut app = App::attached_with_lines(&["hello world"]);
start(&mut app);
app.on_resize(31, 101);
assert!(app.selection.is_none(), "resize must clear");
let mut app = App::attached_with_lines(&["hello world"]);
start(&mut app);
app.on_key_attached(&mut out, ctrl(KeyCode::Char('\\')));
assert!(app.mode == Mode::Dashboard, "ctrl-\\ detaches");
assert!(app.selection.is_none(), "detach must clear");
let mut app = App::attached_with_lines(&["hello world"]);
start(&mut app);
app.on_key_attached(&mut out, shift(KeyCode::PageUp));
assert!(app.view_scroll);
assert!(app.selection.is_none(), "scrollback entry must clear");
let mut app = App::attached_with_lines(&["hello world"]);
let id = app.focused_id.expect("attached");
app.set_watch(Some((id, true)));
start(&mut app);
app.set_watch(None);
assert!(app.selection.is_none(), "a watch change must clear");
}
#[test]
fn attached_screen_lines_cover_every_pane_row() {
let (app, id) = App::attached_watching("printf 'top'; sleep 5", |s| {
s.lines.first().is_some_and(|l| l == "top")
});
let lines = &app.screen_for(id).unwrap().lines;
assert_eq!(lines.len(), 29, "one entry per pane row");
assert_eq!(lines[28], "", "the blank bottom row keeps its slot");
}
#[test]
fn bottom_row_drag_does_not_alias_onto_the_penultimate_row() {
let (mut app, _) = App::attached_watching("printf '\\033[28;1Hbottomtext'; sleep 5", |s| {
s.lines.iter().any(|l| l == "bottomtext")
});
app.on_mouse(press(27, 15));
app.on_mouse(drag_to(28, 0));
app.on_mouse(release(28, 0));
assert!(
app.pending_clipboard.is_empty(),
"a drag over blank cells must copy nothing, got {:?}",
app.pending_clipboard
);
}
#[test]
fn mid_drag_wants_mouse_flip_drops_the_selection() {
let mut app = App::attached_with_lines(&["hello world"]);
app.on_mouse(press(0, 0));
app.on_mouse(drag_to(0, 4));
assert!(app.selection.is_some());
let mut flipped = app.focused_screen.clone().expect("screen installed");
flipped.wants_mouse = true;
app.focused_screen = Some(flipped);
app.on_mouse(drag_to(0, 6));
assert!(app.selection.is_none(), "the flip must drop the selection");
app.on_mouse(release(0, 6));
assert!(app.selection.is_none());
assert!(
app.pending_clipboard.is_empty(),
"a canceled drag is not a copy"
);
}
#[test]
fn mid_drag_mouse_enable_reroutes_the_gesture_to_the_child() {
let dir = temp("app_drag_flip");
let out_file = dir.join("bytes");
let cmd = format!(
"stty -icanon -echo min 1 time 0; head -c 1 >/dev/null; \
printf '\\033[?1002h\\033[?1006h'; head -c 19 > {}",
out_file.display()
);
let (mut app, id) = App::attached_watching(&cmd, |s| !s.wants_mouse);
app.on_mouse(press(1, 2));
assert!(app.selection.is_some(), "the press must start a drag");
app.transport.send(Command::Input {
id,
bytes: b"\n".to_vec(),
});
assert!(
wait_until(Duration::from_secs(5), || {
app.pump();
matches!(app.screen_for(id), Some(s) if s.wants_mouse)
}),
"the mouse-mode flip never reached the client"
);
app.on_mouse(drag_to(1, 5));
assert!(app.selection.is_none(), "the flip must drop the selection");
app.on_mouse(release(1, 5));
assert!(
app.pending_clipboard.is_empty(),
"a canceled drag is not a copy"
);
let mut got = Vec::new();
wait_until(Duration::from_secs(5), || {
got = std::fs::read(&out_file).unwrap_or_default();
got.len() >= 19
});
assert_eq!(got, b"\x1b[<32;6;2M\x1b[<0;6;2m".to_vec());
}
#[test]
fn selection_over_concealed_text_copies_nothing() {
let (mut app, id) = App::attached_watching(
"printf 'ok\\r\\n\\033[8mTOPSECRET\\033[28mZ'; sleep 5",
|s| s.lines.get(1).is_some_and(|l| l.ends_with('Z')),
);
assert_eq!(
app.screen_for(id).unwrap().lines[1],
" Z",
"concealed cells must read as spaces"
);
app.on_mouse(press(1, 0));
app.on_mouse(drag_to(1, 8));
app.on_mouse(release(1, 8));
assert!(
app.pending_clipboard.is_empty(),
"concealed text must not copy, got {:?}",
app.pending_clipboard
);
}
#[test]
fn release_without_drag_copies_the_flick_span() {
let mut app = App::attached_with_lines(&["hello world", "second row"]);
app.on_mouse(press(0, 0));
app.on_mouse(release(0, 4));
assert_eq!(
app.pending_clipboard,
vec![(ClipboardKind::Clipboard, "hello".to_string())]
);
assert!(app.selection.is_none(), "release must clear the selection");
}
#[test]
fn release_past_the_view_clamps_like_a_drag() {
let mut app = App::attached_with_lines(&["hello world", "second row"]);
app.on_mouse(press(0, 6));
app.on_mouse(drag_to(0, 8));
app.on_mouse(release(u16::MAX, u16::MAX));
assert_eq!(
app.pending_clipboard,
vec![(ClipboardKind::Clipboard, "world\nsecond row".to_string())]
);
}
#[test]
fn capture_drop_clears_a_live_selection() {
let mut app = App::attached_with_lines(&["alpha beta", "gamma"]);
app.mouse_captured = true;
app.on_mouse(press(0, 2));
app.on_mouse(drag_to(0, 6));
assert!(app.selection().is_some(), "premise: a drag is live");
if let Some(s) = app.focused_screen.as_mut() {
s.alt_screen = true;
s.alt_scroll = true;
}
app.sync_input_modes(&mut std::io::stdout()).unwrap();
assert!(!app.mouse_captured, "premise: capture dropped");
assert!(app.selection().is_none(), "the drop must clear the drag");
assert!(app.pending_clipboard.is_empty(), "nothing may copy");
}
#[test]
fn press_after_capture_drop_starts_no_selection() {
let mut app = App::attached_with_lines(&["alpha beta", "gamma"]);
app.mouse_captured = false;
app.on_mouse(press(0, 2));
assert!(app.selection().is_none(), "no capture, no gesture to come");
app.on_mouse(drag_to(0, 6));
app.on_mouse(release(0, 6));
assert!(app.pending_clipboard.is_empty(), "nothing may copy");
}
#[test]
fn press_on_a_stale_geometry_screen_starts_no_selection() {
let mut app = App::attached_with_lines(&["hello world"]);
app.on_resize(40, 100);
app.on_mouse(press(0, 0));
assert!(app.selection().is_none(), "stale geometry must not select");
let rows = app.pane_rows() as usize;
if let Some(s) = app.focused_screen.as_mut() {
s.lines.resize(rows, String::new());
}
app.on_mouse(press(0, 0));
assert!(app.selection().is_some(), "a fresh frame selects again");
}
#[test]
fn one_row_terminal_has_no_selectable_pane() {
let (mut app, id) = App::attached(1, 80, "sleep 5");
app.mouse_captured = true;
app.focused_screen = Some(ScreenView {
id,
lines: vec!["hidden".to_string()],
formatted: Vec::new(),
cursor: (0, 0),
hide_cursor: false,
wants_mouse: false,
alt_screen: false,
alt_scroll: false,
scrollback: 0,
});
app.on_mouse(press(0, 0));
assert!(app.selection().is_none(), "the bar row is not selectable");
app.on_mouse(drag_to(0, 5));
app.on_mouse(release(0, 5));
assert!(app.pending_clipboard.is_empty(), "nothing may copy");
}
impl App {
fn enter_scrollback(&mut self, offset: usize) {
self.view_scroll = true;
if let Some(s) = self.focused_screen.as_mut() {
s.scrollback = offset;
}
}
}
#[test]
fn scrollback_drag_copies_the_displayed_history_rows() {
let mut app = App::attached_with_lines(&["old line one", "old line two"]);
app.enter_scrollback(5);
app.on_mouse(press(0, 4));
app.on_mouse(drag_to(1, 7));
app.on_mouse(release(1, 7));
assert_eq!(
app.pending_clipboard,
vec![(ClipboardKind::Clipboard, "line one\nold line".to_string())]
);
assert!(app.selection.is_none(), "release must clear the selection");
}
#[test]
fn scrollback_wheel_cancels_the_drag() {
let mut app = App::attached_with_lines(&["old line one"]);
app.enter_scrollback(5);
app.on_mouse(press(0, 0));
app.on_mouse(drag_to(0, 4));
assert!(app.selection.is_some(), "premise: a drag is live");
app.on_mouse(left(MouseEventKind::ScrollUp, 0, 0));
assert!(app.selection.is_none(), "wheel must drop the drag");
assert!(app.view_scroll, "the view stays in scrollback");
app.on_mouse(release(0, 4));
assert!(app.pending_clipboard.is_empty(), "a cancel is not a copy");
}
#[test]
fn scrollback_keys_clear_the_drag() {
let mut out = io::stdout();
let mut app = App::attached_with_lines(&["old line one"]);
app.enter_scrollback(5);
app.on_mouse(press(0, 0));
app.on_mouse(drag_to(0, 4));
assert!(app.selection.is_some(), "premise: a drag is live");
app.on_key_attached(&mut out, key(KeyCode::PageUp));
assert!(app.selection.is_none(), "navigation must drop the drag");
app.on_mouse(press(0, 0));
app.on_mouse(drag_to(0, 4));
app.on_key_attached(&mut out, key(KeyCode::Esc));
assert!(!app.view_scroll, "Esc exits to live");
assert!(app.selection.is_none(), "the exit must drop the drag");
}
#[test]
fn live_return_frame_clears_a_scrollback_drag() {
let mut app = App::attached_with_lines(&["old line one"]);
app.enter_scrollback(5);
app.on_mouse(press(0, 0));
app.on_mouse(drag_to(0, 4));
assert!(app.selection.is_some(), "premise: a drag is live");
let mut live = app.focused_screen.clone().expect("screen");
live.scrollback = 0;
app.on_screen(live);
assert!(!app.view_scroll, "a live frame exits the view");
assert!(app.selection.is_none(), "the exit must drop the drag");
}
#[test]
fn wants_mouse_child_keeps_the_left_button() {
let dir = temp("app_drag_fwd");
let out_file = dir.join("bytes");
let cmd = format!(
"stty -icanon -echo min 1 time 0; printf '\\033[?1002h\\033[?1006h'; head -c 28 > {}",
out_file.display()
);
let (mut app, id) = App::attached(30, 100, &cmd);
app.set_watch(Some((id, true)));
assert!(
wait_until(Duration::from_secs(5), || {
app.pump();
matches!(app.screen_for(id), Some(s) if s.wants_mouse)
}),
"mouse mode never reached the client"
);
app.on_mouse(press(1, 2));
assert!(app.selection.is_none(), "a wants_mouse press must forward");
app.on_mouse(drag_to(1, 5));
assert!(app.selection.is_none(), "a wants_mouse drag must forward");
app.on_mouse(release(1, 5));
assert!(app.selection.is_none());
assert!(app.pending_clipboard.is_empty(), "nothing may copy");
let mut got = Vec::new();
wait_until(Duration::from_secs(5), || {
got = std::fs::read(&out_file).unwrap_or_default();
got.len() >= 28
});
assert_eq!(got, b"\x1b[<0;3;2M\x1b[<32;6;2M\x1b[<0;6;2m".to_vec());
}
#[test]
fn frame_is_wrapped_in_one_synchronized_update() {
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();
for (label, mode) in [
("dashboard", Mode::Dashboard),
("peek", Mode::Peek),
("attached", Mode::Attached),
] {
app.mode = mode;
app.focused_id = app.selected_id;
app.last_frame.clear(); let mut out = Vec::new();
crate::ui::render(&mut out, &mut app).unwrap();
assert!(!out.is_empty(), "{label} must paint");
assert!(
out.starts_with(b"\x1b[?2026h") && out.ends_with(b"\x1b[?2026l"),
"the {label} frame must open and close one synchronized update"
);
assert_eq!(
out.windows(8).filter(|w| *w == b"\x1b[?2026h").count(),
1,
"{label} must not nest updates"
);
}
}
#[test]
fn unchanged_frame_emits_nothing() {
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.mode = Mode::Peek;
let mut first = Vec::new();
assert!(
crate::ui::render(&mut first, &mut app).unwrap(),
"the first frame paints"
);
assert!(!first.is_empty());
let mut second = Vec::new();
assert!(
!crate::ui::render(&mut second, &mut app).unwrap(),
"an identical frame reports no paint"
);
assert!(second.is_empty(), "an identical frame is a no-op");
}
#[test]
fn repaint_floor_gates_core_driven_frames() {
assert!(!paint_due(false, false, Duration::from_millis(10)));
assert_eq!(
wait_for_paint(false, Duration::from_millis(10)),
Duration::from_millis(23)
);
assert!(paint_due(false, false, PAINT_MIN));
assert_eq!(wait_for_paint(true, PAINT_MIN), WAIT_MAX);
}
#[test]
fn input_and_attached_echo_bypass_the_repaint_floor() {
assert!(
paint_due(false, true, Duration::ZERO),
"a handled event paints"
);
assert!(
paint_due(true, false, Duration::ZERO),
"attached echo paints"
);
assert_eq!(wait_for_paint(false, Duration::ZERO), PAINT_MIN);
}
#[path = "app_readme_tests.rs"]
mod readme;