use super::*;
#[test]
fn colon_write_blocked_by_disk_state_guard_without_bang() {
let path = std::env::temp_dir().join("hjkl_write_no_bang_guard.txt");
std::fs::write(&path, "original\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().disk_state = DiskState::ChangedOnDisk;
app.active_mut().dirty = true;
seed_buffer(&mut app, "edited\n");
app.dispatch_ex("write");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("E13"),
"expected E13 guard message, got: {msg}"
);
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(
on_disk, "original\n",
"disk must be unchanged after blocked :w"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn colon_write_bang_overrides_disk_state_guard() {
let path = std::env::temp_dir().join("hjkl_write_bang_guard.txt");
std::fs::write(&path, "original\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().disk_state = DiskState::ChangedOnDisk;
app.active_mut().dirty = true;
seed_buffer(&mut app, "edited\n");
app.dispatch_ex("write!");
let msg = app.bus.last_body_or_empty().to_string();
assert!(!msg.contains("E13"), ":w! must not produce E13, got: {msg}");
assert_eq!(
app.active().disk_state,
DiskState::Synced,
"disk_state must be Synced after :w!"
);
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
on_disk.contains("edited"),
"disk must have new content after :w!"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn edit_percent_reloads_current_file() {
let path = std::env::temp_dir().join("hjkl_edit_percent_reload.txt");
std::fs::write(&path, "first\nsecond\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
std::fs::write(&path, "alpha\nbeta\ngamma\n").unwrap();
app.dispatch_ex("e %");
let lines = app
.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(lines, vec!["alpha", "beta", "gamma"]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn edit_no_arg_reloads_current_file() {
let path = std::env::temp_dir().join("hjkl_edit_noarg_reload.txt");
std::fs::write(&path, "v1\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
std::fs::write(&path, "v2\n").unwrap();
app.dispatch_ex("e");
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>(),
vec!["v2".to_string()]
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn edit_blocks_dirty_buffer_without_force() {
let path = std::env::temp_dir().join("hjkl_edit_dirty_block.txt");
std::fs::write(&path, "orig\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().dirty = true;
app.dispatch_ex("e %");
let msg = app.bus.last_body_or_empty().to_string();
assert!(msg.contains("E37"), "expected E37, got: {msg}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn edit_force_reloads_dirty_buffer() {
let path = std::env::temp_dir().join("hjkl_edit_force.txt");
std::fs::write(&path, "disk\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().dirty = true;
app.dispatch_ex("e!");
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>(),
vec!["disk".to_string()]
);
assert!(!app.active().dirty);
let _ = std::fs::remove_file(&path);
}
#[test]
fn undo_to_saved_state_clears_dirty() {
let path = std::env::temp_dir().join("hjkl_undo_clears_dirty.txt");
std::fs::write(&path, "alpha\nbravo\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
assert!(!app.active().dirty);
hjkl_vim_tui::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
hjkl_vim_tui::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('X')));
if app.active_mut().editor.take_dirty() {
app.active_mut().refresh_dirty_against_saved();
}
assert!(app.active().dirty, "edit should mark dirty");
hjkl_vim_tui::handle_key(&mut app.active_mut().editor, key(KeyCode::Esc));
hjkl_vim_tui::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('u')));
if app.active_mut().editor.take_dirty() {
app.active_mut().refresh_dirty_against_saved();
}
assert!(
!app.active().dirty,
"undo to saved state should clear dirty"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn edit_new_path_appends_slot_and_switches() {
let path_a = std::env::temp_dir().join("hjkl_phc_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phc_b.txt");
std::fs::write(&path_a, "alpha\n").unwrap();
std::fs::write(&path_b, "beta\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
assert_eq!(app.slots.len(), 1);
app.dispatch_ex(&format!("e {}", path_b.display()));
assert_eq!(app.slots.len(), 2);
assert_eq!(app.active_index(), 1);
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>(),
vec!["beta".to_string()]
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn edit_existing_path_switches_to_open_slot() {
let path_a = std::env::temp_dir().join("hjkl_phc_switch_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phc_switch_b.txt");
std::fs::write(&path_a, "alpha\n").unwrap();
std::fs::write(&path_b, "beta\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
assert_eq!(app.active_index(), 1);
app.dispatch_ex(&format!("e {}", path_a.display()));
assert_eq!(app.slots.len(), 2);
assert_eq!(app.active_index(), 0);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn edit_other_open_path_does_not_block_on_dirty() {
let path_a = std::env::temp_dir().join("hjkl_phc_dirty_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phc_dirty_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.active_mut().dirty = true;
app.dispatch_ex(&format!("e {}", path_b.display()));
assert_eq!(app.active_index(), 1);
assert!(app.slots[0].dirty, "slot 0 should remain dirty");
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn bnext_bprev_cycle_active() {
let path_a = std::env::temp_dir().join("hjkl_phc_cycle_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phc_cycle_b.txt");
let path_c = std::env::temp_dir().join("hjkl_phc_cycle_c.txt");
for p in [&path_a, &path_b, &path_c] {
std::fs::write(p, "x\n").unwrap();
}
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.dispatch_ex(&format!("e {}", path_c.display()));
assert_eq!(app.active_index(), 2);
app.dispatch_ex("bn");
assert_eq!(app.active_index(), 0, "wrap forward to 0");
app.dispatch_ex("bn");
assert_eq!(app.active_index(), 1);
app.dispatch_ex("bp");
assert_eq!(app.active_index(), 0);
app.dispatch_ex("bp");
assert_eq!(app.active_index(), 2, "wrap backward to last");
for p in [&path_a, &path_b, &path_c] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn bnext_no_op_with_single_slot() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("bn");
assert_eq!(app.active_index(), 0);
assert_eq!(app.slots.len(), 1);
}
#[test]
fn bdelete_blocks_dirty_without_force() {
let path_a = std::env::temp_dir().join("hjkl_phc_bd_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phc_bd_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.active_mut().dirty = true;
app.dispatch_ex("bd");
let msg = app.bus.last_body_or_empty().to_string();
assert!(msg.contains("E89"), "expected E89, got: {msg}");
assert_eq!(app.slots.len(), 2);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn bdelete_force_removes_dirty_slot() {
let path_a = std::env::temp_dir().join("hjkl_phc_bdforce_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phc_bdforce_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.active_mut().dirty = true;
app.dispatch_ex("bd!");
assert_eq!(app.slots.len(), 1);
assert_eq!(app.active_index(), 0);
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>(),
vec!["a".to_string()]
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn bdelete_removes_closed_buffers_swap() {
let path_a = std::env::temp_dir().join("hjkl_bd_swap_a.txt");
let path_b = std::env::temp_dir().join("hjkl_bd_swap_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
let swap_b = std::env::temp_dir().join("hjkl_bd_swap_b.swp");
std::fs::write(&swap_b, b"x").unwrap();
app.active_mut().swap_path = Some(swap_b.clone());
assert!(swap_b.exists(), "swap must exist before bdelete");
app.dispatch_ex("bd");
assert_eq!(app.slots.len(), 1, "one slot should remain after bdelete");
assert!(
!swap_b.exists(),
"closed buffer's swap must be removed by bdelete"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
let _ = std::fs::remove_file(&swap_b);
}
#[test]
fn bdelete_on_last_slot_resets_to_no_name() {
let path = std::env::temp_dir().join("hjkl_phc_bd_last.txt");
std::fs::write(&path, "content\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.dispatch_ex("bd");
assert_eq!(app.slots.len(), 1);
assert!(app.active().filename.is_none());
let lines = app
.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert!(
lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()),
"expected empty scratch buffer, got: {lines:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn bwipeout_clears_marks_on_last_slot() {
let path = std::env::temp_dir().join("hjkl_bwipeout_marks_last.txt");
std::fs::write(&path, "hello\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().editor.set_mark('a', (0, 0));
assert!(
app.active().editor.mark('a').is_some(),
"mark should be set before wipe"
);
app.dispatch_ex("bw");
assert!(
app.active().editor.mark('a').is_none(),
":bwipeout on last slot must not carry marks into scratch buffer"
);
assert_eq!(app.slots.len(), 1);
assert!(app.active().filename.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn bwipeout_clears_jumplist_on_last_slot() {
let path = std::env::temp_dir().join("hjkl_bwipeout_jumps_last.txt");
std::fs::write(&path, "line1\nline2\nline3\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().editor.record_jump((2, 0));
let (back, _) = app.active().editor.jump_list();
assert!(
!back.is_empty(),
"jumplist should have an entry before wipe"
);
app.dispatch_ex("bw");
let (back, fwd) = app.active().editor.jump_list();
assert!(
back.is_empty() && fwd.is_empty(),
":bwipeout on last slot must not carry jumps into scratch buffer"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn bdelete_does_not_explicitly_wipe_marks_path() {
let path_a = std::env::temp_dir().join("hjkl_bdelete_path_a.txt");
let path_b = std::env::temp_dir().join("hjkl_bdelete_path_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
assert_eq!(app.slots.len(), 2);
app.active_mut().editor.set_mark('z', (0, 0));
app.dispatch_ex("bd");
assert_eq!(app.slots.len(), 1);
assert!(
app.active().editor.mark('z').is_none(),
"mark from removed slot must not survive after :bd"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn bwipe_removes_closed_buffers_swap() {
let path_a = std::env::temp_dir().join("hjkl_bw_swap_a.txt");
let path_b = std::env::temp_dir().join("hjkl_bw_swap_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
let swap_b = std::env::temp_dir().join("hjkl_bw_swap_b.swp");
std::fs::write(&swap_b, b"x").unwrap();
app.active_mut().swap_path = Some(swap_b.clone());
assert!(swap_b.exists(), "swap must exist before bwipe");
app.dispatch_ex("bw");
assert_eq!(app.slots.len(), 1, "one slot should remain after bwipe");
assert!(
!swap_b.exists(),
"closed buffer's swap must be removed by bwipe"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
let _ = std::fs::remove_file(&swap_b);
}
#[test]
fn bwipeout_multi_slot_removes_slot() {
let path_a = std::env::temp_dir().join("hjkl_bwipeout_multi_a.txt");
let path_b = std::env::temp_dir().join("hjkl_bwipeout_multi_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
assert_eq!(app.slots.len(), 2);
app.dispatch_ex("bw");
assert_eq!(app.slots.len(), 1);
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("wiped"),
"expected wipe status message, got: {msg}"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn bwipeout_blocks_dirty_without_force() {
let path_a = std::env::temp_dir().join("hjkl_bwipeout_dirty_a.txt");
let path_b = std::env::temp_dir().join("hjkl_bwipeout_dirty_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.active_mut().dirty = true;
app.dispatch_ex("bw");
let msg = app.bus.last_body_or_empty().to_string();
assert!(msg.contains("E89"), "expected E89, got: {msg}");
assert_eq!(
app.slots.len(),
2,
"slot must not be removed when dirty without force"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn buffer_alt_swaps_with_prev_active() {
let path_a = std::env::temp_dir().join("hjkl_d2_alt_a.txt");
let path_b = std::env::temp_dir().join("hjkl_d2_alt_b.txt");
let path_c = std::env::temp_dir().join("hjkl_d2_alt_c.txt");
for p in [&path_a, &path_b, &path_c] {
std::fs::write(p, "x\n").unwrap();
}
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display())); app.dispatch_ex(&format!("e {}", path_c.display())); assert_eq!(app.active_index(), 2);
assert_eq!(app.prev_active, Some(1));
app.buffer_alt();
assert_eq!(app.active_index(), 1);
assert_eq!(app.prev_active, Some(2));
app.buffer_alt();
assert_eq!(app.active_index(), 2);
for p in [&path_a, &path_b, &path_c] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn buffer_alt_with_single_slot_no_op_with_message() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.slots.len(), 1);
app.buffer_alt();
assert_eq!(app.active_index(), 0);
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("only one buffer"),
"expected 'only one buffer' message, got: {msg}"
);
}
#[test]
fn bd_clears_prev_active() {
let path_a = std::env::temp_dir().join("hjkl_d2_bd_a.txt");
let path_b = std::env::temp_dir().join("hjkl_d2_bd_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display())); assert_eq!(app.prev_active, Some(0));
app.dispatch_ex("bd!");
assert!(
app.prev_active.is_none(),
"prev_active should be None after bd!"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn b_num_switches_by_index() {
let path_a = std::env::temp_dir().join("hjkl_phe_bnum_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phe_bnum_b.txt");
let path_c = std::env::temp_dir().join("hjkl_phe_bnum_c.txt");
for p in [&path_a, &path_b, &path_c] {
std::fs::write(p, "x\n").unwrap();
}
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.dispatch_ex(&format!("e {}", path_c.display()));
assert_eq!(app.slots.len(), 3);
app.dispatch_ex("b 2");
assert_eq!(app.active_index(), 1, "`:b 2` should switch to index 1");
for p in [&path_a, &path_b, &path_c] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn b_num_out_of_range_errors() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.slots.len(), 1);
app.dispatch_ex("b 5");
let msg = app.bus.last_body_or_empty().to_string();
assert!(msg.contains("E86"), "expected E86, got: {msg}");
}
#[test]
fn b_name_substring_switches() {
let path_foo = std::env::temp_dir().join("hjkl_phe_bname_foo.txt");
let path_bar = std::env::temp_dir().join("hjkl_phe_bname_bar.txt");
std::fs::write(&path_foo, "foo\n").unwrap();
std::fs::write(&path_bar, "bar\n").unwrap();
let mut app = App::new(Some(path_foo.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_bar.display()));
assert_eq!(app.active_index(), 1);
app.dispatch_ex("b foo");
assert_eq!(
app.active_index(),
0,
"`:b foo` should switch to foo's slot"
);
let _ = std::fs::remove_file(&path_foo);
let _ = std::fs::remove_file(&path_bar);
}
#[test]
fn b_name_ambiguous_errors() {
let path_a = std::env::temp_dir().join("hjkl_phe_bamb_foo_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phe_bamb_foo_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.dispatch_ex("b foo");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("E93"),
"expected E93 ambiguous error, got: {msg}"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn bfirst_blast_jump_to_ends() {
let path_a = std::env::temp_dir().join("hjkl_phe_bfl_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phe_bfl_b.txt");
let path_c = std::env::temp_dir().join("hjkl_phe_bfl_c.txt");
for p in [&path_a, &path_b, &path_c] {
std::fs::write(p, "x\n").unwrap();
}
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.dispatch_ex(&format!("e {}", path_c.display()));
assert_eq!(app.slots.len(), 3);
app.dispatch_ex("b 2");
assert_eq!(app.active_index(), 1);
app.dispatch_ex("bfirst");
assert_eq!(app.active_index(), 0, "`:bfirst` should go to slot 0");
app.dispatch_ex("blast");
assert_eq!(app.active_index(), 2, "`:blast` should go to last slot");
for p in [&path_a, &path_b, &path_c] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn wa_writes_dirty_named_slots() {
let path_a = std::env::temp_dir().join("hjkl_phe_wa_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phe_wa_b.txt");
std::fs::write(&path_a, "original a\n").unwrap();
std::fs::write(&path_b, "original b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.slots[0].dirty = true;
BufferEdit::replace_all(app.slots[0].editor.buffer_mut(), "edited a");
app.slots[1].dirty = true;
BufferEdit::replace_all(app.slots[1].editor.buffer_mut(), "edited b");
app.dispatch_ex("wa");
assert!(!app.slots[0].dirty, "slot 0 should be clean after :wa");
assert!(!app.slots[1].dirty, "slot 1 should be clean after :wa");
let contents_a = std::fs::read_to_string(&path_a).unwrap_or_default();
let contents_b = std::fs::read_to_string(&path_b).unwrap_or_default();
assert!(
contents_a.contains("edited a"),
"file a should contain edited content, got: {contents_a}"
);
assert!(
contents_b.contains("edited b"),
"file b should contain edited content, got: {contents_b}"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn qa_blocks_when_any_slot_dirty() {
let path_a = std::env::temp_dir().join("hjkl_phe_qa_dirty_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phe_qa_dirty_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.slots[0].dirty = true;
app.dispatch_ex("qa");
assert!(
!app.exit_requested,
":qa should not exit when dirty slot exists"
);
let msg = app.bus.last_body_or_empty().to_string();
assert!(msg.contains("E37"), "expected E37, got: {msg}");
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn qa_force_exits_with_dirty() {
let path_a = std::env::temp_dir().join("hjkl_phe_qa_force_a.txt");
std::fs::write(&path_a, "a\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.slots[0].dirty = true;
app.dispatch_ex("qa!");
assert!(app.exit_requested, ":qa! should exit even when dirty");
let _ = std::fs::remove_file(&path_a);
}
#[test]
fn q_on_multi_slot_closes_slot_not_app() {
let path_a = std::env::temp_dir().join("hjkl_phe_q_multi_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phe_q_multi_b.txt");
std::fs::write(&path_a, "a\n").unwrap();
std::fs::write(&path_b, "b\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
assert_eq!(app.slots.len(), 2);
app.dispatch_ex("q!");
assert_eq!(
app.slots.len(),
1,
"`:q!` with 2 slots should close active slot"
);
assert!(
!app.exit_requested,
"app should remain open after closing one slot"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn q_on_last_slot_quits_app() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.slots.len(), 1);
assert!(!app.active().dirty);
app.dispatch_ex("q");
assert!(app.exit_requested, "`:q` on clean last slot should exit");
}
#[test]
fn q_bang_force_quits_dirty_buffer_via_hjkl_ex() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "unsaved work");
app.active_mut().dirty = true;
app.dispatch_ex("q!");
assert!(
app.exit_requested,
"`:q!` must force-quit a dirty buffer (hjkl-ex Phase 1 routing)"
);
}
#[test]
fn checktime_reloads_clean_buffer_when_disk_changed() {
let path = std::env::temp_dir().join("hjkl_ct_reload.txt");
std::fs::write(&path, "line1\nline2\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>(),
vec!["line1", "line2"]
);
write_and_wait(&path, "new content\n");
app.checktime_all();
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>(),
vec!["new content"],
"buffer should be reloaded from disk"
);
assert!(!app.active().dirty, "reloaded buffer must not be dirty");
assert_eq!(app.active().disk_state, DiskState::Synced);
let _ = std::fs::remove_file(&path);
}
#[test]
fn checktime_marks_dirty_buffer_as_changed_on_disk_no_reload() {
let path = std::env::temp_dir().join("hjkl_ct_dirty.txt");
std::fs::write(&path, "original\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().dirty = true;
write_and_wait(&path, "changed on disk\n");
app.checktime_all();
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>(),
vec!["original"],
"dirty buffer must not be reloaded"
);
assert_eq!(
app.active().disk_state,
DiskState::ChangedOnDisk,
"disk_state must be ChangedOnDisk"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn checktime_with_autoreload_off_does_not_reload_clean_buffer() {
let path = std::env::temp_dir().join("hjkl_ct_noar.txt");
std::fs::write(&path, "original\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.dispatch_ex("set noautoreload");
assert!(!app.active().editor.settings().autoreload);
write_and_wait(&path, "changed on disk\n");
app.checktime_all();
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| s.to_string().trim_end_matches('\n').to_string())
.collect::<Vec<_>>(),
vec!["original"],
"autoreload off must leave the clean buffer untouched"
);
assert_eq!(app.active().disk_state, DiskState::ChangedOnDisk);
let _ = std::fs::remove_file(&path);
}
#[test]
fn checktime_reload_preserves_cursor_column() {
let path = std::env::temp_dir().join("hjkl_ct_curcol.txt");
std::fs::write(&path, "abcdefgh\nsecond\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().editor.jump_cursor(0, 4);
write_and_wait(&path, "abXXdefgh longer\nsecond\n");
app.checktime_all();
let (row, col) = app.active().editor.cursor();
assert_eq!(
(row, col),
(0, 4),
"reload must preserve cursor row + column"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn checktime_marks_deleted_when_file_removed() {
let path = std::env::temp_dir().join("hjkl_ct_deleted.txt");
std::fs::write(&path, "content\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
std::fs::remove_file(&path).unwrap();
app.checktime_all();
assert_eq!(app.active().disk_state, DiskState::DeletedOnDisk);
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>(),
vec!["content"]
);
}
#[test]
fn checktime_recovers_after_file_recreated() {
let path = std::env::temp_dir().join("hjkl_ct_recover.txt");
std::fs::write(&path, "v1\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
std::fs::remove_file(&path).unwrap();
app.checktime_all();
assert_eq!(app.active().disk_state, DiskState::DeletedOnDisk);
write_and_wait(&path, "v2\n");
app.checktime_all();
assert_eq!(
app.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>(),
vec!["v2"],
"recreated file should be reloaded"
);
assert_eq!(app.active().disk_state, DiskState::Synced);
let _ = std::fs::remove_file(&path);
}
fn fsw_lines(app: &App, idx: usize) -> Vec<String> {
app.slots[idx]
.editor
.buffer()
.rope()
.lines()
.map(|s| s.to_string().trim_end_matches('\n').to_string())
.collect()
}
#[test]
fn fs_event_modified_reloads_clean_buffer() {
let path = std::env::temp_dir().join("hjkl_fsw_modify.txt");
std::fs::write(&path, "old\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
write_and_wait(&path, "fresh\n");
let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(path.clone())]);
assert!(reloaded, "a clean buffer must report a reload");
assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["fresh"]);
assert!(!app.active().dirty);
assert_eq!(app.active().disk_state, DiskState::Synced);
let _ = std::fs::remove_file(&path);
}
#[test]
fn fs_event_dirty_buffer_flags_no_reload() {
let path = std::env::temp_dir().join("hjkl_fsw_dirty.txt");
std::fs::write(&path, "original\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().dirty = true;
write_and_wait(&path, "external\n");
let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(path.clone())]);
assert!(!reloaded, "dirty buffer must not reload");
assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["original"]);
assert_eq!(app.active().disk_state, DiskState::ChangedOnDisk);
let _ = std::fs::remove_file(&path);
}
#[test]
fn fs_event_autoreload_off_no_reload() {
let path = std::env::temp_dir().join("hjkl_fsw_noar.txt");
std::fs::write(&path, "original\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.dispatch_ex("set noautoreload");
write_and_wait(&path, "external\n");
let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(path.clone())]);
assert!(!reloaded);
assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["original"]);
assert_eq!(app.active().disk_state, DiskState::ChangedOnDisk);
let _ = std::fs::remove_file(&path);
}
#[test]
fn fs_event_removed_flags_deleted() {
let path = std::env::temp_dir().join("hjkl_fsw_removed.txt");
std::fs::write(&path, "content\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
std::fs::remove_file(&path).unwrap();
let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Removed(path.clone())]);
assert!(!reloaded);
assert_eq!(app.active().disk_state, DiskState::DeletedOnDisk);
assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["content"]);
}
#[test]
fn fs_event_unrelated_path_is_noop() {
let path = std::env::temp_dir().join("hjkl_fsw_open.txt");
std::fs::write(&path, "open\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let other = std::env::temp_dir().join("hjkl_fsw_unrelated.txt");
std::fs::write(&other, "noise\n").unwrap();
let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(other.clone())]);
assert!(!reloaded, "event for an unopened path must do nothing");
assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["open"]);
assert_eq!(app.active().disk_state, DiskState::Synced);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&other);
}
#[test]
fn fs_event_rename_to_open_path_reloads() {
let path = std::env::temp_dir().join("hjkl_fsw_rename_to.txt");
std::fs::write(&path, "old\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
write_and_wait(&path, "renamed-in\n");
let from = std::env::temp_dir().join("hjkl_fsw_rename_from.txt");
let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Renamed {
from,
to: path.clone(),
}]);
assert!(reloaded);
assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["renamed-in"]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn fs_event_duplicate_events_reload_once() {
let path = std::env::temp_dir().join("hjkl_fsw_dedup.txt");
std::fs::write(&path, "old\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
write_and_wait(&path, "deduped\n");
let reloaded = app.apply_fs_events(vec![
hjkl_fs_watch::FsEvent::Modified(path.clone()),
hjkl_fs_watch::FsEvent::Modified(path.clone()),
]);
assert!(reloaded);
assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["deduped"]);
let reload_notices = app
.bus
.history()
.filter(|h| h.display_body().contains("reloaded from disk"))
.count();
assert_eq!(reload_notices, 1, "duplicate events must reload only once");
let _ = std::fs::remove_file(&path);
}
#[test]
fn fs_event_reload_preserves_cursor_column() {
let path = std::env::temp_dir().join("hjkl_fsw_cursor.txt");
std::fs::write(&path, "abcdefgh\nsecond\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().editor.jump_cursor(0, 4);
write_and_wait(&path, "abXXdefgh longer\nsecond\n");
app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(path.clone())]);
assert_eq!(app.active().editor.cursor(), (0, 4));
let _ = std::fs::remove_file(&path);
}
#[test]
fn fs_event_only_matching_slot_reloads() {
let a = std::env::temp_dir().join("hjkl_fsw_multi_a.txt");
let b = std::env::temp_dir().join("hjkl_fsw_multi_b.txt");
std::fs::write(&a, "a-old\n").unwrap();
std::fs::write(&b, "b-old\n").unwrap();
let mut app = App::new(Some(a.clone()), false, None, None).unwrap();
let b_idx = app.open_new_slot(b.clone()).unwrap();
let a_idx = app.focused_slot_idx();
write_and_wait(&a, "a-new\n");
write_and_wait(&b, "b-new\n");
let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Modified(a.clone())]);
assert!(reloaded);
assert_eq!(fsw_lines(&app, a_idx), vec!["a-new"], "a reloads");
assert_eq!(
fsw_lines(&app, b_idx),
vec!["b-old"],
"b must be untouched (no event for it)"
);
let _ = std::fs::remove_file(&a);
let _ = std::fs::remove_file(&b);
}
#[test]
fn fs_event_empty_batch_is_noop() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(!app.apply_fs_events(Vec::new()));
}
#[test]
fn fs_watch_helpers_noop_without_watcher() {
let mut app = App::new(None, false, None, None).unwrap();
app.fs_watch_sync();
}
#[test]
fn fs_event_recreate_after_delete_reloads() {
let path = std::env::temp_dir().join("hjkl_fsw_recreate.txt");
std::fs::write(&path, "v1\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
std::fs::remove_file(&path).unwrap();
let r1 = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Removed(path.clone())]);
assert!(!r1);
assert_eq!(app.active().disk_state, DiskState::DeletedOnDisk);
write_and_wait(&path, "v2-recreated\n");
let r2 = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Created(path.clone())]);
assert!(r2, "recreated file must reload");
assert_eq!(
fsw_lines(&app, app.focused_slot_idx()),
vec!["v2-recreated"]
);
assert_eq!(app.active().disk_state, DiskState::Synced);
let _ = std::fs::remove_file(&path);
}
#[test]
fn fs_event_rename_unrelated_is_noop() {
let path = std::env::temp_dir().join("hjkl_fsw_rename_noop_open.txt");
std::fs::write(&path, "open\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let from = std::env::temp_dir().join("hjkl_fsw_rn_from.txt");
let to = std::env::temp_dir().join("hjkl_fsw_rn_to.txt");
let reloaded = app.apply_fs_events(vec![hjkl_fs_watch::FsEvent::Renamed { from, to }]);
assert!(!reloaded);
assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["open"]);
assert_eq!(app.active().disk_state, DiskState::Synced);
let _ = std::fs::remove_file(&path);
}
#[test]
fn fs_event_mixed_batch_reloads_only_open() {
let open = std::env::temp_dir().join("hjkl_fsw_mixed_open.txt");
std::fs::write(&open, "old\n").unwrap();
let mut app = App::new(Some(open.clone()), false, None, None).unwrap();
write_and_wait(&open, "mixed-new\n");
let noise_a = std::env::temp_dir().join("hjkl_fsw_mixed_noise_a.txt");
let noise_b = std::env::temp_dir().join("hjkl_fsw_mixed_noise_b.txt");
let reloaded = app.apply_fs_events(vec![
hjkl_fs_watch::FsEvent::Created(noise_a.clone()),
hjkl_fs_watch::FsEvent::Modified(open.clone()),
hjkl_fs_watch::FsEvent::Removed(noise_b.clone()),
]);
assert!(reloaded);
assert_eq!(fsw_lines(&app, app.focused_slot_idx()), vec!["mixed-new"]);
let _ = std::fs::remove_file(&open);
}
#[test]
fn substitute_percent_global_multi_line() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foo foo\nfoo");
app.dispatch_ex("%s/foo/bar/g");
let lines = app
.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines,
vec!["bar bar", "bar"],
"buffer should be fully substituted"
);
let msg = app.bus.last_body_or_empty().to_string();
assert_eq!(msg, "3 substitutions on 2 lines", "status: {msg}");
}
#[test]
fn substitute_current_line_first_only() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foo foo\nfoo");
app.dispatch_ex("s/foo/bar/");
let lines = app
.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(lines[0], "bar foo", "only first occurrence on current line");
assert_eq!(lines[1], "foo", "second line unchanged");
let msg = app.bus.last_body_or_empty().to_string();
assert_eq!(msg, "1 substitutions on 1 lines", "status: {msg}");
}
#[test]
fn substitute_empty_pattern_reuses_last_search() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut()
.editor
.set_last_search(Some("world".to_string()), true);
app.dispatch_ex("s//planet/");
let lines = app
.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines[0], "hello planet",
"should replace using last search pattern"
);
let msg = app.bus.last_body_or_empty().to_string();
assert_eq!(msg, "1 substitutions on 1 lines", "status: {msg}");
}
#[test]
fn substitute_no_match_shows_pattern_not_found() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.dispatch_ex("s/xyz/bar/");
let lines = app
.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(lines[0], "hello world", "buffer should be unchanged");
let msg = app.bus.last_body_or_empty().to_string();
assert_eq!(msg, "Pattern not found", "status: {msg}");
}
#[test]
fn anvil_install_unknown_tool_sets_error_message() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("Anvil install definitely-not-a-real-tool-xyz");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("unknown tool"),
"expected 'unknown tool' in status message, got: {msg:?}"
);
}
#[test]
fn anvil_uninstall_not_installed_graceful() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("Anvil uninstall rust-analyzer");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
!msg.is_empty(),
"expected some status message after anvil uninstall"
);
}
#[test]
fn anvil_update_all_with_zero_installed_tools() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("Anvil update");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("update sweep started"),
"expected 'update sweep started', got: {msg:?}"
);
}
#[test]
fn anvil_picker_source_builds_from_registry() {
use crate::picker_sources::{AnvilPickerSource, AnvilState};
let registry = hjkl_anvil::Registry::embedded().expect("embedded registry must load");
let source = AnvilPickerSource::from_registry(®istry);
assert!(!source.items.is_empty(), "picker source must have items");
for item in &source.items {
let label = item.label();
assert!(
label.contains(&item.name),
"label must contain tool name; got: {label:?}"
);
assert!(
matches!(
item.state,
AnvilState::Available | AnvilState::Installed { .. } | AnvilState::Outdated { .. }
),
"state must be one of the three variants"
);
}
}
#[test]
fn anvil_bad_subcommand_shows_usage() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("Anvil badsubcommand something else");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("usage"),
"expected usage hint in status message, got: {msg:?}"
);
}
#[test]
fn unbound_chord_tail_trie_returns_multi_key_replay() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abcdef");
let leader = app.config.editor.leader;
let mut replay: Vec<hjkl_keymap::KeyEvent> = Vec::new();
let consumed1 = app.dispatch_keymap(
hjkl_keymap::KeyEvent::new(
hjkl_keymap::KeyCode::Char(leader),
hjkl_keymap::KeyModifiers::NONE,
),
1,
&mut replay,
);
assert!(consumed1, "leader should be consumed as Pending prefix");
replay.clear();
let consumed2 = app.dispatch_keymap(
hjkl_keymap::KeyEvent::new(
hjkl_keymap::KeyCode::Char('x'),
hjkl_keymap::KeyModifiers::NONE,
),
1,
&mut replay,
);
assert!(!consumed2, "<leader>x is unbound → consumed=false");
assert!(
replay.len() > 1,
"replay should contain both keys, got {} keys",
replay.len()
);
}
#[test]
fn colon_e_path_opens_file_via_hjkl_ex() {
let path = tmp_path("hjkl_ex_2b_edit_test.txt");
std::fs::write(&path, "hello from hjkl-ex\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path.display()));
let lines = app
.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines,
vec!["hello from hjkl-ex"],
"`:e <path>` must load the file content into the active buffer; got {lines:?}"
);
let active_path = app
.active()
.filename
.as_deref()
.unwrap_or(std::path::Path::new(""));
assert_eq!(
active_path,
path.as_path(),
"`:e <path>` must set the active slot filename to the opened path"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn colon_bd_via_hjkl_ex_clears_sole_buffer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "some content");
app.active_mut().dirty = false;
app.dispatch_ex("bd");
let lines = app
.active()
.editor
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines,
vec![""],
"`:bd` on sole buffer must leave an empty scratch; got {lines:?}"
);
assert!(
app.active().filename.is_none(),
"`:bd` on sole buffer must clear the filename"
);
}
#[test]
fn colon_e_percent_expands_to_current_file() {
let path = tmp_path("hjkl_phase7_percent_test.txt");
std::fs::write(&path, "phase7 percent\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path.display()));
let active_after_first_open = app
.active()
.filename
.as_deref()
.unwrap_or(std::path::Path::new(""))
.to_path_buf();
app.dispatch_ex("e %");
let active_after_percent = app
.active()
.filename
.as_deref()
.unwrap_or(std::path::Path::new(""))
.to_path_buf();
assert_eq!(
active_after_percent, active_after_first_open,
"`:e %%` must expand to the current file path; got {active_after_percent:?}"
);
assert!(
app.bus.last_body_or_empty().is_empty() || !app.bus.last_body_or_empty().starts_with('E'),
"`:e %%` must not produce an error; got: {:?}",
app.bus.last_body_or_empty()
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn colon_e_hash_expands_to_alt() {
let path_a = tmp_path("hjkl_phase7_hash_a.txt");
let path_b = tmp_path("hjkl_phase7_hash_b.txt");
std::fs::write(&path_a, "file a\n").unwrap();
std::fs::write(&path_b, "file b\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_a.display()));
app.dispatch_ex(&format!("e {}", path_b.display()));
let active_before = app
.active()
.filename
.as_deref()
.map(|p| p.to_path_buf())
.unwrap();
assert!(
active_before.ends_with("hjkl_phase7_hash_b.txt"),
"sanity: active must be B; got {active_before:?}"
);
app.dispatch_ex("e #");
let active_after = app
.active()
.filename
.as_deref()
.map(|p| p.to_path_buf())
.unwrap();
assert!(
active_after.ends_with("hjkl_phase7_hash_a.txt"),
"`:e #` must expand to alt (file A); got {active_after:?}"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn colon_split_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
let before = app.layout().leaves().len();
app.dispatch_ex("split");
assert_eq!(
app.layout().leaves().len(),
before + 1,
":split must add one leaf"
);
}
#[test]
fn colon_sp_alias_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
let before = app.layout().leaves().len();
app.dispatch_ex("sp");
assert_eq!(
app.layout().leaves().len(),
before + 1,
":sp alias must add one leaf"
);
}
#[test]
fn colon_vsplit_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
let before = app.layout().leaves().len();
app.dispatch_ex("vsplit");
assert_eq!(
app.layout().leaves().len(),
before + 1,
":vsplit must add one leaf"
);
}
#[test]
fn colon_close_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.dispatch_ex("split");
assert_eq!(app.layout().leaves().len(), 2, "setup: need 2 leaves");
app.dispatch_ex("close");
assert_eq!(
app.layout().leaves().len(),
1,
":close must collapse back to 1 leaf"
);
}
#[test]
fn colon_tabnew_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
let before = app.tabs.len();
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), before + 1, ":tabnew must add a tab");
}
#[test]
fn colon_tabprev_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
let before = app.active_tab;
app.dispatch_ex("tabprev");
assert_eq!(
app.active_tab,
before - 1,
":tabprev must decrement active_tab"
);
}
#[test]
fn colon_tabclose_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), 2, "setup: need 2 tabs");
app.dispatch_ex("tabclose");
assert_eq!(app.tabs.len(), 1, ":tabclose must remove a tab");
}
#[test]
fn colon_only_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "data");
app.dispatch_ex("split");
app.dispatch_ex("split");
assert!(
app.layout().leaves().len() >= 2,
"setup: need at least 2 leaves"
);
app.dispatch_ex("only");
assert_eq!(
app.layout().leaves().len(),
1,
":only must leave exactly 1 leaf"
);
}
#[test]
fn colon_bnext_via_host_registry() {
let mut app = setup_three_slot_app();
assert_eq!(app.active_index(), 2);
app.dispatch_ex("bnext");
assert_eq!(app.active_index(), 0, ":bnext must wrap to first slot");
}
#[test]
fn colon_bn_alias_via_host_registry() {
let mut app = setup_three_slot_app();
assert_eq!(app.active_index(), 2);
app.dispatch_ex("bn");
assert_eq!(app.active_index(), 0, ":bn alias must wrap to first slot");
}
#[test]
fn colon_bprevious_via_host_registry() {
let mut app = setup_three_slot_app();
app.dispatch_ex("bprevious");
assert_eq!(app.active_index(), 1, ":bprevious must retreat one slot");
}
#[test]
fn colon_bp_alias_via_host_registry() {
let mut app = setup_three_slot_app();
app.dispatch_ex("bp");
assert_eq!(app.active_index(), 1, ":bp alias must retreat one slot");
}
#[test]
fn colon_bfirst_via_host_registry() {
let mut app = setup_three_slot_app();
assert_eq!(app.active_index(), 2);
app.dispatch_ex("bfirst");
assert_eq!(app.active_index(), 0, ":bfirst must jump to slot 0");
}
#[test]
fn colon_blast_via_host_registry() {
let mut app = setup_three_slot_app();
app.dispatch_ex("bfirst");
assert_eq!(app.active_index(), 0);
app.dispatch_ex("blast");
assert_eq!(
app.active_index(),
app.slots.len() - 1,
":blast must jump to the last slot"
);
}
#[test]
fn colon_ls_via_host_registry() {
let mut app = setup_three_slot_app();
app.dispatch_ex("ls");
let msg = app.bus.last_body_or_empty().to_string();
assert!(!msg.is_empty(), ":ls must produce a status message");
}
#[test]
fn colon_buffers_via_host_registry() {
let mut app = setup_three_slot_app();
app.dispatch_ex("buffers");
let msg = app
.info_popup
.as_ref()
.map(|p| p.content.clone())
.unwrap_or_else(|| app.bus.last_body_or_empty().to_string());
assert!(!msg.is_empty(), ":buffers must produce output");
}
#[test]
fn colon_clipboard_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("clipboard");
let msg = app
.info_popup
.as_ref()
.map(|p| p.content.clone())
.unwrap_or_else(|| app.bus.last_body_or_empty().to_string());
assert!(!msg.is_empty(), ":clipboard must produce output");
}
#[test]
fn colon_picker_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none(), "picker must start None");
app.dispatch_ex("picker");
assert!(app.picker.is_some(), ":picker must open the picker");
}
#[test]
fn colon_rg_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("rg");
assert!(app.picker.is_some(), ":rg must open the grep picker");
}
#[test]
fn colon_rg_with_pattern_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("rg fn main");
assert!(
app.picker.is_some(),
":rg <pattern> must open the grep picker"
);
}
#[test]
fn leader_slash_opens_grep_picker() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none(), "picker must start None");
app.route_chord_key(key(KeyCode::Char(' ')));
app.route_chord_key(key(KeyCode::Char('/')));
assert!(
app.picker.is_some(),
"<leader>/ must open the grep picker; status={:?}",
app.bus.last_body_or_empty()
);
}
#[test]
fn leader_slash_grep_picker_populates_items() {
if std::process::Command::new("rg")
.arg("--version")
.output()
.is_err()
&& std::process::Command::new("grep")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: no rg or grep on PATH");
return;
}
let dir = std::env::temp_dir().join(format!(
"hjkl_grep_picker_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("findme.txt");
std::fs::write(&file, "alpha\nUNIQUE_NEEDLE_42\nomega\n").unwrap();
let orig_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.route_chord_key(key(KeyCode::Char(' ')));
app.route_chord_key(key(KeyCode::Char('/')));
assert!(app.picker.is_some(), "<leader>/ must open the picker");
for c in "UNIQUE_NEEDLE_42".chars() {
app.handle_picker_key(key(KeyCode::Char(c)));
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut got_match = false;
while std::time::Instant::now() < deadline {
if let Some(p) = app.picker.as_mut() {
let _ = p.refresh();
p.tick(std::time::Instant::now());
let _ = p.refresh();
}
let count = app.picker.as_ref().map(|p| p.matched()).unwrap_or(0);
if count > 0 {
got_match = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
std::env::set_current_dir(&orig_cwd).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
got_match,
"rg-backed grep picker must return at least one match for the seeded UNIQUE_NEEDLE_42; \
status={:?}",
app.bus.last_body_or_empty()
);
}
#[test]
fn colon_b_numeric_via_host_registry() {
let mut app = setup_three_slot_app();
app.dispatch_ex("b 2");
assert_eq!(app.active_index(), 1, ":b 2 must switch to slot index 1");
}
#[test]
fn colon_b_nonexistent_via_host_registry() {
let mut app = setup_three_slot_app();
app.dispatch_ex("b nonexistent_buffer_xyz");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("E94") || msg.contains("No matching"),
":b nonexistent must set error status"
);
}
#[test]
fn colon_bpicker_via_host_registry() {
let mut app = setup_three_slot_app();
assert!(app.picker.is_none());
app.dispatch_ex("bpicker");
assert!(app.picker.is_some(), ":bpicker must open the buffer picker");
}
#[test]
fn colon_checktime_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("checktime");
}
#[test]
fn colon_vnew_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
let before = app.slots.len();
app.dispatch_ex("vnew");
assert!(app.slots.len() > before, ":vnew must add a new buffer slot");
}
#[test]
fn colon_new_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
let before = app.slots.len();
app.dispatch_ex("new");
assert!(app.slots.len() > before, ":new must add a new buffer slot");
}
#[test]
fn colon_tabfirst_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
assert!(app.active_tab > 0 || app.tabs.len() > 1);
app.dispatch_ex("tabfirst");
assert_eq!(app.active_tab, 0, ":tabfirst must jump to tab 0");
}
#[test]
fn colon_tablast_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabfirst");
assert_eq!(app.active_tab, 0);
app.dispatch_ex("tablast");
let last = app.tabs.len() - 1;
assert_eq!(app.active_tab, last, ":tablast must jump to the last tab");
}
#[test]
fn colon_tabonly_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), 3);
app.dispatch_ex("tabfirst");
app.dispatch_ex("tabnext");
assert_eq!(app.active_tab, 1);
app.dispatch_ex("tabonly");
assert_eq!(app.tabs.len(), 1, ":tabonly must leave exactly one tab");
assert_eq!(app.active_tab, 0, ":tabonly must reset active_tab to 0");
}
#[test]
fn colon_tabs_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), 2);
app.info_popup = None;
app.dispatch_ex("tabs");
assert!(
app.info_popup.is_some(),
":tabs must set info_popup with tab listing"
);
let popup = app.info_popup.as_ref().unwrap();
assert!(
popup.content.contains("Tab page 1"),
"popup must list Tab page 1"
);
assert!(
popup.content.contains("Tab page 2"),
"popup must list Tab page 2"
);
}
#[test]
fn colon_lnext_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("lnext");
}
#[test]
fn colon_lopen_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("lopen");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("no diagnostics"),
":lopen with empty diag list must set status 'no diagnostics', got: {msg}"
);
}
#[test]
fn colon_resize_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
let rect = ratatui::layout::Rect {
x: 0,
y: 0,
width: 80,
height: 40,
};
let fw = app.focused_window();
inject_split_rect(app.layout_mut(), fw, rect);
let ratio_before = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
app.dispatch_ex("resize +5");
let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
assert!(
ratio_after > ratio_before,
":resize +5 must grow focused window ratio: before={ratio_before} after={ratio_after}"
);
}
#[test]
fn colon_vertical_resize_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("vsp");
let rect = ratatui::layout::Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let fw = app.focused_window();
inject_split_rect(app.layout_mut(), fw, rect);
let ratio_before = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
app.dispatch_ex("vertical resize +5");
let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
assert!(
ratio_after > ratio_before,
":vertical resize +5 must grow focused window width ratio: before={ratio_before} after={ratio_after}"
);
}
#[test]
fn colon_write_removes_swap_file() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("test_write_swap.txt");
std::fs::write(&file_path, "hello\n").unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
let swap_path = td.path().join("test_write_swap.swp");
app.active_mut().swap_path = Some(swap_path.clone());
seed_buffer(&mut app, "changed content");
app.active_mut().dirty = true;
let idx = app.focused_slot_idx();
app.write_swap_for_slot(idx);
assert!(
swap_path.exists(),
"swap file must exist after write_swap_for_slot"
);
app.dispatch_ex("write");
assert!(!swap_path.exists(), "swap file must be deleted after :w");
}
#[test]
fn colon_preserve_writes_swap() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("test_preserve_swap.txt");
std::fs::write(&file_path, "initial\n").unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
let swap_path = td.path().join("test_preserve_swap.swp");
app.active_mut().swap_path = Some(swap_path.clone());
seed_buffer(&mut app, "modified content");
app.active_mut().dirty = true;
app.dispatch_ex("preserve");
assert!(swap_path.exists(), "swap file must exist after :preserve");
}
#[test]
fn open_file_with_newer_swap_enters_recovery_state() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("test_recovery.txt");
std::fs::write(&file_path, "on disk content\n").unwrap();
let canonical = std::fs::canonicalize(&file_path).unwrap();
let file_mtime_ms = std::fs::metadata(&file_path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let swap_path = td.path().join("test_recovery.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: canonical.to_string_lossy().into_owned(),
file_mtime_unix_ms: file_mtime_ms,
write_time_unix_ms: file_mtime_ms + 10_000,
cursor: (0, 0),
writer_pid: std::process::id(),
};
let rope = ropey::Rope::from_str("swap body content");
hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
app.active_mut().swap_path = Some(swap_path.clone());
let idx = app.focused_slot_idx();
let recovery_needed = app.check_recovery_on_open(idx);
assert!(
recovery_needed,
"check_recovery_on_open must return true when swap is newer"
);
assert!(
app.pending_recovery.is_some(),
"pending_recovery must be set when swap is newer than disk"
);
}
#[test]
fn recovery_y_loads_swap_body() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("test_recovery_y.txt");
std::fs::write(&file_path, "on disk\n").unwrap();
let canonical = std::fs::canonicalize(&file_path).unwrap();
let file_mtime_ms = std::fs::metadata(&file_path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let swap_path = td.path().join("test_recovery_y.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: canonical.to_string_lossy().into_owned(),
file_mtime_unix_ms: file_mtime_ms,
write_time_unix_ms: file_mtime_ms + 10_000,
cursor: (0, 0),
writer_pid: std::process::id(),
};
let rope = ropey::Rope::from_str("recovered content");
hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
app.active_mut().swap_path = Some(swap_path.clone());
let idx = app.focused_slot_idx();
app.check_recovery_on_open(idx);
assert!(app.pending_recovery.is_some(), "must be in recovery state");
app.handle_recovery_key(key(crossterm::event::KeyCode::Char('y')));
assert!(
app.pending_recovery.is_none(),
"pending_recovery must be cleared after 'y'"
);
let content = app.active().editor.buffer().content_joined();
assert!(
content.contains("recovered content"),
"buffer must contain swap body after 'y', got: {content:?}"
);
}
#[test]
fn recovery_y_resets_syntax_spans() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("recover_syntax.txt");
std::fs::write(&file_path, "on disk\n").unwrap();
let canonical = std::fs::canonicalize(&file_path).unwrap();
let file_mtime_ms = std::fs::metadata(&file_path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let swap_path = td.path().join("recover_syntax.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: canonical.to_string_lossy().into_owned(),
file_mtime_unix_ms: file_mtime_ms,
write_time_unix_ms: file_mtime_ms + 10_000,
cursor: (0, 0),
writer_pid: std::process::id(),
};
let rope = ropey::Rope::from_str("recovered body line one\nline two\n");
hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
app.active_mut().swap_path = Some(swap_path.clone());
let idx = app.focused_slot_idx();
let _ = app.active_mut().editor.take_content_reset();
app.recover_install_content(idx, "recovered body line one\nline two\n", 0, 0);
assert!(
app.active_mut().editor.take_content_reset(),
"recovery content install must signal a full content reset (set_content), \
not an incremental edit (replace_all) — else the syntax tree drifts"
);
let content = app.active().editor.buffer().content_joined();
assert!(
content.contains("recovered body line one"),
"buffer must hold the recovered body, got: {content:?}"
);
}
#[test]
#[cfg(unix)]
fn open_locked_file_refused_when_pid_alive() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("locked_file.txt");
std::fs::write(&file_path, "on disk\n").unwrap();
let canonical = std::fs::canonicalize(&file_path).unwrap();
let file_mtime_ms = std::fs::metadata(&file_path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let swap_path = td.path().join("locked_file.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: canonical.to_string_lossy().into_owned(),
file_mtime_unix_ms: file_mtime_ms,
write_time_unix_ms: file_mtime_ms + 10_000,
cursor: (0, 0),
writer_pid: 1, };
let rope = ropey::Rope::from_str("locked content");
hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
let slot_count_before = app.slots.len();
app.active_mut().swap_path = Some(swap_path.clone());
let idx = app.focused_slot_idx();
let recovery = app.check_recovery_on_open(idx);
assert!(!recovery, "locked file must not enter recovery state");
assert!(
app.pending_recovery.is_none(),
"pending_recovery must remain None for a locked file"
);
let msgs: Vec<&str> = app.bus.history().map(|h| h.body.as_str()).collect();
assert!(
msgs.iter().any(|m| m.contains("E325")),
"E325 error must be reported; got: {msgs:?}"
);
if slot_count_before > 1 {
assert!(
app.slots.len() < slot_count_before,
"refused slot must be removed"
);
}
}
#[test]
#[cfg(unix)]
fn open_with_dead_pid_enters_recovery() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("dead_pid_file.txt");
std::fs::write(&file_path, "on disk\n").unwrap();
let canonical = std::fs::canonicalize(&file_path).unwrap();
let file_mtime_ms = std::fs::metadata(&file_path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let swap_path = td.path().join("dead_pid_file.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: canonical.to_string_lossy().into_owned(),
file_mtime_unix_ms: file_mtime_ms,
write_time_unix_ms: file_mtime_ms + 10_000,
cursor: (0, 0),
writer_pid: 999_999_999, };
let rope = ropey::Rope::from_str("recovered from dead pid");
hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
app.active_mut().swap_path = Some(swap_path.clone());
let idx = app.focused_slot_idx();
let recovery = app.check_recovery_on_open(idx);
assert!(
recovery,
"stale (dead-pid) swap must enter recovery state, not be refused"
);
assert!(
app.pending_recovery.is_some(),
"pending_recovery must be set for a dead-pid swap"
);
let msgs: Vec<&str> = app.bus.history().map(|h| h.body.as_str()).collect();
assert!(
!msgs.iter().any(|m| m.contains("E325")),
"E325 must NOT fire for a dead-pid swap; got: {msgs:?}"
);
}
#[test]
fn colon_recover_forces_prompt_even_when_file_newer() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("force_recover.txt");
std::fs::write(&file_path, "on disk content\n").unwrap();
let canonical = std::fs::canonicalize(&file_path).unwrap();
let file_mtime_ms = std::fs::metadata(&file_path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let swap_path = td.path().join("force_recover.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: canonical.to_string_lossy().into_owned(),
file_mtime_unix_ms: file_mtime_ms,
write_time_unix_ms: file_mtime_ms.saturating_sub(5_000), cursor: (0, 0),
writer_pid: std::process::id(), };
let rope = ropey::Rope::from_str("stale swap body");
hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
app.active_mut().swap_path = Some(swap_path.clone());
app.dispatch_ex("recover");
assert!(
app.pending_recovery.is_some(),
":recover must enter recovery state even when swap is older than file"
);
}
#[test]
fn colon_recover_no_swap_reports_not_found() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("no_swap.txt");
std::fs::write(&file_path, "some content\n").unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
let nonexistent_swap = td.path().join("no_swap.swp");
app.active_mut().swap_path = Some(nonexistent_swap);
app.dispatch_ex("recover");
assert!(
app.pending_recovery.is_none(),
":recover with no swap must not enter recovery state"
);
let msgs: Vec<&str> = app.bus.history().map(|h| h.body.as_str()).collect();
assert!(
msgs.iter().any(|m| m.contains("No swap file found")),
"must report 'No swap file found'; got: {msgs:?}"
);
}
#[test]
fn open_writes_swap_immediately() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("arm_on_open.txt");
std::fs::write(&file_path, "initial content\n").unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
let swap_path = td.path().join("arm_on_open.swp");
app.active_mut().swap_path = Some(swap_path.clone());
app.active_mut().last_swap_dirty_gen = None;
assert!(
!swap_path.exists(),
"swap must not exist before arm_swap_on_open"
);
let idx = app.focused_slot_idx();
app.arm_swap_on_open(idx);
assert!(
swap_path.exists(),
"swap file must exist on disk immediately after arm_swap_on_open (PID lock)"
);
}
#[test]
fn graceful_exit_removes_swap() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("cleanup_exit.txt");
std::fs::write(&file_path, "content\n").unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
let swap_path = td.path().join("cleanup_exit.swp");
app.active_mut().swap_path = Some(swap_path.clone());
let idx = app.focused_slot_idx();
seed_buffer(&mut app, "dirty content");
app.write_swap_for_slot(idx);
assert!(swap_path.exists(), "swap must exist before cleanup");
app.cleanup_swaps_on_exit();
assert!(
!swap_path.exists(),
"swap file must be removed after cleanup_swaps_on_exit"
);
assert!(
app.active().swap_path.is_none(),
"swap_path must be None after cleanup_swaps_on_exit"
);
}
#[test]
#[cfg(unix)]
fn second_instance_refused_after_first_opens_unmodified() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("multi_instance.txt");
std::fs::write(&file_path, "shared content\n").unwrap();
let canonical = std::fs::canonicalize(&file_path).unwrap();
let file_mtime_ms = std::fs::metadata(&file_path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let swap_path = td.path().join("multi_instance.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: canonical.to_string_lossy().into_owned(),
file_mtime_unix_ms: file_mtime_ms,
write_time_unix_ms: file_mtime_ms + 1, cursor: (0, 0),
writer_pid: 1, };
let rope = ropey::Rope::from_str("shared content");
hjkl_app::swap::write_swap(&swap_path, &header, &rope).unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
app.active_mut().swap_path = Some(swap_path.clone());
let idx = app.focused_slot_idx();
let recovery = app.check_recovery_on_open(idx);
assert!(
!recovery,
"second instance must be refused when first holds PID lock"
);
assert!(
app.pending_recovery.is_none(),
"pending_recovery must remain None on refusal"
);
let msgs: Vec<&str> = app.bus.history().map(|h| h.body.as_str()).collect();
assert!(
msgs.iter().any(|m| m.contains("E325")),
"E325 must be reported; got: {msgs:?}"
);
}
#[cfg(unix)]
#[test]
fn open_locked_sole_buffer_is_readonly() {
let td = tempfile::tempdir().unwrap();
let file_path = td.path().join("locked_sole.txt");
std::fs::write(&file_path, "owned by another\n").unwrap();
let canonical = std::fs::canonicalize(&file_path).unwrap();
let file_mtime_ms = std::fs::metadata(&file_path)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let swap_path = td.path().join("locked_sole.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: canonical.to_string_lossy().into_owned(),
file_mtime_unix_ms: file_mtime_ms,
write_time_unix_ms: file_mtime_ms + 10_000,
cursor: (0, 0),
writer_pid: 1,
};
hjkl_app::swap::write_swap(&swap_path, &header, &ropey::Rope::from_str("x")).unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.pending_recovery = None;
let idx = app.focused_slot_idx();
assert_eq!(app.slots.len(), 1, "precondition: sole buffer");
app.active_mut().swap_path = Some(swap_path.clone());
let recovery = app.check_recovery_on_open(idx);
assert!(!recovery, "locked sole buffer must not enter recovery");
assert!(
app.active().editor.is_readonly(),
"locked sole buffer must open read-only"
);
assert!(
app.active().swap_path.is_none(),
"read-only viewer must not own a swap path"
);
seed_buffer(&mut app, "clobbered by viewer\n");
app.dispatch_ex("write");
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(
on_disk, "owned by another\n",
"locked read-only viewer must not be able to :w over the owner's file"
);
let msgs: Vec<String> = app.bus.history().map(|h| h.body.clone()).collect();
assert!(
msgs.iter()
.any(|m| m.contains("E45") || m.contains("readonly")),
"write must be refused with a readonly error; got: {msgs:?}"
);
}
#[cfg(unix)]
#[test]
fn locked_secondary_slot_is_readonly_not_removed() {
let td = tempfile::tempdir().unwrap();
let file1 = td.path().join("ms_file1.txt");
let file2 = td.path().join("ms_file2.txt");
std::fs::write(&file1, "first\n").unwrap();
std::fs::write(&file2, "second owned\n").unwrap();
let canon2 = std::fs::canonicalize(&file2).unwrap();
let mtime2 = std::fs::metadata(&file2)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let swap2 = td.path().join("ms_file2.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: canon2.to_string_lossy().into_owned(),
file_mtime_unix_ms: mtime2,
write_time_unix_ms: mtime2 + 10_000,
cursor: (0, 0),
writer_pid: 1, };
hjkl_app::swap::write_swap(&swap2, &header, &ropey::Rope::from_str("x")).unwrap();
let mut app = App::new(Some(file1.clone()), false, None, None).unwrap();
app.pending_recovery = None;
let idx2 = app.open_new_slot(file2.clone()).unwrap();
assert_eq!(app.slots.len(), 2, "precondition: two slots");
app.slots[idx2].swap_path = Some(swap2.clone());
let recovery = app.check_recovery_on_open(idx2);
assert!(!recovery, "locked secondary slot must not enter recovery");
assert_eq!(
app.slots.len(),
2,
"locked secondary slot must NOT be removed — the file was requested"
);
assert!(
app.slots[idx2].editor.is_readonly(),
"locked secondary slot must open read-only"
);
assert!(
app.slots[idx2].swap_path.is_none(),
"locked read-only slot must not own a swap path"
);
}
#[test]
fn scratch_buffer_writes_swap_when_dirty() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "unsaved work");
app.write_swap_for_slot(0);
let swap_path = app.slots[0]
.swap_path
.clone()
.expect("swap_path must be Some after write for non-empty scratch");
assert!(swap_path.exists(), "swap file must exist at {swap_path:?}");
let (header, body) = hjkl_app::swap::read_swap(&swap_path).unwrap();
assert!(
header.canonical_path.is_empty(),
"scratch swap must have empty canonical_path, got {:?}",
header.canonical_path
);
assert_eq!(body, "unsaved work", "swap body must match buffer content");
let _ = hjkl_app::swap::remove_swap(&swap_path);
}
#[test]
fn empty_scratch_buffer_writes_no_swap() {
let mut app = App::new(None, false, None, None).unwrap();
app.write_swap_for_slot(0);
assert!(
app.slots[0].swap_path.is_none(),
"empty scratch buffer must not get a swap_path (so nothing was written)"
);
}
#[test]
fn recover_orphan_scratch_loads_buffer() {
let td = tempfile::tempdir().unwrap();
let orphan_path = td.path().join("scratch_999999999_42.swp");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: String::new(),
file_mtime_unix_ms: 0,
write_time_unix_ms: 1_700_000_000_000,
cursor: (0, 0),
writer_pid: 999_999_999,
};
let rope = ropey::Rope::from_str("unsaved work\n");
hjkl_app::swap::write_swap(&orphan_path, &header, &rope).unwrap();
let mut app = App::new(None, false, None, None).unwrap();
let initial_slot_count = app.slots.len();
let recovered = app.recover_orphan_scratch_buffers_from(td.path());
assert_eq!(recovered, 1, "must recover exactly 1 buffer");
assert_eq!(
app.slots.len(),
initial_slot_count + 1,
"a new slot must have been added"
);
let new_slot = &app.slots[app.slots.len() - 1];
assert!(
new_slot.filename.is_none(),
"recovered slot must be unnamed"
);
assert!(new_slot.dirty, "recovered slot must be dirty");
let content = new_slot.editor.buffer().rope().to_string();
assert!(
content.contains("unsaved work"),
"recovered buffer content must contain 'unsaved work', got {content:?}"
);
assert!(
!orphan_path.exists(),
"orphan swap must be deleted after recovery"
);
}
#[test]
fn active_swap_pending_true_for_dirty_scratch_buffer() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(
app.active().filename.is_none(),
"precondition: scratch buffer"
);
assert!(
!app.active_swap_pending(),
"empty scratch buffer has nothing to protect yet"
);
seed_buffer(&mut app, "unsaved scratch work\n");
assert!(
app.active_swap_pending(),
"dirty non-empty scratch buffer must arm the idle swap writer"
);
}
#[test]
fn colorscheme_switches_and_reports() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.colorscheme, "dark", "default colorscheme is dark");
app.dispatch_ex("colorscheme light");
assert_eq!(app.colorscheme, "light");
assert!(
app.bus.last_body_or_empty().contains("colorscheme light"),
"switch must report the new scheme"
);
app.dispatch_ex("colorscheme");
assert!(app.bus.last_body_or_empty().contains("colorscheme light"));
app.dispatch_ex("colo dark");
assert_eq!(app.colorscheme, "dark");
}
#[test]
fn colorscheme_unknown_errors_and_keeps_current() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("colorscheme solarized");
assert_eq!(
app.colorscheme, "dark",
"unknown scheme must not change current"
);
assert!(
app.bus.last_body_or_empty().contains("E185"),
"unknown colorscheme must report E185"
);
}
#[test]
fn set_background_tracks_colorscheme() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set background=light");
assert_eq!(
app.colorscheme, "light",
":set background= must track colorscheme"
);
}
fn indent_app(seed: &str, keys: &[KeyEvent]) -> String {
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().editor.settings_mut().expandtab = true;
app.active_mut().editor.settings_mut().shiftwidth = 4;
seed_buffer(&mut app, seed);
app.active_mut().editor.jump_cursor(0, 0);
macro_key_seq(&mut app, keys);
(*app.active().editor.buffer().content_joined()).clone()
}
#[test]
fn indent_operator_motion_forms() {
assert_eq!(
indent_app("a\nb\nc\n", &[ck('>'), ck('j')]),
" a\n b\nc\n"
);
assert_eq!(
indent_app("a\nb\nc\n", &[ck('>'), ck('G')]),
" a\n b\n c\n"
);
assert_eq!(
indent_app("a\nb\nc\nd\n", &[ck('>'), ck('2'), ck('j')]),
" a\n b\n c\nd\n"
);
assert_eq!(
indent_app("a\nb\nc\nd\n", &[ck('2'), ck('>'), ck('j')]),
" a\n b\n c\nd\n"
);
}
#[test]
fn indent_text_objects() {
assert_eq!(
indent_app("a\nb\n\nc\n", &[ck('>'), ck('i'), ck('p')]),
" a\n b\n\nc\n"
);
assert_eq!(
indent_app("{\nx\ny\n}\n", &[ck('j'), ck('>'), ck('i'), ck('B')]),
"{\n x\n y\n}\n"
);
}
#[test]
fn indent_dot_repeat() {
assert_eq!(
indent_app("a\nb\nc\n", &[ck('>'), ck('>'), ck('j'), ck('.')]),
" a\n b\nc\n"
);
}
#[test]
fn visual_indent_count_applies_levels() {
assert_eq!(
indent_app("a\nb\nc\n", &[ck('V'), ck('j'), ck('2'), ck('>')]),
" a\n b\nc\n"
);
assert_eq!(
indent_app(
" a\n b\n c\n",
&[ck('V'), ck('j'), ck('2'), ck('<')]
),
"a\nb\n c\n"
);
assert_eq!(
indent_app("a\nb\nc\n", &[ck('V'), ck('3'), ck('>')]),
" a\nb\nc\n"
);
}
#[test]
fn visual_motion_count_extends_selection() {
assert_eq!(
indent_app("a\nb\nc\nd\ne\n", &[ck('V'), ck('2'), ck('j'), ck('>')]),
" a\n b\n c\nd\ne\n"
);
}
#[test]
fn insert_ctrl_t_and_ctrl_d() {
assert_eq!(indent_app("abc\n", &[ck('i'), ctrl_key('t')]), " abc\n");
assert_eq!(
indent_app("abc\n", &[ck('i'), ctrl_key('t'), ctrl_key('d')]),
"abc\n"
);
}
#[test]
fn double_equals_keypress_formats_toml() {
if !hjkl_mangler::is_tool_installed("taplo") {
return;
}
let td = tempfile::tempdir().unwrap();
let path = td.path().join("k.toml");
std::fs::write(&path, "[a]\nx=1\n").unwrap();
let mut app = App::new(Some(path), false, None, None).unwrap();
seed_buffer(&mut app, "[a]\nx=1\n");
app.active_mut().editor.jump_cursor(0, 0);
macro_key_seq(&mut app, &[ck('='), ck('=')]);
let mut installed = false;
for _ in 0..200 {
if app.poll_format_results() {
installed = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(
installed,
"`==` keypress must submit a format job whose result installs"
);
let out = app.active().editor.buffer().content_joined();
assert!(
out.contains("x = 1"),
"`==` must reformat `x=1` → `x = 1`, got: {out:?}"
);
}
#[test]
fn external_format_toml_installs_worker_result() {
if !hjkl_mangler::is_tool_installed("taplo") {
return; }
let td = tempfile::tempdir().unwrap();
let path = td.path().join("messy.toml");
std::fs::write(&path, "[a]\nx=1\n").unwrap();
let mut app = App::new(Some(path), false, None, None).unwrap();
seed_buffer(&mut app, "[a]\nx=1\n");
let submitted = app.submit_external_format(Some(hjkl_mangler::RangeSpec {
start_row: 0,
end_row: 1,
}));
assert!(
submitted,
"taplo is installed → submit_external_format must dispatch a job"
);
let mut installed = false;
for _ in 0..200 {
if app.poll_format_results() {
installed = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(installed, "the async format result must install within ~4s");
let out = app.active().editor.buffer().content_joined();
assert!(
out.contains("x = 1"),
"taplo must reformat `x=1` → `x = 1`, got: {out:?}"
);
}
#[test]
fn trailing_newline_file_loads_without_phantom_line() {
use hjkl_engine::Query;
let td = tempfile::tempdir().unwrap();
let path = td.path().join("eol.txt");
std::fs::write(&path, "a\nb\nc\n").unwrap();
let app = App::new(Some(path), false, None, None).unwrap();
assert_eq!(
app.active().editor.buffer().line_count(),
3,
"a file ending in `\\n` must load as 3 lines, not 4 (no phantom empty line)"
);
}
#[test]
fn save_round_trips_trailing_newline_exactly_once() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("eol_rt.txt");
std::fs::write(&path, "a\nb\nc\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.active_mut().dirty = true; app.dispatch_ex("w");
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(
on_disk, "a\nb\nc\n",
"save must preserve exactly one trailing newline, not drop or double it"
);
}
#[test]
fn save_adds_trailing_newline_when_missing() {
use hjkl_engine::Query;
let td = tempfile::tempdir().unwrap();
let path = td.path().join("noeol.txt");
std::fs::write(&path, "a\nb\nc").unwrap(); let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
assert_eq!(app.active().editor.buffer().line_count(), 3);
app.active_mut().dirty = true;
app.dispatch_ex("w");
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(
on_disk, "a\nb\nc\n",
"save must add a trailing newline when missing (vim `fixendofline`)"
);
}
fn app_with_file(content: &str) -> (App, tempfile::TempDir) {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("sed_compat.txt");
std::fs::write(&path, content).unwrap();
let mut app = App::new(Some(path), false, None, None).unwrap();
seed_buffer(&mut app, content);
(app, td)
}
#[test]
fn percent_substitute_replaces_all_lines() {
let (mut app, _td) = app_with_file("foo foo\nbar\nfoo\n");
app.dispatch_ex("%s/foo/bar/g");
let content = app.active().editor.buffer().content_joined();
assert_eq!(
&**content, "bar bar\nbar\nbar\n",
"`:%s/foo/bar/g` must replace every match on every line, got: {content:?}"
);
}
#[test]
fn substitute_literal_percent_in_pattern() {
let (mut app, _td) = app_with_file("50% done\n");
app.dispatch_ex("s/50%/half/");
let content = app.active().editor.buffer().content_joined();
assert_eq!(
&**content, "half done\n",
"`%` in the substitute pattern must stay literal, got: {content:?}"
);
}
#[test]
fn substitute_literal_hash_in_replacement() {
let (mut app, _td) = app_with_file("count: N\n");
app.dispatch_ex(r"s/N/#42/");
let content = app.active().editor.buffer().content_joined();
assert_eq!(
&**content, "count: #42\n",
"`#` in the replacement must stay literal, got: {content:?}"
);
}
#[test]
fn percent_global_delete_matching_lines() {
let (mut app, _td) = app_with_file("keep\ndrop me\nkeep2\ndrop again\n");
app.dispatch_ex("%g/drop/d");
let content = app.active().editor.buffer().content_joined();
assert_eq!(
&**content, "keep\nkeep2\n",
"`:%g/drop/d` must delete every matching line, got: {content:?}"
);
}
#[test]
fn numeric_range_substitute_still_works() {
let (mut app, _td) = app_with_file("a\nx\nx\nx\nb\n");
app.dispatch_ex("2,3s/x/y/");
let content = app.active().editor.buffer().content_joined();
assert_eq!(
&**content, "a\ny\ny\nx\nb\n",
"`:2,3s/x/y/` must touch only lines 2-3, got: {content:?}"
);
}
#[test]
fn edit_percent_still_expands_to_filename() {
let (mut app, _td) = app_with_file("hello\n");
seed_buffer(&mut app, "changed\n");
app.active_mut().dirty = true;
app.dispatch_ex("e! %");
let content = app.active().editor.buffer().content_joined();
assert_eq!(
content.trim_end(),
"hello",
"`:e! %` must expand `%` to the current file and reload it, got: {content:?}"
);
assert!(
!content.contains("changed"),
"reload must replace the dirty buffer, got: {content:?}"
);
}