use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::Duration;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn op_kind_to_operator(k: hjkl_vim::OperatorKind) -> hjkl_engine::Operator {
match k {
hjkl_vim::OperatorKind::Delete => hjkl_engine::Operator::Delete,
hjkl_vim::OperatorKind::Yank => hjkl_engine::Operator::Yank,
hjkl_vim::OperatorKind::Change => hjkl_engine::Operator::Change,
hjkl_vim::OperatorKind::Indent => hjkl_engine::Operator::Indent,
hjkl_vim::OperatorKind::Outdent => hjkl_engine::Operator::Outdent,
hjkl_vim::OperatorKind::Uppercase => hjkl_engine::Operator::Uppercase,
hjkl_vim::OperatorKind::Lowercase => hjkl_engine::Operator::Lowercase,
hjkl_vim::OperatorKind::ToggleCase => hjkl_engine::Operator::ToggleCase,
hjkl_vim::OperatorKind::Reflow => hjkl_engine::Operator::Reflow,
}
}
fn ctrl_key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn type_str(app: &mut App, text: &str) {
for c in text.chars() {
app.handle_command_field_key(key(KeyCode::Char(c)));
}
}
#[test]
fn palette_open_and_submit_runs_dispatch_and_closes() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
assert!(app.command_field.is_some());
type_str(&mut app, "q");
assert_eq!(app.command_field.as_ref().unwrap().text(), "q");
app.handle_command_field_key(key(KeyCode::Enter));
assert!(app.command_field.is_none());
assert!(app.exit_requested);
}
#[test]
fn wq_no_filename_does_not_exit() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "unsaved work");
app.active_mut().dirty = true;
app.dispatch_ex("wq");
assert!(!app.exit_requested, "wq must not exit when save fails");
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("E32"), "expected E32, got: {msg}");
}
#[test]
fn wq_readonly_does_not_exit() {
let mut app = App::new(None, true, None, None).unwrap();
app.active_mut().filename = Some(tmp_path("hjkl_wq_ro_test.txt"));
app.dispatch_ex("wq");
assert!(!app.exit_requested, "wq must not exit when save fails");
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("E45"), "expected E45, got: {msg}");
}
#[test]
fn palette_esc_in_insert_drops_to_normal_then_motions_apply() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "abc");
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.command_field.is_some());
let f = app.command_field.as_ref().unwrap();
assert_eq!(f.vim_mode(), VimMode::Normal);
assert_eq!(f.text(), "abc");
app.handle_command_field_key(key(KeyCode::Char('b')));
app.handle_command_field_key(key(KeyCode::Char('d')));
app.handle_command_field_key(key(KeyCode::Char('w')));
let f = app.command_field.as_ref().unwrap();
assert_eq!(f.text(), "");
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.command_field.is_none());
}
#[test]
fn palette_ctrl_c_cancels_without_quitting_app() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "wq");
let cc = ctrl_key('c');
if app.command_field.is_some()
&& cc.code == KeyCode::Char('c')
&& cc.modifiers.contains(KeyModifiers::CONTROL)
{
app.command_field = None;
}
assert!(app.command_field.is_none());
assert!(!app.exit_requested);
}
fn type_search(app: &mut App, text: &str) {
for c in text.chars() {
app.handle_search_field_key(key(KeyCode::Char(c)));
}
}
fn seed_buffer(app: &mut App, content: &str) {
BufferEdit::replace_all(app.active_mut().editor.buffer_mut(), content);
}
#[test]
fn search_open_and_type_drives_live_preview() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foo bar foo baz");
app.open_search_prompt(SearchDir::Forward);
assert!(app.search_field.is_some());
type_search(&mut app, "foo");
assert_eq!(app.search_field.as_ref().unwrap().text(), "foo");
assert!(app.active().editor.search_state().pattern.is_some());
}
#[test]
fn search_more_typing_updates_pattern() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foobar foozle");
app.open_search_prompt(SearchDir::Forward);
type_search(&mut app, "foo");
let p1 = app
.active()
.editor
.search_state()
.pattern
.as_ref()
.unwrap()
.as_str()
.to_string();
type_search(&mut app, "z");
let p2 = app
.active()
.editor
.search_state()
.pattern
.as_ref()
.unwrap()
.as_str()
.to_string();
assert_ne!(p1, p2, "pattern must update on further typing");
}
#[test]
fn search_motion_in_normal_edits_prompt_and_updates_highlight() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "alpha beta\ngamma");
app.open_search_prompt(SearchDir::Forward);
type_search(&mut app, "alpha beta");
app.handle_search_field_key(key(KeyCode::Esc));
assert_eq!(
app.search_field.as_ref().unwrap().vim_mode(),
VimMode::Normal
);
app.handle_search_field_key(key(KeyCode::Char('b')));
app.handle_search_field_key(key(KeyCode::Char('d')));
app.handle_search_field_key(key(KeyCode::Char('b')));
let new_text = app.search_field.as_ref().unwrap().text();
assert!(
new_text.len() < "alpha beta".len(),
"prompt text shrank: {new_text:?}"
);
}
#[test]
fn search_enter_commits_and_advances_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "alpha\nbeta\nfoo here\ndone");
app.open_search_prompt(SearchDir::Forward);
type_search(&mut app, "foo");
app.handle_search_field_key(key(KeyCode::Enter));
assert!(app.search_field.is_none());
let (row, col) = app.active().editor.cursor();
assert_eq!(row, 2);
assert_eq!(col, 0);
assert_eq!(app.active().editor.last_search(), Some("foo"));
assert!(app.active().editor.last_search_forward());
}
#[test]
fn search_esc_twice_cancels_and_clears_when_no_prior_search() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc def");
app.open_search_prompt(SearchDir::Forward);
type_search(&mut app, "abc");
app.handle_search_field_key(key(KeyCode::Esc));
assert!(app.search_field.is_some());
app.handle_search_field_key(key(KeyCode::Esc));
assert!(app.search_field.is_none());
assert!(app.active().editor.search_state().pattern.is_none());
}
#[test]
fn search_backward_prompt_uses_question_dir() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foo here\nbar there\nfoo again");
app.active_mut().editor.goto_line(3);
app.open_search_prompt(SearchDir::Backward);
type_search(&mut app, "foo");
app.handle_search_field_key(key(KeyCode::Enter));
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 0);
assert!(!app.active().editor.last_search_forward());
}
#[test]
fn runtime_nmap_registers_on_trie_and_fires() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("nmap x y");
assert!(
!app.user_keymap_records.is_empty(),
"record should be stored after nmap"
);
use crate::app::keymap::HjklMode as Mode;
use hjkl_keymap::{KeyCode as KmCode, KeyEvent as KmEvent, KeyModifiers as KmMods};
let km_ev = KmEvent::new(KmCode::Char('x'), KmMods::NONE);
let mut replay = Vec::new();
let consumed = app.dispatch_keymap_in_mode(km_ev, 1, &mut replay, Mode::Normal);
assert!(consumed, "nmap x should match and be consumed by trie");
assert!(
replay.is_empty(),
"x consumed by trie, replay should be empty"
);
}
#[test]
fn noremap_does_not_recurse_through_trie() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("nmap b y"); app.dispatch_ex("nnoremap a b");
use crate::app::keymap::HjklMode as Mode;
use hjkl_keymap::{KeyCode as KmCode, KeyEvent as KmEvent, KeyModifiers as KmMods};
let km_ev = KmEvent::new(KmCode::Char('a'), KmMods::NONE);
let mut replay = Vec::new();
let consumed = app.dispatch_keymap_in_mode(km_ev, 1, &mut replay, Mode::Normal);
assert!(consumed, "nnoremap a should match");
}
#[test]
fn imap_jj_enters_normal_mode() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("imap jj <Esc>");
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
assert_eq!(app.active().editor.vim_mode(), VimMode::Insert);
use crate::app::keymap::HjklMode as Mode;
use hjkl_keymap::{KeyCode as KmCode, KeyEvent as KmEvent, KeyModifiers as KmMods};
let j_ev = KmEvent::new(KmCode::Char('j'), KmMods::NONE);
let mut replay = Vec::new();
let consumed = app.dispatch_keymap_in_mode(j_ev, 1, &mut replay, Mode::Insert);
assert!(
consumed,
"first j should be pending (chord not yet complete)"
);
assert_eq!(app.active().editor.vim_mode(), VimMode::Insert);
let consumed = app.dispatch_keymap_in_mode(j_ev, 1, &mut replay, Mode::Insert);
assert!(consumed, "second j should match imap jj");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Normal,
"imap jj <Esc> should leave Insert mode"
);
}
#[test]
fn list_user_maps_excludes_builtin_chords() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("nmap a b");
app.dispatch_ex("imap c d");
app.dispatch_ex("nmap");
let popup = app.info_popup.as_deref().unwrap_or("");
assert!(popup.contains('a'), "should list `a` Normal mapping");
assert!(
!popup.contains("<leader>f"),
"must not list built-in <leader>f"
);
assert!(!popup.contains('c'), "imap c must not appear in nmap list");
app.dispatch_ex("imap");
let popup = app.info_popup.as_deref().unwrap_or("");
assert!(popup.contains('c'), "imap listing should contain `c`");
}
#[test]
fn cyclic_recursive_map_bails_without_stack_overflow() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("nmap a a");
use crate::app::keymap::HjklMode as Mode;
use hjkl_keymap::{KeyCode as KmCode, KeyEvent as KmEvent, KeyModifiers as KmMods};
let km_ev = KmEvent::new(KmCode::Char('a'), KmMods::NONE);
let mut replay = Vec::new();
let consumed = app.dispatch_keymap_in_mode(km_ev, 1, &mut replay, Mode::Normal);
assert!(consumed, "nmap a should match and consume");
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("E223"),
"expected E223 recursive-mapping error, got: {msg:?}"
);
assert_eq!(
app.replay_depth, 0,
"replay_depth must return to 0 after cycle bail"
);
}
#[test]
fn unmap_removes_from_trie() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("nmap a b");
app.dispatch_ex("nunmap a");
use crate::app::keymap::HjklMode as Mode;
use hjkl_keymap::{KeyCode as KmCode, KeyEvent as KmEvent, KeyModifiers as KmMods};
let km_ev = KmEvent::new(KmCode::Char('a'), KmMods::NONE);
let mut replay = Vec::new();
let consumed = app.dispatch_keymap_in_mode(km_ev, 1, &mut replay, Mode::Normal);
assert!(!consumed, "unmapped `a` should be unbound");
assert_eq!(replay.len(), 1, "unbound key should be in replay");
}
#[test]
fn colon_nmap_via_extracted_handler() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("nmap <leader>x :w<CR>");
assert!(
!app.user_keymap_records.is_empty(),
"record should be stored after nmap via extracted handler"
);
use crate::app::keymap::HjklMode as Mode;
use hjkl_keymap::{KeyCode as KmCode, KeyEvent as KmEvent, KeyModifiers as KmMods};
let leader = app.config.editor.leader;
let leader_ev = KmEvent::new(KmCode::Char(leader), KmMods::NONE);
let x_ev = KmEvent::new(KmCode::Char('x'), KmMods::NONE);
let mut replay = Vec::new();
let pending = app.dispatch_keymap_in_mode(leader_ev, 1, &mut replay, Mode::Normal);
assert!(
pending,
"<leader> should be pending (chord not yet complete)"
);
let consumed = app.dispatch_keymap_in_mode(x_ev, 1, &mut replay, Mode::Normal);
assert!(consumed, "<leader>x should be consumed by trie");
}
#[test]
fn colon_unmap_via_extracted_handler() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("nmap a b");
app.dispatch_ex("unmap a");
use crate::app::keymap::HjklMode as Mode;
use hjkl_keymap::{KeyCode as KmCode, KeyEvent as KmEvent, KeyModifiers as KmMods};
let km_ev = KmEvent::new(KmCode::Char('a'), KmMods::NONE);
let mut replay = Vec::new();
let consumed = app.dispatch_keymap_in_mode(km_ev, 1, &mut replay, Mode::Normal);
assert!(
!consumed,
"unmapped `a` should be unbound after unmap via extracted handler"
);
}
#[test]
fn colon_mapclear_via_extracted_handler() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("nmap a b");
app.dispatch_ex("nmap c d");
assert_eq!(
app.user_keymap_records.len(),
2,
"two records before mapclear"
);
app.dispatch_ex("mapclear");
assert!(
app.user_keymap_records.is_empty(),
"user_keymap_records should be empty after mapclear via extracted handler"
);
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("cleared"), "status should confirm clear");
}
#[test]
fn colon_map_list_via_extracted_handler() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("nmap p q");
app.dispatch_ex("map");
assert!(
app.info_popup.is_some(),
"info_popup should be set after bare `map` via extracted handler"
);
let popup = app.info_popup.as_deref().unwrap_or("");
assert!(popup.contains('p'), "popup should list the `p` binding");
}
#[test]
fn app_new_no_file() {
let app = App::new(None, false, None, None).unwrap();
assert!(!app.active().dirty);
assert!(!app.active().is_new_file);
assert!(app.active().filename.is_none());
assert!(!app.active().editor.is_readonly());
}
#[test]
fn app_new_readonly_flag() {
let app = App::new(None, true, None, None).unwrap();
assert!(app.active().editor.is_readonly());
}
#[test]
fn app_new_not_found_sets_is_new_file() {
let path = tmp_path("hjkl_phase5_nonexistent_abc123.txt");
let _ = std::fs::remove_file(&path);
let app = App::new(Some(path), false, None, None).unwrap();
assert!(app.active().is_new_file);
assert!(!app.active().dirty);
}
#[test]
fn app_new_goto_line_clamps() {
let app = App::new(None, false, Some(999), None).unwrap();
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 0);
}
#[test]
fn ex_goto_line_100_via_dispatch() {
let mut app = App::new(None, false, None, None).unwrap();
let buf: String = (1..=120)
.map(|n| format!("line{n}"))
.collect::<Vec<_>>()
.join("\n");
use hjkl_buffer::{Edit, Position};
app.active_mut().editor.mutate_edit(Edit::InsertStr {
at: Position::new(0, 0),
text: buf,
});
app.active_mut().editor.jump_cursor(0, 0);
app.dispatch_ex("100");
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 99, "':100' must land on row 99");
}
#[test]
fn dot_repeat_replays_last_change() {
use crate::keymap_actions::AppAction;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use hjkl_buffer::{Edit, Position};
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().editor.mutate_edit(Edit::InsertStr {
at: Position::new(0, 0),
text: "abc".to_string(),
});
app.active_mut().editor.jump_cursor(0, 0);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
app.dispatch_action(AppAction::DotRepeat { count: 1 }, 1);
let line0 = app.active().editor.buffer().line(0).map(|l| l.to_string());
assert_eq!(
line0.as_deref(),
Some("c"),
"`.` after `x` must delete one more char, got {line0:?}"
);
}
#[test]
fn dot_repeat_with_count_3_replays_three_times() {
use crate::keymap_actions::AppAction;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use hjkl_buffer::{Edit, Position};
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().editor.mutate_edit(Edit::InsertStr {
at: Position::new(0, 0),
text: "abcdef".to_string(),
});
app.active_mut().editor.jump_cursor(0, 0);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
app.pending_count.try_accumulate('3');
app.dispatch_action(AppAction::DotRepeat { count: 1 }, 1);
let line0 = app.active().editor.buffer().line(0).map(|l| l.to_string());
assert_eq!(
line0.as_deref(),
Some("ef"),
"`3.` after `x` must delete 3 more chars, got {line0:?}"
);
}
#[test]
fn ex_goto_line_100_via_command_field_keys() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use hjkl_buffer::{Edit, Position};
let mut app = App::new(None, false, None, None).unwrap();
let buf: String = (1..=120)
.map(|n| format!("line{n}"))
.collect::<Vec<_>>()
.join("\n");
app.active_mut().editor.mutate_edit(Edit::InsertStr {
at: Position::new(0, 0),
text: buf,
});
app.active_mut().editor.jump_cursor(0, 0);
app.open_command_prompt();
for c in ['1', '0', '0'] {
app.handle_command_field_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
app.handle_command_field_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (row, _col) = app.active().editor.cursor();
assert_eq!(
row, 99,
"':100<Enter>' via command-field must land on row 99, got {row}"
);
let fw = app.focused_window();
let win = app.windows[fw].as_ref().unwrap();
assert_eq!(
win.cursor_row, 99,
"window cache cursor_row must follow engine cursor after `:100`"
);
}
#[test]
fn do_save_readonly_blocked() {
let mut app = App::new(None, true, None, None).unwrap();
app.active_mut().filename = Some(tmp_path("hjkl_phase5_ro_test.txt"));
app.do_save(None);
let msg = app.status_message.unwrap_or_default();
assert!(
msg.contains("E45"),
"expected E45 readonly error, got: {msg}"
);
}
#[test]
fn do_save_no_filename_e32() {
let mut app = App::new(None, false, None, None).unwrap();
app.do_save(None);
let msg = app.status_message.unwrap_or_default();
assert!(msg.contains("E32"), "expected E32, got: {msg}");
}
#[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.status_message.clone().unwrap_or_default();
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.status_message.clone().unwrap_or_default();
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 colon_set_background_dark_swaps_theme() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set background=dark");
let msg = app.status_message.clone().unwrap_or_default();
assert_eq!(
msg, "background=dark",
"expected background=dark, got: {msg}"
);
}
#[test]
fn colon_set_background_light_swaps_theme() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set background=light");
let msg = app.status_message.clone().unwrap_or_default();
assert_eq!(
msg, "background=light",
"expected background=light, got: {msg}"
);
}
#[test]
fn colon_set_background_unknown_errors() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set background=mauve");
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.starts_with("E:"),
"expected E: error for unknown background, got: {msg}"
);
}
#[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().lines();
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().lines(), 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.status_message.unwrap_or_default();
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().lines(),
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::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
hjkl_vim::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::handle_key(&mut app.active_mut().editor, key(KeyCode::Esc));
hjkl_vim::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 esc_on_empty_command_prompt_dismisses() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
assert!(app.command_field.is_some());
app.handle_command_field_key(key(KeyCode::Esc));
assert!(
app.command_field.is_none(),
"empty : prompt should close on Esc"
);
}
#[test]
fn esc_on_nonempty_command_drops_to_normal_then_closes() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
app.handle_command_field_key(key(KeyCode::Char('w')));
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.command_field.is_some());
assert_eq!(
app.command_field.as_ref().unwrap().vim_mode(),
VimMode::Normal
);
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.command_field.is_none());
}
#[test]
fn esc_on_empty_search_prompt_dismisses() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_search_prompt(SearchDir::Forward);
assert!(app.search_field.is_some());
app.handle_search_field_key(key(KeyCode::Esc));
assert!(
app.search_field.is_none(),
"empty / prompt should close on Esc"
);
}
#[test]
fn edit_no_arg_no_filename_e32() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("e");
let msg = app.status_message.unwrap_or_default();
assert!(msg.contains("E32"), "expected E32, got: {msg}");
}
#[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().lines(),
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.status_message.clone().unwrap_or_default();
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().lines(), vec!["a".to_string()]);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_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().lines();
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 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.status_message.clone().unwrap_or_default();
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 buffer_source_new_produces_n_entries() {
let path_a = std::env::temp_dir().join("hjkl_d4_src_a.txt");
let path_b = std::env::temp_dir().join("hjkl_d4_src_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);
let source = Box::new(crate::picker::BufferSource::new(
&app.slots,
|s| {
s.filename
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("[No Name]")
.to_owned()
},
|s| s.dirty,
|s| s.editor.buffer().as_string(),
|s| s.filename.clone(),
|s| s.editor.buffer().cursor().row,
|_| 0,
));
let mut picker = crate::picker::Picker::new(source);
picker.refresh();
assert_eq!(picker.total(), 2, "expected 2 entries");
assert!(picker.scan_done(), "scan_done must be set");
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn buffer_source_select_returns_switch_buffer() {
use crate::picker::{BufferSource, PickerAction, PickerLogic};
use crate::picker_action::AppAction;
let path = std::env::temp_dir().join("hjkl_d4_sel.txt");
std::fs::write(&path, "x\n").unwrap();
let app = App::new(Some(path.clone()), false, None, None).unwrap();
let source = BufferSource::new(
&app.slots,
|s| {
s.filename
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("[No Name]")
.to_owned()
},
|s| s.dirty,
|s| s.editor.buffer().as_string(),
|s| s.filename.clone(),
|s| s.editor.buffer().cursor().row,
|_| 0,
);
match source.select(0) {
PickerAction::Custom(b) => {
let a = b
.downcast::<AppAction>()
.expect("should downcast to AppAction");
assert!(matches!(*a, AppAction::SwitchSlot(0)));
}
_ => panic!("expected Custom(AppAction::SwitchSlot(0))"),
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn edit_drops_pristine_default_buffer_when_first_real_file_opens() {
let path = std::env::temp_dir().join("hjkl_drop_pristine.txt");
std::fs::write(&path, "hello\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.slots.len(), 1);
assert!(app.active().filename.is_none());
app.dispatch_ex(&format!("e {}", path.display()));
assert_eq!(
app.slots.len(),
1,
"pristine default buffer should have been dropped"
);
assert_eq!(app.active_index(), 0);
assert_eq!(
app.active().filename.as_deref(),
Some(path.as_path()),
"active slot should now be the opened file"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn edit_keeps_dirty_default_buffer_when_opening_real_file() {
let path = std::env::temp_dir().join("hjkl_keep_dirty_default.txt");
std::fs::write(&path, "hello\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.slots[0].dirty = true;
app.dispatch_ex(&format!("e {}", path.display()));
assert_eq!(
app.slots.len(),
2,
"dirty unnamed buffer must not be dropped silently"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn open_extra_adds_slot_and_leaves_active_zero() {
let path_a = std::env::temp_dir().join("hjkl_open_extra_a.txt");
let path_b = std::env::temp_dir().join("hjkl_open_extra_b.txt");
std::fs::write(&path_a, "first\n").unwrap();
std::fs::write(&path_b, "second\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
assert_eq!(app.slots.len(), 1);
assert_eq!(app.active_index(), 0);
app.open_extra(path_b.clone()).unwrap();
assert_eq!(app.slots.len(), 2, "extra slot should have been added");
assert_eq!(
app.active_index(),
0,
"active must stay at 0 after open_extra"
);
assert_eq!(
app.slots[0].editor.buffer().lines(),
vec!["first".to_string()]
);
assert_eq!(
app.slots[1].editor.buffer().lines(),
vec!["second".to_string()]
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn ls_lists_all_buffers_with_active_marker() {
let path_a = std::env::temp_dir().join("hjkl_phc_ls_a.txt");
let path_b = std::env::temp_dir().join("hjkl_phc_ls_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("ls");
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("1: "), "expected slot 1 entry, got: {msg}");
assert!(
msg.contains("2:%"),
"active marker missing on slot 2: {msg}"
);
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.status_message.clone().unwrap_or_default();
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.status_message.clone().unwrap_or_default();
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.status_message.clone().unwrap_or_default();
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 start_screen_present_when_no_file() {
let app = App::new(None, false, None, None).unwrap();
assert!(
app.start_screen.is_some(),
"start_screen must be Some when no file given"
);
}
#[test]
fn start_screen_absent_when_file_given() {
let path = std::env::temp_dir().join("hjkl_splash_with_file.txt");
std::fs::write(&path, "x\n").unwrap();
let app = App::new(Some(path.clone()), false, None, None).unwrap();
assert!(
app.start_screen.is_none(),
"start_screen must be None when file given"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn mode_label_returns_start_during_splash() {
let app = App::new(None, false, None, None).unwrap();
assert!(app.start_screen.is_some());
assert_eq!(app.mode_label(), "START");
}
#[test]
fn with_config_updates_leader_and_reapplies_to_existing_slot() {
let app = App::new(None, false, None, None).unwrap();
assert_eq!(app.config.editor.leader, ' ');
let mut cfg = crate::config::Config::default();
cfg.editor.leader = '\\';
cfg.editor.tab_width = 2;
let app = app.with_config(cfg);
assert_eq!(app.config.editor.leader, '\\');
assert_eq!(app.config.editor.tab_width, 2);
assert_eq!(
app.slots.len(),
1,
"with_config should not add or drop slots"
);
}
#[test]
fn with_config_preserves_readonly_on_existing_slot() {
let app = App::new(None, true, None, None).unwrap();
assert!(app.active().editor.is_readonly());
let app = app.with_config(crate::config::Config::default());
assert!(
app.active().editor.is_readonly(),
"readonly state must survive with_config re-application"
);
}
#[test]
fn config_load_from_disk_then_with_config_propagates_overrides() {
use std::io::Write as _;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(
tmp,
r#"
[editor]
leader = "\\"
tab_width = 2
[theme]
name = "dark"
"#
)
.unwrap();
let cfg = crate::config::load_from(tmp.path()).expect("load_from must succeed");
assert_eq!(cfg.editor.huge_file_threshold, 50_000);
assert!(cfg.editor.expandtab);
assert_eq!(cfg.editor.leader, '\\');
assert_eq!(cfg.editor.tab_width, 2);
use hjkl_config::Validate;
cfg.validate()
.expect("merged user+default config must validate");
let app = App::new(None, false, None, None).unwrap().with_config(cfg);
assert_eq!(app.config.editor.leader, '\\');
assert_eq!(app.config.editor.tab_width, 2);
}
#[test]
fn config_load_from_disk_validation_failure_surfaces() {
use std::io::Write as _;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp, "[editor]\nhuge_file_threshold = 0").unwrap();
let cfg = crate::config::load_from(tmp.path()).expect("parse must succeed");
use hjkl_config::Validate;
let err = cfg.validate().unwrap_err();
assert_eq!(err.field, "editor.huge_file_threshold");
}
#[test]
fn open_git_status_picker_sets_picker() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none());
app.open_git_status_picker();
assert!(
app.picker.is_some(),
"picker should be open after open_git_status_picker"
);
}
#[test]
fn git_status_picker_title_is_git_status() {
use crate::picker_git::GitStatusPicker;
use hjkl_picker::PickerLogic;
let tmp = tempfile::tempdir().unwrap();
let source = GitStatusPicker::new(tmp.path().to_path_buf());
assert_eq!(source.title(), "git status");
}
#[test]
fn git_log_picker_opens() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none());
app.open_git_log_picker();
assert!(
app.picker.is_some(),
"picker should be open after open_git_log_picker"
);
}
#[test]
fn git_log_picker_title_is_git_log() {
use crate::picker_git::GitLogPicker;
use hjkl_picker::PickerLogic;
let tmp = tempfile::tempdir().unwrap();
let source = GitLogPicker::new(tmp.path().to_path_buf());
assert_eq!(source.title(), "git log");
}
#[test]
fn git_branch_picker_opens() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none());
app.open_git_branch_picker();
assert!(
app.picker.is_some(),
"picker should be open after open_git_branch_picker"
);
}
#[test]
fn git_branch_picker_title_is_git_branches() {
use crate::picker_git::GitBranchPicker;
use hjkl_picker::PickerLogic;
let tmp = tempfile::tempdir().unwrap();
let source = GitBranchPicker::new(tmp.path().to_path_buf());
assert_eq!(source.title(), "git branches");
}
#[test]
fn git_file_history_picker_opens() {
let path = std::env::temp_dir().join("hjkl_gB_smoke.txt");
std::fs::write(&path, "content\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
assert!(app.picker.is_none());
app.open_git_file_history_picker();
let _ = std::fs::remove_file(&path);
}
#[test]
fn git_file_history_picker_no_path_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.active().filename.is_none());
app.open_git_file_history_picker();
assert!(app.picker.is_none(), "picker must not open without a path");
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("no path"),
"expected 'no path' status message, got: {msg:?}"
);
}
#[test]
fn git_file_history_picker_title_is_git_file_history() {
use crate::picker_git::GitFileHistoryPicker;
use hjkl_picker::PickerLogic;
let tmp = tempfile::tempdir().unwrap();
let source = GitFileHistoryPicker::new(
tmp.path().to_path_buf(),
std::path::PathBuf::from("src/main.rs"),
);
assert_eq!(source.title(), "git file history");
}
#[test]
fn git_status_picker_no_repo_scan_produces_sentinel_or_empty() {
use crate::picker_git::GitStatusPicker;
use hjkl_picker::PickerLogic;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
let tmp = tempfile::tempdir().unwrap();
let mut source = GitStatusPicker::new(tmp.path().to_path_buf());
let cancel = Arc::new(AtomicBool::new(false));
let handle = source.enumerate(None, Arc::clone(&cancel));
if let Some(h) = handle {
let _ = h.join();
}
let count = source.item_count();
if count > 0 {
let label = source.label(0);
assert!(
label.contains("not a git repo"),
"sentinel label unexpected: {label:?}"
);
assert!(matches!(
source.select(0),
crate::picker::PickerAction::None
));
}
}
#[test]
fn git_stash_picker_opens() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none());
app.open_git_stash_picker();
assert!(
app.picker.is_some(),
"picker should be open after open_git_stash_picker"
);
}
#[test]
fn git_stash_picker_title_is_git_stashes() {
use crate::picker_git::GitStashPicker;
use hjkl_picker::PickerLogic;
let tmp = tempfile::tempdir().unwrap();
let source = GitStashPicker::new(tmp.path().to_path_buf());
assert_eq!(source.title(), "git stashes");
}
#[test]
fn git_stash_picker_shift_s_chord_dispatches() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none());
app.open_git_stash_picker();
assert!(app.picker.is_some(), "S chord must open the stash picker");
assert_eq!(app.picker.as_ref().unwrap().title(), "git stashes");
}
#[test]
fn git_tags_picker_opens() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none());
app.open_git_tags_picker();
assert!(
app.picker.is_some(),
"picker should be open after open_git_tags_picker"
);
}
#[test]
fn git_tags_picker_title_is_git_tags() {
use crate::picker_git::GitTagsPicker;
use hjkl_picker::PickerLogic;
let tmp = tempfile::tempdir().unwrap();
let source = GitTagsPicker::new(tmp.path().to_path_buf());
assert_eq!(source.title(), "git tags");
}
#[test]
fn git_tags_picker_no_repo_produces_sentinel() {
use crate::picker_git::GitTagsPicker;
use hjkl_picker::PickerLogic;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
let tmp = tempfile::tempdir().unwrap();
let mut source = GitTagsPicker::new(tmp.path().to_path_buf());
let cancel = Arc::new(AtomicBool::new(false));
let handle = source.enumerate(None, Arc::clone(&cancel));
if let Some(h) = handle {
let _ = h.join();
}
let count = source.item_count();
assert!(count > 0, "should have at least a sentinel item");
let label = source.label(0);
assert!(
label.contains("no tags") || label.contains("not a git repo"),
"sentinel label unexpected: {label:?}"
);
assert!(matches!(
source.select(0),
crate::picker::PickerAction::None
));
}
#[test]
fn git_remotes_picker_opens() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none());
app.open_git_remotes_picker();
assert!(
app.picker.is_some(),
"picker should be open after open_git_remotes_picker"
);
}
#[test]
fn git_remotes_picker_title_is_git_remotes() {
use crate::picker_git::GitRemotesPicker;
use hjkl_picker::PickerLogic;
let tmp = tempfile::tempdir().unwrap();
let source = GitRemotesPicker::new(tmp.path().to_path_buf());
assert_eq!(source.title(), "git remotes");
}
#[test]
fn git_remotes_picker_no_repo_produces_sentinel() {
use crate::picker_git::GitRemotesPicker;
use hjkl_picker::PickerLogic;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
let tmp = tempfile::tempdir().unwrap();
let mut source = GitRemotesPicker::new(tmp.path().to_path_buf());
let cancel = Arc::new(AtomicBool::new(false));
let handle = source.enumerate(None, Arc::clone(&cancel));
if let Some(h) = handle {
let _ = h.join();
}
let count = source.item_count();
assert!(count > 0, "should have at least a sentinel item");
let label = source.label(0);
assert!(
label.contains("no remotes") || label.contains("not a git repo"),
"sentinel label unexpected: {label:?}"
);
assert!(matches!(
source.select(0),
crate::picker::PickerAction::None
));
}
#[test]
fn picker_action_custom_downcasts_to_app_action() {
use crate::picker_action::AppAction;
use hjkl_picker::PickerAction;
let action = PickerAction::Custom(Box::new(AppAction::SwitchSlot(2)));
if let PickerAction::Custom(b) = action {
let recovered = b.downcast::<AppAction>().expect("should downcast");
assert!(matches!(*recovered, AppAction::SwitchSlot(2)));
} else {
panic!("expected Custom");
}
}
fn write_and_wait(path: &std::path::Path, content: &str) {
std::fs::write(path, content).unwrap();
std::thread::sleep(Duration::from_millis(50));
}
#[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().lines(), vec!["line1", "line2"]);
write_and_wait(&path, "new content\n");
app.checktime_all();
assert_eq!(
app.active().editor.buffer().lines(),
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().lines(),
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_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().lines(), 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().lines(),
vec!["v2"],
"recreated file should be reloaded"
);
assert_eq!(app.active().disk_state, DiskState::Synced);
let _ = std::fs::remove_file(&path);
}
#[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().lines().to_vec();
assert_eq!(
lines,
vec!["bar bar", "bar"],
"buffer should be fully substituted"
);
let msg = app.status_message.clone().unwrap_or_default();
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().lines().to_vec();
assert_eq!(lines[0], "bar foo", "only first occurrence on current line");
assert_eq!(lines[1], "foo", "second line unchanged");
let msg = app.status_message.clone().unwrap_or_default();
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().lines().to_vec();
assert_eq!(
lines[0], "hello planet",
"should replace using last search pattern"
);
let msg = app.status_message.clone().unwrap_or_default();
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().lines().to_vec();
assert_eq!(lines[0], "hello world", "buffer should be unchanged");
let msg = app.status_message.clone().unwrap_or_default();
assert_eq!(msg, "Pattern not found", "status: {msg}");
}
#[test]
fn poll_grammar_loads_clears_expired_error() {
let mut app = App::new(None, false, None, None).unwrap();
app.grammar_load_error = Some(GrammarLoadError {
name: "fake-lang".to_string(),
message: "connection refused".to_string(),
at: std::time::Instant::now() - Duration::from_secs(10),
});
let needs_redraw = app.poll_grammar_loads();
assert!(needs_redraw, "expired error should request redraw");
assert!(
app.grammar_load_error.is_none(),
"expired error should be cleared"
);
}
#[test]
fn sp_splits_focused_window() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.windows.len(), 1);
assert_eq!(app.focused_window(), 0);
app.dispatch_ex("sp");
assert_eq!(
app.windows.iter().filter(|w| w.is_some()).count(),
2,
"expected 2 open windows after :sp"
);
let new_win_id = app.focused_window();
assert_ne!(new_win_id, 0, "focus must have moved to the new window");
assert!(
app.layout().leaves().len() == 2,
"layout must have 2 leaves after split"
);
}
#[test]
fn close_focused_window_collapses_split() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
assert_eq!(app.windows.iter().filter(|w| w.is_some()).count(), 2);
let focused_before_close = app.focused_window();
app.dispatch_ex("close");
assert!(
app.windows[focused_before_close].is_none(),
"closed window entry must be None"
);
assert_eq!(
app.layout().leaves().len(),
1,
"layout must collapse to 1 leaf"
);
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("window closed"),
"expected 'window closed' status"
);
}
#[test]
fn close_last_window_errors() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.windows.iter().filter(|w| w.is_some()).count(), 1);
app.dispatch_ex("close");
assert_eq!(
app.windows.iter().filter(|w| w.is_some()).count(),
1,
"last window must not be closed"
);
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("E444"), "expected E444 error, got: {msg}");
}
#[test]
fn ctrl_w_j_focuses_below() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
let top_win = app.focused_window();
app.focus_below();
let bottom_win = app.focused_window();
assert_ne!(top_win, bottom_win, "focus must have moved down");
app.focus_below();
assert_eq!(
app.focused_window(),
bottom_win,
"focus must not move below the bottom-most window"
);
}
#[test]
fn ctrl_w_k_focuses_above() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
app.focus_below();
let bottom_win = app.focused_window();
app.focus_above();
let top_win = app.focused_window();
assert_ne!(bottom_win, top_win, "focus must have moved up");
app.focus_above();
assert_eq!(
app.focused_window(),
top_win,
"focus must not move above the top-most window"
);
}
#[test]
fn non_focused_window_keeps_scroll_after_focused_scrolls() {
let mut app = App::new(None, false, None, None).unwrap();
let content: String = (0..100)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
seed_buffer(&mut app, &content);
app.dispatch_ex("sp");
let top_win = app.focused_window();
app.focus_below();
let bottom_win = app.focused_window();
assert_ne!(top_win, bottom_win);
let top_top_row_before = app.windows[top_win].as_ref().unwrap().top_row;
app.windows[bottom_win].as_mut().unwrap().top_row = 20;
let top_top_row_after = app.windows[top_win].as_ref().unwrap().top_row;
assert_eq!(
top_top_row_before, top_top_row_after,
"non-focused window scroll must not change when focused window scrolls"
);
}
#[test]
fn vsp_creates_vertical_split_with_new_on_left() {
let mut app = App::new(None, false, None, None).unwrap();
let original_win = app.focused_window();
app.dispatch_ex("vsp");
assert_eq!(
app.windows.iter().filter(|w| w.is_some()).count(),
2,
"expected 2 open windows after :vsp"
);
assert_eq!(app.layout().leaves().len(), 2, "layout must have 2 leaves");
let new_win = app.focused_window();
assert_ne!(new_win, original_win, "focus must have moved to new window");
let right = app.layout().neighbor_right(new_win);
assert_eq!(
right,
Some(original_win),
"original window must be to the right of the new one"
);
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("vsplit"),
"expected 'vsplit' status, got: {msg}"
);
}
#[test]
fn vnew_creates_empty_buffer_in_left_split() {
let mut app = App::new(None, false, None, None).unwrap();
let original_win = app.focused_window();
app.dispatch_ex("vnew");
assert_eq!(
app.windows.iter().filter(|w| w.is_some()).count(),
2,
"expected 2 open windows after :vnew"
);
assert_eq!(app.layout().leaves().len(), 2);
let new_win = app.focused_window();
assert_ne!(new_win, original_win);
let new_slot_idx = app.windows[new_win].as_ref().unwrap().slot;
assert!(
app.slots[new_slot_idx].filename.is_none(),
"vnew window must point to an unnamed slot"
);
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("vnew"), "expected 'vnew' status, got: {msg}");
}
#[test]
fn ctrl_w_lt_resize_width_negative_registers() {
use crate::app::keymap::HjklMode as Mode;
use hjkl_keymap::{
Chord, KeyCode, KeyEvent as KmKeyEvent, KeyModifiers as KmKeyMods, KeyResolve,
};
let mut app = App::new(None, false, None, None).unwrap();
let ctrl_w = KmKeyEvent::new(KeyCode::Char('w'), KmKeyMods::CTRL);
let lt = KmKeyEvent::new(KeyCode::Char('<'), KmKeyMods::NONE);
let chord = Chord(vec![ctrl_w, lt]);
let kids = app.app_keymap.children(Mode::Normal, &Chord(vec![ctrl_w]));
assert!(
kids.iter().any(|(k, _)| *k == lt),
"<C-w><lt> binding must register; kids: {kids:?}"
);
let r1 = app
.app_keymap
.feed(Mode::Normal, ctrl_w, std::time::Instant::now());
assert!(matches!(r1, KeyResolve::Pending | KeyResolve::Ambiguous));
let r2 = app
.app_keymap
.feed(Mode::Normal, lt, std::time::Instant::now());
match r2 {
KeyResolve::Match(binding) => {
assert!(matches!(
binding.action,
crate::keymap_actions::AppAction::ResizeWidth(-1)
));
}
other => panic!("expected Match(ResizeWidth(-1)), got {other:?}"),
}
let _ = chord;
}
#[test]
fn ctrl_w_h_focuses_left() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("vsp");
let left_win = app.focused_window();
app.focus_left();
assert_eq!(
app.focused_window(),
left_win,
"focus_left from leftmost must be a no-op"
);
app.focus_right();
let right_win = app.focused_window();
assert_ne!(left_win, right_win, "focus must have moved right");
app.focus_left();
assert_eq!(
app.focused_window(),
left_win,
"focus_left must return to left window"
);
}
#[test]
fn ctrl_w_l_focuses_right() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("vsp");
let left_win = app.focused_window();
app.focus_right();
let right_win = app.focused_window();
assert_ne!(left_win, right_win, "focus must have moved right");
app.focus_right();
assert_eq!(
app.focused_window(),
right_win,
"focus_right from rightmost must be a no-op"
);
}
#[test]
fn ctrl_w_w_cycles_next() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
let leaves = app.layout().leaves();
assert_eq!(leaves.len(), 2);
let initial = app.focused_window();
app.focus_next();
let after_one = app.focused_window();
assert_ne!(initial, after_one, "focus_next must move focus");
app.focus_next();
let after_two = app.focused_window();
assert_eq!(after_two, initial, "two focus_next calls must wrap around");
}
#[test]
fn ctrl_w_shift_w_cycles_previous() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
let initial = app.focused_window();
app.focus_previous();
let after_one = app.focused_window();
assert_ne!(initial, after_one, "focus_previous must move focus");
app.focus_previous();
let after_two = app.focused_window();
assert_eq!(
after_two, initial,
"two focus_previous calls must wrap around"
);
}
fn inject_split_rect(
layout: &mut window::LayoutTree,
id: window::WindowId,
rect: ratatui::layout::Rect,
) {
if let window::LayoutTree::Split {
a, b, last_rect, ..
} = layout
&& (a.contains(id) || b.contains(id))
{
*last_rect = Some(rect);
if let window::LayoutTree::Split { .. } = a.as_mut() {
inject_split_rect(a, id, rect);
}
if let window::LayoutTree::Split { .. } = b.as_mut() {
inject_split_rect(b, id, rect);
}
}
}
#[test]
fn resize_height_grows_focused_window() {
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.resize_height(2);
let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
assert!(
ratio_after > ratio_before,
"ratio should grow when resizing focused (top) window: before={ratio_before} after={ratio_after}"
);
}
#[test]
fn resize_height_clamps_at_minimum() {
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: 10,
};
let fw = app.focused_window();
inject_split_rect(app.layout_mut(), fw, rect);
app.resize_height(-1000);
let ratio = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
assert!(ratio >= 0.01, "ratio must be >= 0.01 after clamp: {ratio}");
assert!(
ratio < 1.0,
"ratio must be < 1.0 (sibling needs at least 1 row): {ratio}"
);
}
#[test]
fn resize_width_grows_focused_window() {
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.resize_width(4);
let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
assert!(
ratio_after > ratio_before,
"ratio should grow when resizing focused (left) window: before={ratio_before} after={ratio_after}"
);
}
#[test]
fn equalize_layout_resets_uneven_splits() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
if let window::LayoutTree::Split { ratio, .. } = app.layout_mut() {
*ratio = 0.3;
}
app.equalize_layout();
let ratio = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
assert!(
(ratio - 0.5).abs() < 1e-5,
"equalize should reset ratio to 0.5, got {ratio}"
);
}
#[test]
fn maximize_height_collapses_siblings() {
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: 24,
};
let fw = app.focused_window();
inject_split_rect(app.layout_mut(), fw, rect);
app.maximize_height();
let ratio = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
let expected = 23.0_f32 / 24.0;
assert!(
(ratio - expected).abs() < 0.05,
"maximize_height should set ratio near {expected}, got {ratio}"
);
}
#[test]
fn ctrl_w_plus_grows_focused() {
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.resize_height(1);
let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
assert!(
ratio_after > ratio_before,
"Ctrl-w + must grow the focused window: before={ratio_before} after={ratio_after}"
);
}
#[test]
fn only_drops_other_windows() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
app.dispatch_ex("sp");
assert_eq!(
app.layout().leaves().len(),
3,
"expected 3 windows before :only"
);
let focused = app.focused_window();
app.dispatch_ex("only");
assert_eq!(
app.layout().leaves(),
vec![focused],
"only focused leaf should remain"
);
let open_count = app.windows.iter().filter(|w| w.is_some()).count();
assert_eq!(open_count, 1, "exactly one window must remain open");
assert!(
app.windows[focused].is_some(),
"focused window must still be open"
);
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("only"), "expected 'only' status, got: {msg}");
}
#[test]
fn only_no_op_with_single_window() {
let mut app = App::new(None, false, None, None).unwrap();
let focused = app.focused_window();
app.dispatch_ex("only");
assert_eq!(app.layout().leaves(), vec![focused]);
assert_eq!(app.windows.iter().filter(|w| w.is_some()).count(), 1);
}
#[test]
fn new_creates_horizontal_split_empty_buffer() {
let mut app = App::new(None, false, None, None).unwrap();
let original_win = app.focused_window();
app.dispatch_ex("new");
assert_eq!(
app.windows.iter().filter(|w| w.is_some()).count(),
2,
"expected 2 open windows after :new"
);
assert_eq!(app.layout().leaves().len(), 2, "layout must have 2 leaves");
let new_win = app.focused_window();
assert_ne!(new_win, original_win, "focus must have moved to new window");
let new_slot_idx = app.windows[new_win].as_ref().unwrap().slot;
assert!(
app.slots[new_slot_idx].filename.is_none(),
":new window must point to an unnamed slot"
);
let below = app.layout().neighbor_below(new_win);
assert_eq!(
below,
Some(original_win),
"original window must be below the new one"
);
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("new"), "expected 'new' status, got: {msg}");
}
#[test]
fn ctrl_w_o_invokes_only() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
assert_eq!(app.layout().leaves().len(), 2);
let focused = app.focused_window();
app.only_focused_window();
assert_eq!(app.layout().leaves(), vec![focused]);
assert_eq!(app.windows.iter().filter(|w| w.is_some()).count(), 1);
}
#[test]
fn ctrl_w_x_swaps_with_sibling() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
let leaves_before = app.layout().leaves();
assert_eq!(leaves_before.len(), 2);
app.swap_with_sibling();
let leaves_after = app.layout().leaves();
assert_eq!(
leaves_after,
vec![leaves_before[1], leaves_before[0]],
"swap_with_sibling must reverse the leaf order"
);
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("swap"), "expected 'swap' status, got: {msg}");
}
#[test]
fn ctrl_w_n_creates_horizontal_empty_split() {
let mut app = App::new(None, false, None, None).unwrap();
let original_win = app.focused_window();
app.dispatch_ex("new");
assert_eq!(app.layout().leaves().len(), 2);
let new_win = app.focused_window();
assert_ne!(new_win, original_win);
let below = app.layout().neighbor_below(new_win);
assert_eq!(below, Some(original_win));
}
#[test]
fn ctrl_w_q_closes_window_when_multiple() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
assert_eq!(app.layout().leaves().len(), 2);
let focused_before = app.focused_window();
if app.layout().leaves().len() > 1 {
app.close_focused_window();
} else {
app.exit_requested = true;
}
assert!(
!app.exit_requested,
"Ctrl-w q must not quit with multiple windows"
);
assert!(
app.windows[focused_before].is_none(),
"focused window must be closed"
);
assert_eq!(app.layout().leaves().len(), 1, "layout must collapse");
}
#[test]
fn ctrl_w_q_quits_when_last() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.layout().leaves().len(), 1);
if app.layout().leaves().len() > 1 {
app.close_focused_window();
} else {
app.exit_requested = true;
}
assert!(app.exit_requested, "Ctrl-w q must quit when last window");
}
#[test]
fn colon_q_closes_window_when_multiple() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
assert_eq!(app.layout().leaves().len(), 2);
let focused_before = app.focused_window();
app.dispatch_ex("q");
assert!(
!app.exit_requested,
":q must not quit with multiple windows"
);
assert!(
app.windows[focused_before].is_none(),
"focused window must be closed by :q"
);
assert_eq!(app.layout().leaves().len(), 1, "layout must collapse to 1");
}
#[test]
fn colon_q_quits_when_last() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.layout().leaves().len(), 1);
assert!(!app.active().dirty);
app.dispatch_ex("q");
assert!(app.exit_requested, ":q on last window must exit");
}
#[test]
fn tabnew_creates_second_tab() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.tabs.len(), 1);
assert_eq!(app.active_tab, 0);
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), 2, "tabnew must create a second tab");
assert_eq!(app.active_tab, 1, "active_tab must advance to the new tab");
}
#[test]
fn tabnew_no_arg_uses_empty_buffer() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), 2);
let tab = &app.tabs[app.active_tab];
let slot_idx = app.windows[tab.focused_window].as_ref().unwrap().slot;
assert!(
app.slots[slot_idx].filename.is_none(),
"tabnew with no arg must use an unnamed buffer"
);
let lines = app.slots[slot_idx].editor.buffer().lines();
assert!(
lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()),
"tabnew buffer must be empty"
);
}
#[test]
fn tabnext_wraps_at_end() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), 3);
assert_eq!(app.active_tab, 2);
app.dispatch_ex("tabnext");
assert_eq!(app.active_tab, 0, "tabnext must wrap to the first tab");
}
#[test]
fn colon_tabnext_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
assert_eq!(app.active_tab, 1);
app.active_tab = 0;
app.dispatch_ex("tabnext");
assert_eq!(app.active_tab, 1, "tabnext must advance active_tab");
}
#[test]
fn colon_tabn_alias_via_host_registry() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
assert_eq!(app.active_tab, 1);
app.active_tab = 0;
app.dispatch_ex("tabn");
assert_eq!(app.active_tab, 1, "tabn alias must advance active_tab");
}
#[test]
fn tabprev_wraps_at_start() {
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("tabnext");
assert_eq!(app.active_tab, 0);
app.dispatch_ex("tabprev");
assert_eq!(app.active_tab, 2, "tabprev must wrap to the last tab");
}
#[test]
fn tabclose_removes_current_tab() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), 2);
assert_eq!(app.active_tab, 1);
app.dispatch_ex("tabclose");
assert_eq!(app.tabs.len(), 1, "tabclose must remove the current tab");
assert_eq!(app.active_tab, 0, "active_tab must fall back to 0");
}
#[test]
fn tabclose_last_tab_errors() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.tabs.len(), 1);
app.dispatch_ex("tabclose");
assert_eq!(app.tabs.len(), 1, "tabclose must not close the last tab");
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("E444"), "expected E444 error, got: {msg}");
}
#[test]
fn gt_switches_tab() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), 2);
app.dispatch_ex("tabprev");
assert_eq!(app.active_tab, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
drive_key(&mut app, key(KeyCode::Char('t')));
assert_eq!(app.active_tab, 1, "gt must advance to the next tab");
}
#[test]
fn gt_switches_tab_backward() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
assert_eq!(app.tabs.len(), 2);
assert_eq!(app.active_tab, 1);
drive_key(&mut app, key(KeyCode::Char('g')));
drive_key(
&mut app,
crossterm::event::KeyEvent::new(KeyCode::Char('T'), crossterm::event::KeyModifiers::SHIFT),
);
assert_eq!(app.active_tab, 0, "gT must switch to the previous tab");
}
#[test]
fn each_tab_keeps_independent_layout() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
let tab0_leaves = app.tabs[0].layout.leaves().len();
assert_eq!(tab0_leaves, 2, "tab 0 must have 2 leaves after :sp");
app.dispatch_ex("tabnew");
assert_eq!(app.active_tab, 1);
let tab1_leaves = app.tabs[1].layout.leaves().len();
assert_eq!(tab1_leaves, 1, "tab 1 must start with 1 leaf");
app.dispatch_ex("tabprev");
assert_eq!(app.active_tab, 0);
let tab0_leaves_after = app.tabs[0].layout.leaves().len();
assert_eq!(
tab0_leaves_after, 2,
"tab 0 layout must be preserved after switching tabs"
);
}
#[test]
fn tabfirst_jumps_to_first() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
assert_eq!(app.active_tab, 2);
app.dispatch_ex("tabfirst");
assert_eq!(app.active_tab, 0, "tabfirst must jump to tab 0");
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("tab 1/"),
"expected 'tab 1/N' status, got: {msg}"
);
}
#[test]
fn tabfirst_noop_when_already_first() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabfirst");
assert_eq!(app.active_tab, 0);
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("tab 1/"),
"no-op must still report position: {msg}"
);
}
#[test]
fn tabrewind_and_tabr_are_aliases_for_tabfirst() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
assert_eq!(app.active_tab, 2);
app.dispatch_ex("tabrewind");
assert_eq!(app.active_tab, 0);
app.dispatch_ex("tabnext");
app.dispatch_ex("tabr");
assert_eq!(app.active_tab, 0);
}
#[test]
fn tablast_jumps_to_last() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
app.dispatch_ex("tabfirst");
assert_eq!(app.active_tab, 0);
app.dispatch_ex("tablast");
assert_eq!(app.active_tab, 2, "tablast must jump to the last tab");
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("tab 3/3"),
"expected 'tab 3/3' status, got: {msg}"
);
}
#[test]
fn tablast_noop_when_already_last() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tablast");
assert_eq!(app.active_tab, 1);
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("tab 2/2"), "no-op must report position: {msg}");
}
#[test]
fn tabonly_drops_other_tabs() {
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("tabonly");
assert_eq!(app.tabs.len(), 1, "tabonly must close all other tabs");
assert_eq!(app.active_tab, 0, "active_tab must be reset to 0");
let msg = app.status_message.clone().unwrap_or_default();
assert_eq!(msg, "tabonly");
}
#[test]
fn tabonly_no_op_with_single_tab() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabonly");
assert_eq!(app.tabs.len(), 1, "tabonly on single tab must stay at 1");
let msg = app.status_message.clone().unwrap_or_default();
assert_eq!(msg, "tabonly", "must report success even as no-op");
}
#[test]
fn tabo_is_alias_for_tabonly() {
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("tabo");
assert_eq!(app.tabs.len(), 1);
}
#[test]
fn tabmove_no_arg_moves_to_end() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
app.dispatch_ex("tabfirst");
assert_eq!(app.active_tab, 0);
app.dispatch_ex("tabmove");
assert_eq!(app.active_tab, 2, "tab should now be at position 2 (end)");
let msg = app.status_message.clone().unwrap_or_default();
assert_eq!(msg, "tabmove");
}
#[test]
fn tabmove_to_position_zero() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
app.dispatch_ex("tabmove 0");
assert_eq!(app.active_tab, 0, "tab should now be at position 0");
}
#[test]
fn tabmove_relative_plus_one() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
app.dispatch_ex("tabfirst");
assert_eq!(app.active_tab, 0);
app.dispatch_ex("tabmove +1");
assert_eq!(app.active_tab, 1, "tab should now be at position 1");
}
#[test]
fn tabmove_relative_minus_one() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
assert_eq!(app.active_tab, 2);
app.dispatch_ex("tabmove -1");
assert_eq!(app.active_tab, 1, "tab should now be at position 1");
}
#[test]
fn tabmove_clamps_out_of_range() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabmove 99");
assert_eq!(app.active_tab, 1, "out-of-range must clamp to last");
app.dispatch_ex("tabmove -99");
assert_eq!(app.active_tab, 0, "large negative must clamp to 0");
}
#[test]
fn tabs_listing_marks_active_tab() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("tabnew");
app.dispatch_ex("tabnew");
app.dispatch_ex("tabs");
let popup = app.info_popup.clone().unwrap_or_default();
assert!(popup.contains("Tab page 1"), "missing Tab page 1");
assert!(popup.contains("Tab page 2"), "missing Tab page 2");
assert!(popup.contains("Tab page 3"), "missing Tab page 3");
let lines: Vec<&str> = popup.lines().collect();
assert!(
lines[1].starts_with(" ") || lines[1].starts_with(' '),
"tab 1 must be inactive"
);
assert!(
lines[5].starts_with("> "),
"tab 3 (active) must show '>': {:?}",
lines[5]
);
}
#[test]
fn move_window_to_new_tab_creates_new_tab() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
assert_eq!(app.tabs[0].layout.leaves().len(), 2);
let focused_before = app.focused_window();
app.move_window_to_new_tab()
.expect("should succeed with 2 windows");
assert_eq!(app.tabs.len(), 2, "must create a second tab");
assert_eq!(app.active_tab, 1, "must switch to the new tab");
assert_eq!(app.tabs[1].layout.leaves(), vec![focused_before]);
assert_eq!(app.tabs[1].focused_window, focused_before);
assert_eq!(app.tabs[0].layout.leaves().len(), 1);
}
#[test]
fn move_window_to_new_tab_errors_when_only_window() {
let mut app = App::new(None, false, None, None).unwrap();
let result = app.move_window_to_new_tab();
assert!(result.is_err(), "must error when only one window in tab");
let msg = result.unwrap_err();
assert!(msg.contains("E1"), "expected E1 error, got: {msg}");
assert_eq!(app.tabs.len(), 1);
}
#[test]
fn ctrl_w_t_moves_window_to_new_tab() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("sp");
assert_eq!(app.tabs.len(), 1);
drive_key(&mut app, ctrl_key('w'));
drive_key(
&mut app,
crossterm::event::KeyEvent::new(KeyCode::Char('T'), crossterm::event::KeyModifiers::SHIFT),
);
assert_eq!(app.tabs.len(), 2, "Ctrl-w T must create a new tab");
}
fn pub_diags_params(file_url: &str, diags: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"uri": file_url,
"diagnostics": diags,
})
}
fn file_url(path: &std::path::Path) -> String {
hjkl_lsp::uri::from_path(path).unwrap().to_string()
}
fn tmp_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(name)
}
#[test]
fn publish_diagnostics_populates_slot_diags() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_diag_test.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "let x = ();\nlet y = ();");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": {
"start": { "line": 0, "character": 4 },
"end": { "line": 0, "character": 5 }
},
"severity": 1,
"message": "unused variable",
"source": "rustc",
"code": "E0001"
}]),
);
app.handle_publish_diagnostics(params);
let slot = app.active();
assert_eq!(slot.lsp_diags.len(), 1);
let d = &slot.lsp_diags[0];
assert_eq!(d.start_row, 0);
assert_eq!(d.start_col, 4);
assert_eq!(d.end_row, 0);
assert_eq!(d.end_col, 5);
assert_eq!(d.severity, DiagSeverity::Error);
assert_eq!(d.message, "unused variable");
assert_eq!(d.source.as_deref(), Some("rustc"));
assert_eq!(d.code.as_deref(), Some("E0001"));
assert!(
slot.diag_signs_lsp
.iter()
.any(|s| s.row == 0 && s.ch == 'E'),
"expected an 'E' gutter sign for row 0"
);
}
#[test]
fn publish_diagnostics_replaces_existing() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_diag_replace.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb\nc");
let params1 = pub_diags_params(
&file_url(&path),
serde_json::json!([
{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "err A"
},
{
"range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 1 } },
"severity": 2,
"message": "warn B"
}
]),
);
app.handle_publish_diagnostics(params1);
assert_eq!(app.active().lsp_diags.len(), 2);
let params2 = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": { "start": { "line": 2, "character": 0 }, "end": { "line": 2, "character": 1 } },
"severity": 3,
"message": "info C"
}]),
);
app.handle_publish_diagnostics(params2);
let slot = app.active();
assert_eq!(
slot.lsp_diags.len(),
1,
"second publish must replace, not append"
);
assert_eq!(slot.lsp_diags[0].message, "info C");
assert_eq!(slot.lsp_diags[0].severity, DiagSeverity::Info);
assert_eq!(slot.diag_signs_lsp.len(), 1);
assert_eq!(slot.diag_signs_lsp[0].row, 2);
}
#[test]
fn publish_diagnostics_clears_on_empty() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_diag_clear.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a");
let params_with = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "err"
}]),
);
app.handle_publish_diagnostics(params_with);
assert_eq!(app.active().lsp_diags.len(), 1);
let params_clear = pub_diags_params(&file_url(&path), serde_json::json!([]));
app.handle_publish_diagnostics(params_clear);
let slot = app.active();
assert!(slot.lsp_diags.is_empty(), "empty publish must clear diags");
assert!(
slot.diag_signs_lsp.is_empty(),
"empty publish must clear gutter signs"
);
}
#[test]
fn publish_diagnostics_ignores_unknown_uri() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_diag_known.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a");
let unknown_path = tmp_path("hjkl_diag_unknown.rs");
let params = pub_diags_params(
&file_url(&unknown_path),
serde_json::json!([{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "err"
}]),
);
app.handle_publish_diagnostics(params);
assert!(
app.active().lsp_diags.is_empty(),
"unmatched URI must not populate diags"
);
}
#[test]
fn lnext_jumps_to_next_diag() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_lnext.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb\nc\nhello world");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([
{
"range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 1 } },
"severity": 1,
"message": "first"
},
{
"range": { "start": { "line": 3, "character": 6 }, "end": { "line": 3, "character": 11 } },
"severity": 2,
"message": "second"
}
]),
);
app.handle_publish_diagnostics(params);
app.lnext_severity(None);
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 1, "lnext must jump to first diag after cursor");
app.lnext_severity(None);
let (row, col) = app.active().editor.cursor();
assert_eq!(row, 3);
assert_eq!(col, 6, "lnext must place cursor at diag start_col");
}
#[test]
fn gg_scrolls_window_viewport_to_top() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.set_viewport_height(20);
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.width = 80;
vp.height = 20;
vp.text_width = 80;
vp.top_row = 60;
}
app.active_mut().editor.jump_cursor(70, 0);
app.sync_viewport_from_editor();
let fw = app.focused_window();
assert_eq!(app.windows[fw].as_ref().unwrap().top_row, 60);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
app.sync_viewport_from_editor();
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 0, "gg must put cursor at row 0");
let stored_top = app.windows[fw].as_ref().unwrap().top_row;
assert!(
stored_top < 60,
"gg must scroll window viewport to top, but stored top_row stayed at {stored_top}"
);
}
#[test]
fn plus_slash_argv_scrolls_window_viewport_to_match() {
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_scroll");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.rs");
{
let mut f = std::fs::File::create(&path).unwrap();
for i in 0..100 {
if i == 80 {
writeln!(f, "fn target() {{}}").unwrap();
} else {
writeln!(f, "// padding line {i}").unwrap();
}
}
}
let mut app = App::new(Some(path.clone()), false, None, Some("target".into())).unwrap();
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 80, "+/target must move cursor to row 80");
app.active_mut().editor.set_viewport_height(20);
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.width = 80;
vp.height = 20;
vp.text_width = 80;
}
app.active_mut().editor.ensure_cursor_in_scrolloff();
let editor_top = app.active().editor.host().viewport().top_row;
assert!(
editor_top > 0,
"ensure_cursor_in_scrolloff should scroll editor viewport away from row 0; got top_row={editor_top}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn slash_search_in_editor_scrolls_window_viewport() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..100)
.map(|i| {
if i == 80 {
"target".into()
} else {
format!("line {i}")
}
})
.collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.set_viewport_height(20);
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.width = 80;
vp.height = 20;
vp.text_width = 80;
}
let fw = app.focused_window();
app.commit_search("target");
let stored_top = app.windows[fw].as_ref().unwrap().top_row;
assert!(
stored_top > 0,
"/target<CR> should scroll the focused window's stored top_row past 0 to reveal the match"
);
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 80, "/target<CR> should land cursor on row 80");
let count = crate::render::search_count(&app);
assert_eq!(
count,
Some((1, 1)),
"search counter must update after /<CR>"
);
let stored_top = app.windows[fw].as_ref().unwrap().top_row;
let screen_row = 80usize.saturating_sub(stored_top);
assert!(
(5..=14).contains(&screen_row),
"scrolloff=5 violated: screen_row={screen_row} (top={stored_top}, cursor=80, height=20)"
);
}
#[test]
fn plus_slash_argv_with_realistic_rust_source() {
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_real");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.rs");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "//! crate root").unwrap(); writeln!(f).unwrap(); writeln!(f, "use std::path::PathBuf;").unwrap();
writeln!(f).unwrap();
writeln!(f, "/// Entry.").unwrap();
writeln!(f, "fn main() {{").unwrap(); writeln!(f, " let _ = main_helper();").unwrap(); writeln!(f, "}}").unwrap();
writeln!(f, "fn main_helper() {{}}").unwrap(); }
let app = App::new(Some(path.clone()), false, None, Some("main".into())).unwrap();
let (row, _col) = app.active().editor.cursor();
assert_eq!(
row, 5,
"+/main on rust source must land on row 5 (first `fn main`), got row {row}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plus_slash_argv_search_lands_on_first_forward_match() {
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.txt");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "alpha").unwrap();
writeln!(f, "beta").unwrap();
writeln!(f, "main one").unwrap();
writeln!(f, "delta").unwrap();
writeln!(f, "main two").unwrap();
writeln!(f, "main three").unwrap();
}
let app = App::new(Some(path.clone()), false, None, Some("main".into())).unwrap();
let (row, col) = app.active().editor.cursor();
assert_eq!(
row, 2,
"+/main must land on the FIRST forward match (row 2), got row {row}"
);
assert_eq!(col, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plus_slash_argv_search_with_goto_line_searches_forward() {
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_goto_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.txt");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "main early").unwrap(); writeln!(f, "two").unwrap();
writeln!(f, "three").unwrap();
writeln!(f, "four").unwrap();
writeln!(f, "five").unwrap(); writeln!(f, "six").unwrap();
writeln!(f, "main mid").unwrap(); writeln!(f, "main late").unwrap(); }
let app = App::new(Some(path.clone()), false, Some(5), Some("main".into())).unwrap();
let (row, _col) = app.active().editor.cursor();
assert_eq!(
row, 6,
"+5 +/main must search forward from row 4, landing on row 6"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plus_slash_argv_persists_forward_direction_for_n() {
use hjkl_engine::{Input, Key};
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_n_dir");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.txt");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "alpha").unwrap(); writeln!(f, "beta").unwrap(); writeln!(f, "main one").unwrap(); writeln!(f, "delta").unwrap(); writeln!(f, "main two").unwrap(); writeln!(f, "main three").unwrap(); }
let mut app = App::new(Some(path.clone()), false, None, Some("main".into())).unwrap();
let (row0, _) = app.active().editor.cursor();
assert_eq!(row0, 2, "+/main must land on first match (row 2)");
assert_eq!(app.active().editor.last_search(), Some("main"));
let n_input = Input {
key: Key::Char('n'),
..Default::default()
};
hjkl_vim::dispatch_input(&mut app.active_mut().editor, n_input);
let (row1, _) = app.active().editor.cursor();
assert_eq!(
row1, 4,
"after +/main, `n` must advance FORWARD to row 4 (got row {row1}); \
backward would land on row 0 (no match) or stay/regress"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn search_count_cursor_on_match_stays_on_match() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foo X foo X foo");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 5;
vp.top_row = 0;
}
app.commit_search("foo");
assert_eq!(
crate::render::search_count(&app),
Some((1, 3)),
"/<pat><CR> from cursor on a match must keep counter at 1/3, \
not advance to 2/3"
);
}
#[test]
fn search_count_n_press_increments_by_one() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "X foo X foo X foo");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 5;
vp.top_row = 0;
}
app.commit_search("foo");
assert_eq!(crate::render::search_count(&app), Some((1, 3)));
app.active_mut().editor.search_advance_forward(true);
assert_eq!(
crate::render::search_count(&app),
Some((2, 3)),
"n must advance counter from 1/3 to 2/3, not skip"
);
app.active_mut().editor.search_advance_forward(true);
assert_eq!(crate::render::search_count(&app), Some((3, 3)));
}
#[test]
fn search_count_handles_multibyte_chars_before_match() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "alpha\n/// — main one\nbeta\nmain two");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 10;
vp.top_row = 0;
}
app.commit_search("main");
assert_eq!(
crate::render::search_count(&app),
Some((1, 2)),
"/main must land on M1 with counter 1/2, even when M1 sits \
behind a multi-byte char (em-dash) on its line"
);
app.active_mut().editor.search_advance_forward(true);
assert_eq!(crate::render::search_count(&app), Some((2, 2)));
}
#[test]
fn search_count_through_full_key_flow() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "X foo X foo X foo");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 5;
vp.top_row = 0;
}
app.open_search_prompt(crate::app::SearchDir::Forward);
for ch in ['f', 'o', 'o'] {
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
app.handle_search_field_key(key);
}
let count = crate::render::search_count(&app);
assert_eq!(count, Some((0, 3)), "during typing, counter must be 0/3");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
app.handle_search_field_key(enter);
let count = crate::render::search_count(&app);
assert_eq!(
count,
Some((1, 3)),
"after / submit, counter must be 1/3 — bug was 2/3"
);
}
#[test]
fn search_count_after_commit_lands_on_first_match() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "X foo X foo X foo");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 5;
vp.top_row = 0;
}
app.commit_search("foo");
let count = crate::render::search_count(&app);
assert_eq!(
count,
Some((1, 3)),
"/{{pat}}<CR> from a non-match cursor must land on match 1, not skip to 2"
);
}
#[test]
fn lsp_jump_reveals_cursor_in_viewport() {
use crate::app::window::WindowId;
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_jump_scroll.rs");
app.active_mut().filename = Some(path.clone());
let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 20;
vp.top_row = 0;
}
let fw: WindowId = app.focused_window();
if let Some(w) = app.windows[fw].as_mut() {
w.top_row = 0;
}
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": { "start": { "line": 50, "character": 0 }, "end": { "line": 50, "character": 1 } },
"severity": 1,
"message": "deep"
}]),
);
app.handle_publish_diagnostics(params);
app.lnext_severity(None);
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 50);
let vp_top = app.active().editor.host().viewport().top_row;
assert!(
vp_top > 0,
"viewport top_row stayed at 0 after jump — ensure_cursor_in_scrolloff not called"
);
let stored_top = app.windows[fw].as_ref().unwrap().top_row;
assert!(
stored_top > 0,
"focused window's stored top_row stayed at 0 — sync_viewport_from_editor missed the scroll"
);
}
#[test]
fn lprev_jumps_to_prev_diag_with_wrap() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_lprev.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb\nc\nd");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([
{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "first"
},
{
"range": { "start": { "line": 2, "character": 1 }, "end": { "line": 2, "character": 2 } },
"severity": 2,
"message": "second"
}
]),
);
app.handle_publish_diagnostics(params);
app.lprev_severity(None);
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 2, "lprev from first diag must wrap to last");
app.lprev_severity(None);
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 0, "lprev must jump to previous diag");
}
#[test]
fn lnext_severity_skips_lower_severity() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_lnext_sev.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb\nc");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([
{
"range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 1 } },
"severity": 2,
"message": "warn"
},
{
"range": { "start": { "line": 2, "character": 0 }, "end": { "line": 2, "character": 1 } },
"severity": 1,
"message": "err"
}
]),
);
app.handle_publish_diagnostics(params);
app.lnext_severity(Some(DiagSeverity::Error));
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 2, "lnext with Error filter must skip Warning diags");
}
#[test]
fn lopen_shows_no_diags_message_when_empty() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_diag_picker();
assert!(app.picker.is_none(), "picker must not open when no diags");
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("no diagnostics"),
"expected 'no diagnostics', got: {msg}"
);
}
#[test]
fn lopen_lists_diags_in_picker() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_lopen.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "some error"
}]),
);
app.handle_publish_diagnostics(params);
app.open_diag_picker();
assert!(app.picker.is_some(), "picker must open when diags exist");
}
#[test]
fn lsp_info_with_lsp_disabled_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
app.show_lsp_info();
let popup = app.info_popup.clone().unwrap_or_default();
assert!(
popup.contains("LSP: disabled"),
"expected 'LSP: disabled' message, got: {popup}"
);
}
#[test]
fn lsp_info_lists_running_servers() {
let mut app = App::new(None, false, None, None).unwrap();
app.lsp = Some(hjkl_lsp::LspManager::spawn(hjkl_lsp::LspConfig::default()));
let key = hjkl_lsp::ServerKey {
language: "rust".into(),
root: std::path::PathBuf::from("/tmp/proj"),
};
app.lsp_state.insert(
key,
LspServerInfo {
initialized: true,
capabilities: serde_json::json!({}),
},
);
app.show_lsp_info();
assert!(
app.info_popup.is_some(),
"popup must open when LSP is enabled"
);
let popup = app.info_popup.as_ref().unwrap();
assert!(popup.contains("rust"), "popup must mention server language");
assert!(
popup.contains("initialized"),
"popup must show server state"
);
if let Some(mgr) = app.lsp.take() {
mgr.shutdown();
}
}
#[test]
fn notify_change_skipped_when_dirty_gen_unchanged() {
let mut app = App::new(None, false, None, None).unwrap();
let dg = app.active().editor.buffer().dirty_gen();
app.active_mut().last_lsp_dirty_gen = Some(dg);
app.lsp_notify_change_active();
assert_eq!(
app.active().last_lsp_dirty_gen,
Some(dg),
"guard must remain unchanged when no LSP manager"
);
}
fn make_location(uri: &str, row: u32, col: u32) -> lsp_types::Location {
lsp_types::Location {
uri: uri.parse::<lsp_types::Uri>().expect("valid URI"),
range: lsp_types::Range {
start: lsp_types::Position {
line: row,
character: col,
},
end: lsp_types::Position {
line: row,
character: col + 1,
},
},
}
}
fn ok_val(v: serde_json::Value) -> Result<serde_json::Value, hjkl_lsp::RpcError> {
Ok(v)
}
fn err_val(msg: &str) -> Result<serde_json::Value, hjkl_lsp::RpcError> {
Err(hjkl_lsp::RpcError {
code: -32601,
message: msg.to_string(),
})
}
#[test]
fn goto_definition_single_jumps_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3");
let path = tmp_path("hjkl_gd_single.rs");
app.active_mut().filename = Some(path.clone());
let uri = file_url(&path);
let loc = make_location(&uri, 2, 0);
let result = ok_val(serde_json::to_value(vec![loc]).unwrap());
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_goto_response(buffer_id, (0, 0), result, "definition");
assert_eq!(app.active().editor.buffer().cursor().row, 2);
assert!(app.picker.is_none(), "single result must not open picker");
}
#[test]
fn goto_definition_empty_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let result = ok_val(serde_json::Value::Null);
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_goto_response(buffer_id, (0, 0), result, "definition");
let msg = app.status_message.as_deref().unwrap_or("");
assert!(
msg.contains("no definition found"),
"expected 'no definition found', got: {msg}"
);
assert!(app.picker.is_none());
}
#[test]
fn goto_definition_multi_opens_picker() {
let mut app = App::new(None, false, None, None).unwrap();
let locs = vec![
make_location(&file_url(&tmp_path("hjkl_gd_multi_a.rs")), 0, 0),
make_location(&file_url(&tmp_path("hjkl_gd_multi_b.rs")), 5, 3),
make_location(&file_url(&tmp_path("hjkl_gd_multi_c.rs")), 10, 1),
];
let result = ok_val(serde_json::to_value(locs).unwrap());
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_goto_response(buffer_id, (0, 0), result, "definition");
assert!(app.picker.is_some(), "multiple results must open picker");
}
#[test]
fn goto_references_always_opens_picker() {
let mut app = App::new(None, false, None, None).unwrap();
let locs = vec![make_location(&file_url(&tmp_path("hjkl_gd_only.rs")), 3, 0)];
let result = ok_val(serde_json::to_value(locs).unwrap());
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_references_response(buffer_id, (0, 0), result);
assert!(app.picker.is_some(), "references must always open picker");
}
#[test]
fn hover_response_sets_info_popup() {
let mut app = App::new(None, false, None, None).unwrap();
let hover = lsp_types::Hover {
contents: lsp_types::HoverContents::Markup(lsp_types::MarkupContent {
kind: lsp_types::MarkupKind::Markdown,
value: "**fn** foo() -> i32".to_string(),
}),
range: None,
};
let result = ok_val(serde_json::to_value(hover).unwrap());
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_hover_response(buffer_id, (0, 0), result);
assert!(app.info_popup.is_some(), "hover must set info_popup");
let popup = app.info_popup.as_ref().unwrap();
assert!(popup.contains("foo"), "popup must contain function name");
}
#[test]
fn hover_empty_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let result: Result<serde_json::Value, hjkl_lsp::RpcError> = Ok(serde_json::Value::Null);
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_hover_response(buffer_id, (0, 0), result);
let msg = app.status_message.as_deref().unwrap_or("");
assert!(
msg.contains("no hover info"),
"expected 'no hover info', got: {msg}"
);
assert!(app.info_popup.is_none());
}
#[test]
fn goto_definition_error_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let result = err_val("server error");
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_goto_response(buffer_id, (0, 0), result, "definition");
let msg = app.status_message.as_deref().unwrap_or("");
assert!(
msg.contains("server error"),
"expected error message, got: {msg}"
);
}
#[test]
fn k_dispatches_hover() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().filename = Some(tmp_path("k_test.rs"));
app.lsp_hover();
assert!(app.info_popup.is_none());
let msg = app.status_message.as_deref().unwrap_or("");
assert!(msg.contains("LSP: not enabled"), "got: {msg}");
}
#[test]
fn gd_dispatches_goto_definition() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().filename = Some(tmp_path("gd_test.rs"));
app.lsp_goto_definition();
assert!(app.lsp_pending.is_empty());
}
#[test]
fn lsp_request_works_with_relative_filename() {
let mut app = App::new(None, false, None, None).unwrap();
let mgr = hjkl_lsp::LspManager::spawn(hjkl_lsp::LspConfig::default());
app.lsp = Some(mgr);
app.active_mut().filename = Some(std::path::PathBuf::from("src/main.rs"));
app.lsp_goto_definition();
assert_eq!(
app.lsp_pending.len(),
1,
"relative-path goto must produce a pending request, not the \
'no file open' error path"
);
if let Some(mgr) = app.lsp.take() {
mgr.shutdown();
}
}
fn make_completion_item(label: &str) -> crate::completion::CompletionItem {
crate::completion::CompletionItem {
label: label.to_string(),
detail: None,
kind: crate::completion::CompletionKind::Other,
insert_text: label.to_string(),
filter_text: None,
}
}
fn synthesize_completion_response(labels: &[&str]) -> serde_json::Value {
let items: Vec<serde_json::Value> = labels
.iter()
.map(|l| serde_json::json!({ "label": l }))
.collect();
serde_json::json!(items)
}
#[test]
fn completion_response_opens_popup() {
let mut app = App::new(None, false, None, None).unwrap();
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
app.active_mut().filename = Some(std::path::PathBuf::from("/tmp/test.rs"));
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
let response_val = synthesize_completion_response(&["foo", "bar", "baz"]);
app.handle_completion_response(buffer_id, 0, 0, Ok(response_val));
assert!(app.completion.is_some(), "popup should open");
let popup = app.completion.as_ref().unwrap();
assert_eq!(popup.all_items.len(), 3);
assert_eq!(popup.visible.len(), 3);
}
#[test]
fn completion_response_empty_no_popup() {
let mut app = App::new(None, false, None, None).unwrap();
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
app.active_mut().filename = Some(std::path::PathBuf::from("/tmp/test.rs"));
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
let response_val = serde_json::json!([]);
app.handle_completion_response(buffer_id, 0, 0, Ok(response_val));
assert!(
app.completion.is_none(),
"empty response must not open popup"
);
assert!(
app.status_message
.as_deref()
.unwrap_or("")
.contains("no completions"),
"status should report no completions"
);
}
#[test]
fn completion_request_pending_routes_to_handler() {
let mut app = App::new(None, false, None, None).unwrap();
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
app.active_mut().filename = Some(std::path::PathBuf::from("/tmp/test.rs"));
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
let req_id = app.lsp_alloc_request_id();
app.lsp_pending.insert(
req_id,
LspPendingRequest::Completion {
buffer_id,
anchor_row: 0,
anchor_col: 0,
},
);
let response_val = synthesize_completion_response(&["alpha", "beta"]);
let pending = app.lsp_pending.remove(&req_id).unwrap();
app.handle_lsp_response(pending, Ok(response_val));
assert!(
app.completion.is_some(),
"response must route to popup opener"
);
let popup = app.completion.as_ref().unwrap();
assert_eq!(popup.all_items.len(), 2);
}
#[test]
fn accept_completion_inserts_selected_item() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "fn foo");
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
let items = vec![make_completion_item("hello"), make_completion_item("world")];
app.completion = Some(crate::completion::Completion::new(0, 0, items));
app.completion.as_mut().unwrap().selected = 1;
app.accept_completion();
app.sync_after_engine_mutation();
assert!(app.completion.is_none());
let line = app.active().editor.buffer().lines()[0].clone();
assert!(
line.starts_with("world"),
"buffer line should start with inserted text, got: {line:?}"
);
assert!(
!app.active_mut().editor.take_dirty(),
"accept_completion call site must drain dirty via sync_after_engine_mutation"
);
assert!(
app.active_mut().editor.take_content_edits().is_empty(),
"accept_completion call site must drain content_edits"
);
}
#[test]
fn dismiss_completion_clears_state() {
let mut app = App::new(None, false, None, None).unwrap();
let items = vec![make_completion_item("foo")];
app.completion = Some(crate::completion::Completion::new(0, 0, items));
app.pending_ctrl_x = true;
app.dismiss_completion();
assert!(app.completion.is_none());
assert!(!app.pending_ctrl_x);
}
#[test]
fn set_prefix_dismisses_when_filter_empty() {
let items = vec![make_completion_item("alpha"), make_completion_item("beta")];
let mut popup = crate::completion::Completion::new(0, 0, items);
popup.set_prefix("xyz");
assert!(
popup.is_empty(),
"popup should be empty after non-matching prefix"
);
}
#[allow(clippy::mutable_key_type)]
fn make_workspace_edit(
uri: &str,
start_line: u32,
start_char: u32,
end_line: u32,
end_char: u32,
new_text: &str,
) -> lsp_types::WorkspaceEdit {
let url = uri.parse::<lsp_types::Uri>().expect("valid URI");
let mut changes = std::collections::HashMap::new();
changes.insert(
url,
vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: start_line,
character: start_char,
},
end: lsp_types::Position {
line: end_line,
character: end_char,
},
},
new_text: new_text.to_string(),
}],
);
lsp_types::WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
}
}
#[test]
fn apply_workspace_edit_single_file() {
let path = std::env::temp_dir().join("hjkl_ws_edit_single.txt");
std::fs::write(&path, "hello world\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let uri = file_url(&path);
let edit = make_workspace_edit(&uri, 0, 6, 0, 11, "rust");
let count = app
.apply_workspace_edit(edit)
.expect("apply_workspace_edit failed");
assert_eq!(count, 1);
let lines = app.active().editor.buffer().lines();
assert_eq!(
lines[0], "hello rust",
"edit should replace 'world' with 'rust'"
);
let _ = std::fs::remove_file(&path);
}
#[test]
#[allow(clippy::mutable_key_type)]
fn apply_workspace_edit_sorts_edits_descending() {
let path = std::env::temp_dir().join("hjkl_ws_edit_sort.txt");
std::fs::write(&path, "hello world foo\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let url = file_url(&path)
.parse::<lsp_types::Uri>()
.expect("valid URI");
let mut changes = std::collections::HashMap::new();
changes.insert(
url,
vec![
lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 5,
},
},
new_text: "hi".to_string(),
},
lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 6,
},
end: lsp_types::Position {
line: 0,
character: 11,
},
},
new_text: "earth".to_string(),
},
],
);
let edit = lsp_types::WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
};
app.apply_workspace_edit(edit).expect("apply failed");
let lines = app.active().editor.buffer().lines();
assert_eq!(lines[0], "hi earth foo", "both edits must apply correctly");
let _ = std::fs::remove_file(&path);
}
#[test]
#[allow(clippy::mutable_key_type)]
fn apply_workspace_edit_multi_file() {
let path_a = std::env::temp_dir().join("hjkl_ws_multi_a.txt");
let path_b = std::env::temp_dir().join("hjkl_ws_multi_b.txt");
std::fs::write(&path_a, "file a content\n").unwrap();
std::fs::write(&path_b, "file b content\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
let uri_a = file_url(&path_a);
let uri_b = file_url(&path_b);
let url_a = uri_a.parse::<lsp_types::Uri>().expect("valid URI a");
let url_b = uri_b.parse::<lsp_types::Uri>().expect("valid URI b");
let mut changes = std::collections::HashMap::new();
changes.insert(
url_a,
vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 7,
},
end: lsp_types::Position {
line: 0,
character: 14,
},
},
new_text: "edited".to_string(),
}],
);
changes.insert(
url_b,
vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 7,
},
end: lsp_types::Position {
line: 0,
character: 14,
},
},
new_text: "changed".to_string(),
}],
);
let edit = lsp_types::WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
};
let count = app
.apply_workspace_edit(edit)
.expect("multi-file apply failed");
assert_eq!(count, 2, "should affect 2 files");
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn rename_response_null_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let pending = LspPendingRequest::Rename {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
new_name: "newName".to_string(),
};
app.handle_lsp_response(pending, Ok(serde_json::Value::Null));
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("cannot rename"),
"null rename must set 'cannot rename' status, got: {msg}"
);
}
#[test]
fn rename_response_applies_workspace_edit() {
let path = std::env::temp_dir().join("hjkl_rename_apply.txt");
std::fs::write(&path, "old_name here\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let uri = file_url(&path);
let edit = make_workspace_edit(&uri, 0, 0, 0, 8, "new_name");
let val = serde_json::to_value(edit).unwrap();
let pending = LspPendingRequest::Rename {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
new_name: "new_name".to_string(),
};
app.handle_lsp_response(pending, Ok(val));
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("renamed"),
"rename response must set status, got: {msg}"
);
let lines = app.active().editor.buffer().lines();
assert_eq!(lines[0], "new_name here");
let _ = std::fs::remove_file(&path);
}
#[test]
fn format_response_empty_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let pending = LspPendingRequest::Format {
buffer_id: 0,
range: None,
};
app.handle_lsp_response(pending, Ok(serde_json::json!([])));
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("no formatting"),
"empty format response must say 'no formatting changes', got: {msg}"
);
}
#[test]
fn format_response_applies_text_edits() {
let path = std::env::temp_dir().join("hjkl_format_apply.txt");
std::fs::write(&path, "fn foo(){}\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let buf_id = app.active().buffer_id as hjkl_lsp::BufferId;
let edits: Vec<lsp_types::TextEdit> = vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 9,
},
end: lsp_types::Position {
line: 0,
character: 9,
},
},
new_text: " ".to_string(),
}];
let val = serde_json::to_value(&edits).unwrap();
let pending = LspPendingRequest::Format {
buffer_id: buf_id,
range: None,
};
app.handle_lsp_response(pending, Ok(val));
let msg = app.status_message.clone().unwrap_or_default();
assert_eq!(msg, "formatted");
let lines = app.active().editor.buffer().lines();
assert_eq!(lines[0], "fn foo(){ }");
let _ = std::fs::remove_file(&path);
}
#[test]
fn code_action_response_empty_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let pending = LspPendingRequest::CodeAction {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
};
app.handle_lsp_response(pending, Ok(serde_json::json!([])));
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("no code actions"),
"empty code actions must say 'no code actions', got: {msg}"
);
}
#[test]
fn code_action_response_multi_opens_picker() {
let mut app = App::new(None, false, None, None).unwrap();
let pending = LspPendingRequest::CodeAction {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
};
let actions = serde_json::json!([
{
"title": "Fix import",
"kind": "quickfix",
},
{
"title": "Extract method",
"kind": "refactor",
},
]);
app.handle_lsp_response(pending, Ok(actions));
assert!(
app.picker.is_some(),
"multiple code actions must open picker"
);
assert_eq!(
app.pending_code_actions.len(),
2,
"pending_code_actions must hold both actions"
);
}
#[test]
fn code_action_response_single_applies_action() {
let path = std::env::temp_dir().join("hjkl_ca_single.txt");
std::fs::write(&path, "old content\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let uri = file_url(&path);
let edit = make_workspace_edit(&uri, 0, 0, 0, 11, "new content");
let action = lsp_types::CodeAction {
title: "Replace content".to_string(),
edit: Some(edit),
..Default::default()
};
let val =
serde_json::to_value(vec![lsp_types::CodeActionOrCommand::CodeAction(action)]).unwrap();
let pending = LspPendingRequest::CodeAction {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
};
app.handle_lsp_response(pending, Ok(val));
assert!(
app.picker.is_none(),
"single code action must not open picker"
);
let msg = app.status_message.clone().unwrap_or_default();
assert!(
msg.contains("files changed"),
"single action apply must set status, got: {msg}"
);
let lines = app.active().editor.buffer().lines();
assert_eq!(lines[0], "new content");
let _ = std::fs::remove_file(&path);
}
#[test]
fn lsp_code_actions_includes_overlapping_diags_in_context() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "fn foo() {\n let x = 1;\n}\n");
app.active_mut().lsp_diags = vec![
LspDiag {
start_row: 0,
start_col: 3,
end_row: 0,
end_col: 6,
severity: DiagSeverity::Error,
message: "overlapping".to_string(),
source: None,
code: None,
},
LspDiag {
start_row: 1,
start_col: 0,
end_row: 1,
end_col: 5,
severity: DiagSeverity::Warning,
message: "not overlapping".to_string(),
source: None,
code: None,
},
];
app.active_mut().editor.jump_cursor(0, 4);
let cursor_row = 0usize;
let cursor_col = 4usize;
let diags = &app.active().lsp_diags;
let overlapping: Vec<_> = diags
.iter()
.filter(|d| {
let after_start = (cursor_row, cursor_col) >= (d.start_row, d.start_col);
let before_end = cursor_row < d.end_row
|| (cursor_row == d.end_row && cursor_col < d.end_col)
|| (cursor_row == d.start_row && d.start_row == d.end_row);
after_start && (before_end || cursor_row == d.start_row)
})
.collect();
assert_eq!(
overlapping.len(),
1,
"only the overlapping diag should be included"
);
assert_eq!(overlapping[0].message, "overlapping");
}
#[test]
fn count_gt_advances_multiple_tabs() {
let mut app = App::new(None, false, None, None).unwrap();
for _ in 0..5 {
app.dispatch_ex("tabnew");
}
assert_eq!(app.tabs.len(), 6);
app.active_tab = 0;
let count = 5_usize;
for _ in 0..count {
app.dispatch_ex("tabnext");
}
assert_eq!(
app.active_tab, 5,
"5gt from tab 0 should land on tab 5 (index 5)"
);
}
#[test]
fn count_gt_upper_retreats_multiple_tabs() {
let mut app = App::new(None, false, None, None).unwrap();
for _ in 0..4 {
app.dispatch_ex("tabnew");
}
assert_eq!(app.tabs.len(), 5);
app.active_tab = 4;
let count = 3_usize;
for _ in 0..count {
app.dispatch_ex("tabprev");
}
assert_eq!(
app.active_tab, 1,
"3gT from tab 4 should land on tab 1 (index 1)"
);
}
#[test]
fn count_ctrl_w_plus_resizes_by_count() {
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");
};
let count: i32 = 3;
app.resize_height(count);
let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
*ratio
} else {
panic!("expected Split");
};
assert!(
ratio_after > ratio_before,
"3<C-w>+ must grow the ratio: before={ratio_before} after={ratio_after}"
);
let ratio_delta_1 = (20.0_f32 + 1.0) / 40.0;
assert!(
ratio_after > ratio_delta_1,
"ratio after 3-row grow ({ratio_after}) should exceed 1-row grow ({ratio_delta_1})"
);
}
#[test]
fn pending_count_accumulation_rules() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.pending_count.is_empty());
assert!(app.pending_count.try_accumulate('1'));
assert_eq!(app.pending_count.peek(), 1);
assert!(app.pending_count.try_accumulate('0'));
assert_eq!(app.pending_count.peek(), 10);
let count: usize = app.pending_count.take_or(1) as usize;
assert_eq!(count, 10);
assert!(app.pending_count.is_empty());
assert!(
!app.pending_count.try_accumulate('0'),
"'0' with empty pending_count must not be accumulated"
);
assert!(
app.pending_count.is_empty(),
"'0' with empty pending_count must not be buffered"
);
}
#[test]
fn count_engine_motion_5j_moves_cursor_five_rows() {
let mut app = App::new(None, false, None, None).unwrap();
let content: String = (0..20).map(|i| format!("line {i}\n")).collect();
let content = content.trim_end_matches('\n');
hjkl_engine::BufferEdit::replace_all(app.active_mut().editor.buffer_mut(), content);
let (start_row, _) = app.active().editor.cursor();
assert_eq!(start_row, 0);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE),
);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
let (end_row, _) = app.active().editor.cursor();
assert_eq!(end_row, 5, "5j must move cursor from row 0 to row 5");
}
#[test]
fn zero_with_empty_count_is_start_of_line() {
let mut app = App::new(None, false, None, None).unwrap();
hjkl_engine::BufferEdit::replace_all(
app.active_mut().editor.buffer_mut(),
"hello world\nsecond line",
);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE),
);
let (_, col_after_dollar) = app.active().editor.cursor();
assert!(col_after_dollar > 0, "$ must move to end of line");
assert!(app.pending_count.is_empty());
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE),
);
let (_, col_after_zero) = app.active().editor.cursor();
assert_eq!(
col_after_zero, 0,
"0 with no pending count must go to col 0"
);
}
#[test]
fn set_cursorline_flips_setting() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(
app.active().editor.settings().cursorline,
"cursorline must default to true"
);
app.dispatch_ex("set nocursorline");
assert!(
!app.active().editor.settings().cursorline,
":set nocursorline must disable cursorline"
);
app.dispatch_ex("set cursorline");
assert!(
app.active().editor.settings().cursorline,
":set cursorline must enable cursorline"
);
}
#[test]
fn set_cul_alias_works() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set cul");
assert!(
app.active().editor.settings().cursorline,
":set cul must enable cursorline"
);
}
#[test]
fn set_cursorcolumn_flips_setting() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(!app.active().editor.settings().cursorcolumn);
app.dispatch_ex("set cuc");
assert!(app.active().editor.settings().cursorcolumn);
app.dispatch_ex("set nocuc");
assert!(!app.active().editor.settings().cursorcolumn);
}
#[test]
fn set_signcolumn_yes() {
use hjkl_engine::types::SignColumnMode;
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(
app.active().editor.settings().signcolumn,
SignColumnMode::Auto,
"signcolumn defaults to auto"
);
app.dispatch_ex("set signcolumn=yes");
assert_eq!(
app.active().editor.settings().signcolumn,
SignColumnMode::Yes
);
}
#[test]
fn set_signcolumn_scl_alias() {
use hjkl_engine::types::SignColumnMode;
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set scl=no");
assert_eq!(
app.active().editor.settings().signcolumn,
SignColumnMode::No
);
}
#[test]
fn set_foldcolumn_stores_value() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.active().editor.settings().foldcolumn, 0);
app.dispatch_ex("set foldcolumn=4");
assert_eq!(app.active().editor.settings().foldcolumn, 4);
app.dispatch_ex("set fdc=0");
assert_eq!(app.active().editor.settings().foldcolumn, 0);
}
#[test]
fn set_colorcolumn_stores_value() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.active().editor.settings().colorcolumn, "");
app.dispatch_ex("set cc=80");
assert_eq!(app.active().editor.settings().colorcolumn, "80");
app.dispatch_ex("set colorcolumn=80,120");
assert_eq!(app.active().editor.settings().colorcolumn, "80,120");
app.dispatch_ex("set cc=");
assert_eq!(app.active().editor.settings().colorcolumn, "");
}
#[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.status_message.clone().unwrap_or_default();
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.status_message.clone().unwrap_or_default();
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.status_message.clone().unwrap_or_default();
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.status_message.clone().unwrap_or_default();
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()
);
}
fn drive_key(app: &mut App, ct_key: KeyEvent) {
if let Some(state) = app.pending_state {
use hjkl_vim::{Key as VimKey, Outcome};
let vim_key = match ct_key.code {
KeyCode::Char(c) => Some(VimKey::Char(c)),
KeyCode::Esc => Some(VimKey::Esc),
KeyCode::Enter => Some(VimKey::Enter),
KeyCode::Backspace => Some(VimKey::Backspace),
KeyCode::Tab => Some(VimKey::Tab),
_ => None,
};
if let Some(vk) = vim_key {
match hjkl_vim::step(state, vk) {
Outcome::Wait(new_state) => {
app.pending_state = Some(new_state);
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::ReplaceChar { ch, count }) => {
app.pending_state = None;
app.active_mut().editor.replace_char_at(ch, count);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::FindChar {
ch,
forward,
till,
count,
}) => {
app.pending_state = None;
app.active_mut().editor.find_char(ch, forward, till, count);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::AfterGChord { ch, count }) => {
app.pending_state = None;
match ch {
't' => {
app.dispatch_action(
crate::keymap_actions::AppAction::Tabnext,
count as u32,
);
return;
}
'T' => {
app.dispatch_action(
crate::keymap_actions::AppAction::Tabprev,
count as u32,
);
return;
}
'd' => {
app.dispatch_action(
crate::keymap_actions::AppAction::LspGotoDef,
count as u32,
);
return;
}
'D' => {
app.dispatch_action(
crate::keymap_actions::AppAction::LspGotoDecl,
count as u32,
);
return;
}
'r' => {
app.dispatch_action(
crate::keymap_actions::AppAction::LspGotoRef,
count as u32,
);
return;
}
'i' => {
app.dispatch_action(
crate::keymap_actions::AppAction::LspGotoImpl,
count as u32,
);
return;
}
'y' => {
app.dispatch_action(
crate::keymap_actions::AppAction::LspGotoTypeDef,
count as u32,
);
return;
}
_ => {}
}
let case_op_kind = match ch {
'u' => Some(hjkl_vim::OperatorKind::Lowercase),
'U' => Some(hjkl_vim::OperatorKind::Uppercase),
'~' => Some(hjkl_vim::OperatorKind::ToggleCase),
'q' => Some(hjkl_vim::OperatorKind::Reflow),
_ => None,
};
if let Some(op) = case_op_kind {
app.pending_state = Some(hjkl_vim::PendingState::AfterOp {
op,
count1: count,
inner_count: 0,
});
return;
}
app.active_mut().editor.after_g(ch, count);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::AfterZChord { ch, count }) => {
app.pending_state = None;
app.active_mut().editor.after_z(ch, count);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::ApplyOpMotion {
op,
motion_key,
total_count,
}) => {
app.pending_state = None;
app.active_mut().editor.apply_op_motion(
op_kind_to_operator(op),
motion_key,
total_count,
);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::ApplyOpDouble { op, total_count }) => {
app.pending_state = None;
app.active_mut()
.editor
.apply_op_double(op_kind_to_operator(op), total_count);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::ApplyOpTextObj {
op,
ch,
inner,
total_count,
}) => {
app.pending_state = None;
app.active_mut().editor.apply_op_text_obj(
op_kind_to_operator(op),
ch,
inner,
total_count,
);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::ApplyOpG {
op,
ch,
total_count,
}) => {
app.pending_state = None;
app.active_mut()
.editor
.apply_op_g(op_kind_to_operator(op), ch, total_count);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::ApplyOpFind {
op,
ch,
forward,
till,
total_count,
}) => {
app.pending_state = None;
app.active_mut().editor.apply_op_find(
op_kind_to_operator(op),
ch,
forward,
till,
total_count,
);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::SetPendingRegister { reg }) => {
app.pending_state = None;
app.active_mut().editor.set_pending_register(reg);
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::SetMark { ch }) => {
app.pending_state = None;
app.active_mut().editor.set_mark_at_cursor(ch);
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::GotoMarkLine { ch }) => {
app.pending_state = None;
app.active_mut().editor.goto_mark_line(ch);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::GotoMarkChar { ch }) => {
app.pending_state = None;
app.active_mut().editor.goto_mark_char(ch);
app.sync_viewport_from_editor();
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::StartMacroRecord { reg }) => {
app.pending_state = None;
app.active_mut().editor.start_macro_record(reg);
return;
}
Outcome::Commit(hjkl_vim::EngineCmd::PlayMacro { reg, count }) => {
app.pending_state = None;
let inputs = app.active_mut().editor.play_macro(reg, count);
for input in inputs {
let ct_key = engine_input_to_key_event(input);
if ct_key.code != KeyCode::Null {
drive_key(app, ct_key);
}
}
app.active_mut().editor.end_macro_replay();
app.sync_viewport_from_editor();
return;
}
Outcome::Cancel => {
app.pending_state = None;
return;
}
Outcome::Forward => {
}
}
}
}
if app.active().editor.is_chord_pending() {
hjkl_vim::handle_key(&mut app.active_mut().editor, ct_key);
app.sync_viewport_from_editor();
return;
}
let Some(km_ev) = crate::keymap_translate::from_crossterm(&ct_key) else {
hjkl_vim::handle_key(&mut app.active_mut().editor, ct_key);
app.sync_viewport_from_editor();
return;
};
let mut replay = Vec::new();
let consumed = app.dispatch_keymap(km_ev, 1, &mut replay);
if consumed {
return;
}
for ev in &replay {
let back = crate::keymap_translate::to_crossterm(ev);
hjkl_vim::handle_key(&mut app.active_mut().editor, back);
}
app.sync_viewport_from_editor();
}
#[test]
fn gg_via_dispatch_jumps_to_top() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(30, 0);
assert_eq!(app.active().editor.cursor().0, 30);
drive_key(&mut app, key(KeyCode::Char('g')));
drive_key(&mut app, key(KeyCode::Char('g')));
assert_eq!(
app.active().editor.cursor().0,
0,
"gg through dispatch path must move cursor to top"
);
}
#[test]
fn r_space_replaces_char_with_space() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc");
app.active_mut().editor.jump_cursor(0, 1);
drive_key(&mut app, key(KeyCode::Char('r')));
assert!(
app.pending_state.is_some(),
"r must set app pending_state to Replace"
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be in chord-pending after app-intercepted r"
);
drive_key(&mut app, key(KeyCode::Char(' ')));
assert!(
app.pending_state.is_none(),
"pending_state cleared after commit"
);
let line = app.active().editor.buffer().as_string();
assert_eq!(
line, "a c",
"r<space> must replace 'b' with ' ', got {line:?}"
);
}
#[test]
fn f_with_leader_char_finds_it() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "a b c");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('f')));
assert!(
app.pending_state.is_some(),
"f must set app pending_state to Find"
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be in chord-pending after app-intercepted f"
);
drive_key(&mut app, key(KeyCode::Char(' ')));
assert_eq!(app.active().editor.cursor(), (0, 1));
}
#[test]
fn fx_finds_x_forward() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc x def");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('f')));
assert!(
app.pending_state.is_some(),
"f must set app pending_state to Find"
);
drive_key(&mut app, key(KeyCode::Char('x')));
assert!(
app.pending_state.is_none(),
"pending_state cleared after commit"
);
assert_eq!(
app.active().editor.cursor(),
(0, 4),
"fx must land on 'x' at col 4"
);
}
#[test]
fn fx_finds_x_backward() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc x def");
app.active_mut().editor.jump_cursor(0, 8);
drive_key(&mut app, key(KeyCode::Char('F')));
assert!(
app.pending_state.is_some(),
"F must set app pending_state to Find"
);
drive_key(&mut app, key(KeyCode::Char('x')));
assert!(
app.pending_state.is_none(),
"pending_state cleared after commit"
);
assert_eq!(
app.active().editor.cursor(),
(0, 4),
"Fx must land on 'x' at col 4"
);
}
#[test]
fn tx_lands_before_x() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc x def");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('t')));
drive_key(&mut app, key(KeyCode::Char('x')));
assert_eq!(
app.active().editor.cursor(),
(0, 3),
"tx must stop one before 'x' at col 3"
);
}
#[test]
fn tx_backward_lands_after_x() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc x def");
app.active_mut().editor.jump_cursor(0, 8);
drive_key(&mut app, key(KeyCode::Char('T')));
drive_key(&mut app, key(KeyCode::Char('x')));
assert_eq!(
app.active().editor.cursor(),
(0, 5),
"Tx must stop one after 'x' at col 5"
);
}
#[test]
fn fx_with_count_3() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "xaxbxc");
app.active_mut().editor.jump_cursor(0, 0);
app.pending_count.try_accumulate('3');
drive_key(&mut app, key(KeyCode::Char('f')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::Find { count: 3, .. })
),
"pending_state must carry count=3, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('x')));
assert_eq!(
app.active().editor.cursor(),
(0, 4),
"3fx must land on 3rd 'x' at col 4"
);
}
#[test]
fn fx_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc x def");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('f')));
assert!(app.pending_state.is_some());
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must clear find pending_state"
);
assert_eq!(app.active().editor.cursor(), (0, 0));
}
#[test]
fn gj_via_dispatch_moves_down_display_line() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
drive_key(&mut app, key(KeyCode::Char('j')));
assert_eq!(
app.active().editor.cursor().0,
1,
"gj must move down one row"
);
}
#[test]
fn gg_jumps_top() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(30, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { .. })
),
"g must set pending_state=AfterG, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(app.pending_state.is_none(), "pending cleared after gg");
assert_eq!(app.active().editor.cursor().0, 0, "gg must jump to row 0");
}
#[test]
fn gg_with_count_5_jumps_line_5() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..20).map(|i| format!("line {i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(0, 0);
app.pending_count.try_accumulate('5');
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { count: 5 })
),
"pending_state must carry count=5, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('g')));
assert_eq!(app.active().editor.cursor().0, 4, "5gg must land on row 4");
}
#[test]
fn gv_restores_last_visual() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\n");
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('v')));
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('l')));
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('l')));
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Esc));
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"should be Normal after Esc"
);
drive_key(&mut app, key(KeyCode::Char('g')));
drive_key(&mut app, key(KeyCode::Char('v')));
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Visual,
"gv must re-enter Visual mode"
);
}
#[test]
fn gj_screen_down() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\n");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
drive_key(&mut app, key(KeyCode::Char('j')));
assert_eq!(
app.active().editor.cursor().0,
1,
"gj must move down to row 1"
);
}
#[test]
fn gu_then_w_lowercases_word() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "HELLO world\n");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
drive_key(&mut app, key(KeyCode::Char('u')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Lowercase,
..
})
),
"gu must set reducer AfterOp(Lowercase), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending after gu (reducer owns it)"
);
drive_key(&mut app, key(KeyCode::Char('w')));
assert!(app.pending_state.is_none(), "pending must clear after guw");
let content = app.active().editor.buffer().as_string();
assert!(
content.starts_with("hello"),
"gu+w must lowercase the word; got {content:?}"
);
}
#[test]
fn diw_deletes_word_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('i')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpTextObj {
op: hjkl_vim::OperatorKind::Delete,
inner: true,
..
})
),
"di must set OpTextObj(inner:true), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending after reducer-owned di"
);
drive_key(&mut app, key(KeyCode::Char('w')));
assert!(app.pending_state.is_none());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert!(
!line.contains("hello"),
"diw must delete 'hello', remaining: {line:?}"
);
}
#[test]
fn daw_deletes_around_word_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('a')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpTextObj {
op: hjkl_vim::OperatorKind::Delete,
inner: false,
..
})
),
"da must set OpTextObj(inner:false), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending after reducer-owned da"
);
drive_key(&mut app, key(KeyCode::Char('w')));
assert!(app.pending_state.is_none());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert!(
!line.contains("hello"),
"daw must delete 'hello' and surrounding space, remaining: {line:?}"
);
}
#[test]
fn di_quote_deletes_quoted_string() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, r#"say "hello" now"#);
app.active_mut().editor.jump_cursor(0, 5);
drive_chars(&mut app, r#"di""#);
assert!(app.pending_state.is_none());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert!(
!line.contains("hello"),
r#"di" must delete text inside quotes, remaining: {line:?}"#
);
assert!(
line.contains('"'),
r#"di" must leave the quote delimiters, remaining: {line:?}"#
);
}
#[test]
fn dap_deletes_paragraph_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\n\nfoo bar");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "dap");
assert!(app.pending_state.is_none());
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert!(
!lines.contains(&"hello world".to_string()),
"dap must delete first paragraph, got {lines:?}"
);
}
#[test]
fn guiw_uppercases_word_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { .. })
),
"g must set AfterG"
);
drive_key(&mut app, key(KeyCode::Char('U')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Uppercase,
..
})
),
"gU must set reducer AfterOp(Uppercase), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending after 2c-v gU intercept"
);
drive_key(&mut app, key(KeyCode::Char('i')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpTextObj {
op: hjkl_vim::OperatorKind::Uppercase,
inner: true,
..
})
),
"i after gU must set reducer OpTextObj(inner:true), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending (reducer owns text-obj)"
);
drive_key(&mut app, key(KeyCode::Char('w')));
assert!(app.pending_state.is_none(), "pending must clear after gUiw");
assert!(!app.active().editor.is_chord_pending());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "HELLO world",
"gUiw must uppercase inner word 'hello', got {line:?}"
);
}
#[test]
fn g_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc\n");
app.active_mut().editor.jump_cursor(0, 1);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(app.pending_state.is_some(), "g must set pending_state");
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must clear g pending_state"
);
assert_eq!(
app.active().editor.cursor(),
(0, 1),
"cursor must not move on g<Esc>"
);
}
#[test]
fn ambiguous_chord_resolves_to_shorter_on_timeout() {
use crate::keymap_actions::AppAction;
let mut app = App::new(None, false, None, None).unwrap();
use crate::app::keymap::HjklMode as Mode;
app.app_keymap
.add(Mode::Normal, "q", AppAction::OpenFilePicker, "file picker")
.unwrap();
app.app_keymap
.add(
Mode::Normal,
"qd",
AppAction::OpenBufferPicker,
"buffer picker",
)
.unwrap();
let mut replay: Vec<hjkl_keymap::KeyEvent> = Vec::new();
let consumed = app.dispatch_keymap(
hjkl_keymap::KeyEvent::new(
hjkl_keymap::KeyCode::Char('q'),
hjkl_keymap::KeyModifiers::NONE,
),
1,
&mut replay,
);
assert!(consumed, "q should be consumed (Ambiguous)");
assert!(app.picker.is_none(), "no picker yet — waiting for timeout");
let out = app
.resolve_chord_timeout(crate::app::keymap::HjklMode::Normal)
.expect("chord was pending");
assert!(out.is_empty(), "Match should leave nothing to replay");
assert!(
app.picker.is_some(),
"shorter binding (file picker) must fire on timeout"
);
}
#[test]
fn ambiguous_chord_fires_longer_on_fast_second_key() {
use crate::keymap_actions::AppAction;
let mut app = App::new(None, false, None, None).unwrap();
use crate::app::keymap::HjklMode as Mode;
app.app_keymap
.add(Mode::Normal, "q", AppAction::OpenFilePicker, "file picker")
.unwrap();
app.app_keymap
.add(
Mode::Normal,
"qd",
AppAction::OpenBufferPicker,
"buffer picker",
)
.unwrap();
let mut replay: Vec<hjkl_keymap::KeyEvent> = Vec::new();
app.dispatch_keymap(
hjkl_keymap::KeyEvent::new(
hjkl_keymap::KeyCode::Char('q'),
hjkl_keymap::KeyModifiers::NONE,
),
1,
&mut replay,
);
app.dispatch_keymap(
hjkl_keymap::KeyEvent::new(
hjkl_keymap::KeyCode::Char('d'),
hjkl_keymap::KeyModifiers::NONE,
),
1,
&mut replay,
);
assert!(app.picker.is_some(), "qd must fire buffer picker");
assert_eq!(
app.picker.as_ref().unwrap().title(),
"buffers",
"buffer picker title expected"
);
}
#[test]
fn resolve_chord_timeout_returns_none_when_no_chord_pending() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(
app.resolve_chord_timeout(crate::app::keymap::HjklMode::Normal)
.is_none(),
"no pending chord → None"
);
}
fn km_prefix(app: &App, notation: &str) -> Vec<hjkl_keymap::KeyEvent> {
let leader = app.config.editor.leader;
hjkl_keymap::Chord::parse(notation, leader)
.expect("test chord must parse")
.0
}
#[test]
fn which_key_leader_submenu_shows_direct_leader_children() {
let app = App::new(None, false, None, None).unwrap();
let leader = app.config.editor.leader;
let prefix = km_prefix(&app, "<leader>");
let entries = crate::which_key::entries_for(
&app.app_keymap,
crate::app::keymap::HjklMode::Normal,
&prefix,
leader,
);
let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
assert!(keys.contains(&"f"), "missing f (file picker)");
assert!(keys.contains(&"b"), "missing b (buffer picker)");
assert!(keys.contains(&"/"), "missing / (grep picker)");
assert!(keys.contains(&"g"), "missing g (git submenu)");
assert!(
!keys.contains(&"gs"),
"gs must not appear at <leader> level"
);
assert!(
!keys.contains(&"gl"),
"gl must not appear at <leader> level"
);
assert!(
!keys.contains(&"gb"),
"gb must not appear at <leader> level"
);
}
#[test]
fn which_key_leader_g_shows_git_actions() {
let app = App::new(None, false, None, None).unwrap();
let leader = app.config.editor.leader;
let prefix = km_prefix(&app, "<leader>g");
let entries = crate::which_key::entries_for(
&app.app_keymap,
crate::app::keymap::HjklMode::Normal,
&prefix,
leader,
);
let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
assert!(keys.contains(&"s"), "missing s (git status)");
assert!(keys.contains(&"l"), "missing l (git log)");
assert!(keys.contains(&"b"), "missing b (git branches)");
assert!(keys.contains(&"S"), "missing S (git stashes)");
assert!(keys.contains(&"t"), "missing t (git tags)");
assert!(keys.contains(&"r"), "missing r (git remotes)");
}
#[test]
fn which_key_ctrl_w_shows_window_motions() {
let app = App::new(None, false, None, None).unwrap();
let leader = app.config.editor.leader;
let prefix = km_prefix(&app, "<C-w>");
let entries = crate::which_key::entries_for(
&app.app_keymap,
crate::app::keymap::HjklMode::Normal,
&prefix,
leader,
);
let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
assert!(keys.contains(&"h"), "missing h (focus left)");
assert!(keys.contains(&"j"), "missing j (focus down)");
assert!(keys.contains(&"k"), "missing k (focus up)");
assert!(keys.contains(&"l"), "missing l (focus right)");
assert!(keys.contains(&">"), "missing > (wider)");
assert!(keys.contains(&"<lt>"), "missing <lt> (narrower)");
}
#[test]
fn which_key_runtime_nmap_appears_in_entries() {
use crate::keymap_actions::AppAction;
let mut app = App::new(None, false, None, None).unwrap();
let leader = app.config.editor.leader;
app.app_keymap
.add(
crate::app::keymap::HjklMode::Normal,
"<leader>z",
AppAction::OpenFilePicker,
"runtime file picker",
)
.unwrap();
let prefix = km_prefix(&app, "<leader>");
let entries = crate::which_key::entries_for(
&app.app_keymap,
crate::app::keymap::HjklMode::Normal,
&prefix,
leader,
);
let found = entries.iter().find(|e| e.key == "z");
assert!(found.is_some(), "runtime <leader>z must appear in entries");
assert_eq!(
found.unwrap().desc,
"runtime file picker",
"description must match the registered binding"
);
}
#[test]
fn which_key_no_pending_popup_suppressed() {
let app = App::new(None, false, None, None).unwrap();
let pending = app.active_which_key_prefix();
assert!(
pending.is_empty(),
"fresh app must have no pending prefix, got {} events",
pending.len()
);
}
fn feed_km_key(app: &mut App, ct_key: KeyEvent) -> bool {
let Some(km_ev) = crate::keymap_translate::from_crossterm(&ct_key) else {
return false;
};
let mut replay = Vec::new();
app.dispatch_keymap(km_ev, 1, &mut replay)
}
#[test]
fn which_key_backspace_pops_one_key() {
let mut app = App::new(None, false, None, None).unwrap();
let leader = app.config.editor.leader;
feed_km_key(
&mut app,
KeyEvent::new(KeyCode::Char(leader), KeyModifiers::NONE),
);
feed_km_key(
&mut app,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert_eq!(
app.app_keymap
.pending(crate::app::keymap::HjklMode::Normal)
.len(),
2,
"should have 2 pending keys after <leader>g"
);
app.app_keymap.pop(crate::app::keymap::HjklMode::Normal);
assert!(
!app.which_key_sticky,
"sticky must be false when buffer non-empty after pop"
);
let pending = app.app_keymap.pending(crate::app::keymap::HjklMode::Normal);
assert_eq!(pending.len(), 1, "should have 1 pending key after pop");
assert_eq!(
pending[0].code,
hjkl_keymap::KeyCode::Char(leader),
"remaining key should be <leader>"
);
}
#[test]
fn which_key_backspace_to_empty_enters_sticky() {
let mut app = App::new(None, false, None, None).unwrap();
let leader = app.config.editor.leader;
feed_km_key(
&mut app,
KeyEvent::new(KeyCode::Char(leader), KeyModifiers::NONE),
);
assert_eq!(
app.app_keymap
.pending(crate::app::keymap::HjklMode::Normal)
.len(),
1
);
let removed = app.app_keymap.pop(crate::app::keymap::HjklMode::Normal);
assert!(removed.is_some(), "pop should return the removed key");
if app
.app_keymap
.pending(crate::app::keymap::HjklMode::Normal)
.is_empty()
{
app.which_key_sticky = true;
}
assert!(
app.app_keymap
.pending(crate::app::keymap::HjklMode::Normal)
.is_empty(),
"buffer must be empty after popping last key"
);
assert!(
app.which_key_sticky,
"sticky must be true after buffer empties"
);
}
#[test]
fn which_key_backspace_at_root_is_noop() {
let mut app = App::new(None, false, None, None).unwrap();
app.which_key_sticky = true;
assert!(
app.app_keymap
.pending(crate::app::keymap::HjklMode::Normal)
.is_empty()
);
let pending_non_empty = !app
.app_keymap
.pending(crate::app::keymap::HjklMode::Normal)
.is_empty();
let would_noop = !pending_non_empty && app.which_key_sticky;
assert!(would_noop, "backspace at root with sticky should noop");
assert!(app.which_key_sticky, "sticky must remain true after noop");
assert!(
app.app_keymap
.pending(crate::app::keymap::HjklMode::Normal)
.is_empty()
);
}
#[test]
fn which_key_esc_clears_sticky() {
let mut app = App::new(None, false, None, None).unwrap();
app.which_key_sticky = true;
app.app_keymap.reset(crate::app::keymap::HjklMode::Normal);
app.pending_count.reset();
app.clear_prefix_state();
app.which_key_sticky = false;
assert!(!app.which_key_sticky, "Esc must clear sticky");
assert!(
app.app_keymap
.pending(crate::app::keymap::HjklMode::Normal)
.is_empty()
);
}
#[test]
fn which_key_non_backspace_key_clears_sticky() {
let mut app = App::new(None, false, None, None).unwrap();
app.which_key_sticky = true;
app.which_key_sticky = false;
assert!(!app.which_key_sticky, "any non-backspace key clears sticky");
}
#[test]
fn pending_replace_with_count_replaces_five_chars() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abcdefgh");
app.active_mut().editor.jump_cursor(0, 0);
app.pending_count.try_accumulate('5');
drive_key(&mut app, key(KeyCode::Char('r')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::Replace { count: 5 })
),
"pending_state must be Replace {{ count: 5 }}, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('X')));
assert!(
app.pending_state.is_none(),
"pending_state must clear after commit"
);
let content = app.active().editor.buffer().as_string();
assert_eq!(
content, "XXXXXfgh",
"5rX must replace first 5 chars with X, got {content:?}"
);
}
#[test]
fn pending_replace_esc_cancels_without_mutation() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('r')));
assert!(app.pending_state.is_some());
drive_key(&mut app, key(KeyCode::Esc));
assert!(app.pending_state.is_none(), "Esc must cancel pending state");
let content = app.active().editor.buffer().as_string();
assert_eq!(content, "abc", "buffer must be unchanged after cancel");
}
#[test]
fn zz_centers_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..20).map(|i| format!("line {i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(10, 0);
drive_key(&mut app, key(KeyCode::Char('z')));
assert!(
app.pending_state.is_some(),
"z must set AfterZ pending state"
);
drive_key(&mut app, key(KeyCode::Char('z')));
assert!(
app.pending_state.is_none(),
"second key must commit and clear pending state"
);
assert_eq!(
app.active().editor.cursor().0,
10,
"zz must not move the cursor row"
);
}
#[test]
fn zt_scrolls_top() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..20).map(|i| format!("line {i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(10, 0);
drive_key(&mut app, key(KeyCode::Char('z')));
drive_key(&mut app, key(KeyCode::Char('t')));
assert!(
app.pending_state.is_none(),
"pending_state cleared after zt commit"
);
assert_eq!(
app.active().editor.cursor().0,
10,
"zt must not move the cursor row"
);
}
#[test]
fn zo_opens_fold() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "a\nb\nc\nd");
app.active_mut().editor.buffer_mut().add_fold(1, 2, true);
app.active_mut().editor.jump_cursor(1, 0);
drive_key(&mut app, key(KeyCode::Char('z')));
drive_key(&mut app, key(KeyCode::Char('o')));
assert!(
app.pending_state.is_none(),
"pending_state cleared after zo commit"
);
let folds = app.active().editor.buffer().folds();
assert!(!folds[0].closed, "zo must open the fold at cursor");
}
#[test]
fn zm_closes_all_folds() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "a\nb\nc\nd\ne\nf");
app.active_mut().editor.buffer_mut().add_fold(0, 1, false);
app.active_mut().editor.buffer_mut().add_fold(4, 5, false);
drive_key(&mut app, key(KeyCode::Char('z')));
drive_key(&mut app, key(KeyCode::Char('M')));
let folds = app.active().editor.buffer().folds();
assert!(folds.iter().all(|f| f.closed), "zM must close all folds");
}
#[test]
fn z_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello\nworld");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('z')));
assert!(
app.pending_state.is_some(),
"z must set AfterZ pending state"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must cancel AfterZ pending state"
);
assert_eq!(
app.active().editor.cursor(),
(0, 0),
"cursor must not move on cancel"
);
}
#[test]
fn zf_in_visual_creates_fold() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "a\nb\nc\nd\ne");
app.active_mut().editor.jump_cursor(1, 0);
drive_key(&mut app, key(KeyCode::Char('V')));
drive_key(&mut app, key(KeyCode::Char('j')));
drive_key(&mut app, key(KeyCode::Char('j')));
drive_key(&mut app, key(KeyCode::Char('z')));
drive_key(&mut app, key(KeyCode::Char('f')));
let folds = app.active().editor.buffer().folds();
assert_eq!(folds.len(), 1, "zf in visual must create exactly one fold");
assert_eq!(
folds[0].start_row, 1,
"fold must start at visual anchor row"
);
assert_eq!(folds[0].end_row, 3, "fold must end at cursor row");
assert!(folds[0].closed, "fold must be closed");
}
fn drive_chars(app: &mut App, s: &str) {
for c in s.chars() {
drive_key(app, key(KeyCode::Char(c)));
}
}
#[test]
fn dw_deletes_word_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Delete,
count1: 1,
inner_count: 0,
})
),
"d must set AfterOp(Delete) pending, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('w')));
assert!(
app.pending_state.is_none(),
"pending must clear after commit"
);
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(line, "world", "dw must delete 'hello ', got {line:?}");
}
#[test]
fn dd_deletes_line_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line1\nline2\nline3");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "dd");
assert!(app.pending_state.is_none());
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert_eq!(lines, vec!["line2", "line3"], "dd must delete line1");
}
#[test]
fn d3w_deletes_three_words_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "one two three four");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('3')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp { inner_count: 3, .. })
),
"after d3, inner_count must be 3, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('w')));
assert!(app.pending_state.is_none());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "four",
"d3w must delete 'one two three ', got {line:?}"
);
}
#[test]
fn two_dd_deletes_two_lines_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line1\nline2\nline3");
app.active_mut().editor.jump_cursor(0, 0);
app.pending_count.try_accumulate('2');
drive_key(&mut app, key(KeyCode::Char('d')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp { count1: 2, .. })
),
"count1 must be 2, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('d')));
assert!(app.pending_state.is_none());
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["line3"],
"2dd must delete two lines, got {lines:?}"
);
}
#[test]
fn cw_changes_to_word_end() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "cw");
assert!(app.pending_state.is_none());
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Insert,
"cw must enter Insert mode"
);
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert!(
line.starts_with(' ') || line == " world",
"cw quirk: trailing space must be preserved, got {line:?}"
);
}
#[test]
fn dip_text_object_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\n\nfoo bar");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('i')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpTextObj {
op: hjkl_vim::OperatorKind::Delete,
inner: true,
..
})
),
"after di, reducer must hold OpTextObj(Delete,inner=true), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending after reducer-owned di"
);
drive_key(&mut app, key(KeyCode::Char('p')));
assert!(
app.pending_state.is_none(),
"pending must clear after ApplyOpTextObj commit"
);
assert!(!app.active().editor.is_chord_pending());
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert!(
!lines.contains(&"hello world".to_string()),
"dip must delete first paragraph, got {lines:?}"
);
}
#[test]
fn dgg_deletes_to_top() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line1\nline2\nline3");
app.active_mut().editor.jump_cursor(2, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Delete,
..
})
),
"d must set AfterOp(Delete), got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpG {
op: hjkl_vim::OperatorKind::Delete,
total_count: 1,
})
),
"after dg, reducer must be in OpG state, got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending after dg (reducer owns OpG)"
);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
app.pending_state.is_none(),
"pending must clear after ApplyOpG commit"
);
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert!(
lines.is_empty() || lines == vec![""],
"dgg from line3 must delete all lines, got {lines:?}"
);
}
#[test]
fn dfx_deletes_to_x_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello x world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Delete,
..
})
),
"d must set AfterOp(Delete), got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('f')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpFind {
op: hjkl_vim::OperatorKind::Delete,
forward: true,
till: false,
..
})
),
"df must transition to OpFind(forward, !till), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be in chord-pending after reducer-owned df"
);
drive_key(&mut app, key(KeyCode::Char('x')));
assert!(
app.pending_state.is_none(),
"pending must clear after ApplyOpFind commit"
);
assert!(!app.active().editor.is_chord_pending());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(line, " world", "dfx must delete 'hello x', got {line:?}");
}
#[test]
fn dtx_stops_before_x_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello x world");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "dtx");
assert!(app.pending_state.is_none());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "x world",
"dtx must delete 'hello ' leaving 'x world', got {line:?}"
);
}
#[test]
fn two_d_3fx_total_count_6() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "xaxbxcxdxexf");
app.active_mut().editor.jump_cursor(0, 0);
app.pending_count.try_accumulate('2');
drive_key(&mut app, key(KeyCode::Char('d')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp { count1: 2, .. })
),
"count1 must be 2 after pending_count+d, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('3')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp { inner_count: 3, .. })
),
"inner_count must accumulate to 3, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('f')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpFind {
total_count: 6,
forward: true,
till: false,
..
})
),
"OpFind total_count must be 6 (2*3), got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('x')));
assert!(app.pending_state.is_none());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(line, "f", "2d3fx must delete through 6th 'x', got {line:?}");
}
#[test]
fn df_then_esc_cancels_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello x world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('f')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpFind { .. })
),
"df must set OpFind, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must cancel OpFind pending"
);
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "hello x world",
"buffer must be unchanged after df<Esc>"
);
}
#[test]
fn cfx_changes_to_x_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello x world");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "cfx");
assert!(app.pending_state.is_none());
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Insert,
"cfx must enter Insert mode"
);
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(line, " world", "cfx must delete 'hello x', got {line:?}");
}
#[test]
fn gufx_uppercases_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello x world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { .. })
),
"g must set AfterG"
);
drive_key(&mut app, key(KeyCode::Char('U')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Uppercase,
..
})
),
"gU must set reducer AfterOp(Uppercase), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending after 2c-v gU intercept"
);
drive_key(&mut app, key(KeyCode::Char('f')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpFind {
op: hjkl_vim::OperatorKind::Uppercase,
forward: true,
till: false,
..
})
),
"f after gU must set reducer OpFind(forward:true), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending (reducer owns find)"
);
drive_key(&mut app, key(KeyCode::Char('x')));
assert!(app.pending_state.is_none(), "pending must clear after gUfx");
assert!(!app.active().editor.is_chord_pending());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "HELLO X world",
"gUfx must uppercase 'hello x', got {line:?}"
);
}
#[test]
fn d_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
assert!(app.pending_state.is_some(), "d must set pending state");
drive_key(&mut app, key(KeyCode::Esc));
assert!(app.pending_state.is_none(), "Esc must cancel pending");
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(line, "hello", "buffer must be unchanged after cancel");
}
#[test]
fn y_dollar_yanks_to_eol() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('y')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Yank,
..
})
),
"y must set AfterOp(Yank)"
);
drive_key(&mut app, key(KeyCode::Char('$')));
assert!(app.pending_state.is_none(), "pending must clear after y$");
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(line, "hello world", "y$ must not modify buffer");
}
#[test]
fn g_uw_uppercases_word_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { .. })
),
"g must set AfterG pending, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('U')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Uppercase,
..
})
),
"gU must set reducer AfterOp(Uppercase), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending (reducer owns op-pending)"
);
drive_key(&mut app, key(KeyCode::Char('w')));
assert!(app.pending_state.is_none(), "pending must clear after gUw");
assert!(!app.active().editor.is_chord_pending());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "HELLO world",
"gUw must uppercase first word, got {line:?}"
);
}
#[test]
fn dgg_deletes_to_top_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "aaa\nbbb\nccc");
app.active_mut().editor.jump_cursor(2, 0);
drive_chars(&mut app, "dgg");
assert!(app.pending_state.is_none(), "pending must clear after dgg");
assert!(!app.active().editor.is_chord_pending());
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert!(
lines.is_empty() || lines == vec![""],
"dgg from last line must delete all content, got {lines:?}"
);
}
#[test]
fn dge_deletes_word_end_back_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
assert!(app.pending_state.is_some(), "d sets AfterOp");
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(app.pending_state, Some(hjkl_vim::PendingState::OpG { .. })),
"g transitions to OpG, got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('e')));
assert!(app.pending_state.is_none(), "pending clears after dge");
assert!(!app.active().editor.is_chord_pending());
}
#[test]
fn dgj_deletes_screen_down_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line1\nline2\nline3");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "dgj");
assert!(app.pending_state.is_none(), "pending clears after dgj");
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["line3"],
"dgj must delete line1+line2, got {lines:?}"
);
}
#[test]
fn dg_then_esc_cancels_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "unchanged");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(app.pending_state, Some(hjkl_vim::PendingState::OpG { .. })),
"must be in OpG state before Esc"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(app.pending_state.is_none(), "Esc must cancel OpG");
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "unchanged",
"buffer must be unchanged after cancel, got {line:?}"
);
}
#[test]
fn g_ugg_uppercases_to_top_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello\nworld\nfoo");
app.active_mut().editor.jump_cursor(2, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { .. })
),
"g must set AfterG"
);
drive_key(&mut app, key(KeyCode::Char('U')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Uppercase,
..
})
),
"gU must set reducer AfterOp(Uppercase), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending after 2c-v gU intercept"
);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::OpG {
op: hjkl_vim::OperatorKind::Uppercase,
..
})
),
"g after gU must set reducer OpG(Uppercase), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine must NOT be chord-pending (reducer owns OpG)"
);
drive_key(&mut app, key(KeyCode::Char('g')));
assert!(app.pending_state.is_none(), "pending must clear after gUgg");
assert!(
!app.active().editor.is_chord_pending(),
"engine chord must complete"
);
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert!(
lines.iter().all(|l| l.chars().all(|c| !c.is_lowercase())),
"gUgg must uppercase all lines to top, got {lines:?}"
);
}
#[test]
fn g_uu_uppercases_line_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "gUU");
assert!(app.pending_state.is_none(), "pending must clear after gUU");
assert!(!app.active().editor.is_chord_pending());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "HELLO WORLD",
"gUU must uppercase entire line, got {line:?}"
);
}
#[test]
fn guu_lowercases_line_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "HELLO WORLD");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "guu");
assert!(app.pending_state.is_none(), "pending must clear after guu");
assert!(!app.active().editor.is_chord_pending());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "hello world",
"guu must lowercase entire line, got {line:?}"
);
}
#[test]
fn g_tilde_tilde_toggles_line_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "Hello World");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "g~~");
assert!(app.pending_state.is_none(), "pending must clear after g~~");
assert!(!app.active().editor.is_chord_pending());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert_eq!(
line, "hELLO wORLD",
"g~~ must toggle case of entire line, got {line:?}"
);
}
#[test]
fn gqq_reflows_line_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_chars(&mut app, "gqq");
assert!(app.pending_state.is_none(), "pending must clear after gqq");
assert!(!app.active().editor.is_chord_pending());
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert!(
!line.is_empty(),
"gqq must not delete short line, got {line:?}"
);
}
#[test]
fn two_g_uw_uppercases_two_words_via_reducer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world foo");
app.active_mut().editor.jump_cursor(0, 0);
app.pending_state = Some(hjkl_vim::PendingState::AfterG { count: 2 });
drive_key(&mut app, key(KeyCode::Char('U')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Uppercase,
count1: 2,
..
})
),
"2gU must set AfterOp(Uppercase, count1:2), got {:?}",
app.pending_state
);
drive_key(&mut app, key(KeyCode::Char('w')));
assert!(app.pending_state.is_none(), "pending must clear after 2gUw");
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert!(
line.starts_with("HELLO WORLD"),
"2gUw must uppercase first 2 words, got {line:?}"
);
}
#[test]
fn engine_pending_none_after_g_u_in_reducer_path() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('g')));
drive_key(&mut app, key(KeyCode::Char('U')));
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterOp {
op: hjkl_vim::OperatorKind::Uppercase,
..
})
),
"gU must set reducer AfterOp(Uppercase), got {:?}",
app.pending_state
);
assert!(
!app.active().editor.is_chord_pending(),
"engine Pending must be None after 2c-v gU intercept"
);
}
#[test]
fn visual_g_u_uppercases_selection() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('v')));
for _ in 0..4 {
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('l')));
}
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Visual,
"must be in Visual mode"
);
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('g')));
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('U')));
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"gU in visual must return to Normal mode"
);
let line = app
.active()
.editor
.buffer()
.lines()
.first()
.cloned()
.unwrap_or_default();
assert!(
line.starts_with("HELLO"),
"visual gU must uppercase selection 'hello', got {line:?}"
);
}
#[test]
fn quote_a_then_dd_deletes_into_register_a() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\nline two");
assert_eq!(app.active().editor.cursor().0, 0);
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('a')));
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('d')));
let slot = app.active().editor.registers().read('a');
assert!(slot.is_some(), "register 'a' should be set after \"add");
let text = &slot.unwrap().text;
assert!(
text.contains("hello world"),
"register 'a' should contain 'hello world', got {text:?}"
);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(lines, vec!["line two"], "\"add must delete first line");
}
#[test]
fn quote_a_then_yy_then_quote_a_then_p_pastes_named_register() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "first line\nsecond line");
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('a')));
drive_key(&mut app, key(KeyCode::Char('y')));
drive_key(&mut app, key(KeyCode::Char('y')));
let slot = app.active().editor.registers().read('a');
assert!(slot.is_some(), "register 'a' must be set after \"ayy");
let text = slot.unwrap().text.clone();
assert!(
text.contains("first line"),
"register 'a' should contain 'first line', got {text:?}"
);
drive_key(&mut app, key(KeyCode::Char('j')));
assert_eq!(
app.active().editor.cursor().0,
1,
"cursor must be on line 1"
);
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('a')));
drive_key(&mut app, key(KeyCode::Char('p')));
let lines = app.active().editor.buffer().lines().to_vec();
assert!(lines.len() >= 3, "paste must add a line, got {lines:?}");
assert!(
lines.iter().any(|l| l.contains("first line")),
"pasted content must contain 'first line', got {lines:?}"
);
}
#[test]
fn quote_underscore_then_dd_blackhole_no_unnamed_change() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "keep me\ndelete me\nkeep too");
drive_key(&mut app, key(KeyCode::Char('y')));
drive_key(&mut app, key(KeyCode::Char('y')));
let baseline = app
.active()
.editor
.registers()
.read('"')
.map(|s| s.text.clone())
.unwrap_or_default();
drive_key(&mut app, key(KeyCode::Char('j')));
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('_')));
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('d')));
let after = app
.active()
.editor
.registers()
.read('"')
.map(|s| s.text.clone())
.unwrap_or_default();
assert_eq!(
baseline, after,
"\"_dd must not overwrite the unnamed register"
);
let lines = app.active().editor.buffer().lines().to_vec();
assert!(
!lines.iter().any(|l| l.contains("delete me")),
"\"_dd must still delete the line from the buffer, got {lines:?}"
);
}
#[test]
fn quote_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
drive_key(&mut app, key(KeyCode::Char('"')));
assert!(
app.pending_state.is_some(),
"\" must set app pending_state to SelectRegister"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must clear pending_state after \""
);
}
#[test]
fn quote_invalid_char_no_register_set() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\nsecond");
drive_key(&mut app, key(KeyCode::Char('y')));
drive_key(&mut app, key(KeyCode::Char('y')));
let baseline_unnamed = app
.active()
.editor
.registers()
.read('"')
.map(|s| s.text.clone())
.unwrap_or_default();
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('!')));
assert!(
app.pending_state.is_none(),
"invalid register char must cancel pending_state"
);
let slot = app.active().editor.registers().read('!');
assert!(slot.is_none(), "register '!' must not exist");
let after = app
.active()
.editor
.registers()
.read('"')
.map(|s| s.text.clone())
.unwrap_or_default();
assert_eq!(
baseline_unnamed, after,
"unnamed register must be unchanged after \"!"
);
}
fn km_char(c: char) -> hjkl_keymap::KeyEvent {
hjkl_keymap::KeyEvent::new(
hjkl_keymap::KeyCode::Char(c),
hjkl_keymap::KeyModifiers::empty(),
)
}
fn win_cursor_row(app: &App) -> usize {
let fw = app.focused_window();
app.windows[fw].as_ref().unwrap().cursor_row
}
fn win_cursor_col(app: &App) -> usize {
let fw = app.focused_window();
app.windows[fw].as_ref().unwrap().cursor_col
}
fn assert_window_synced_to_engine(app: &App) {
let fw = app.focused_window();
let win = app.windows[fw].as_ref().unwrap();
let (e_row, e_col) = app.active().editor.cursor();
let e_top = app.active().editor.host().viewport().top_row;
assert_eq!(
win.cursor_row, e_row,
"window.cursor_row out of sync with engine cursor"
);
assert_eq!(
win.cursor_col, e_col,
"window.cursor_col out of sync with engine cursor"
);
assert_eq!(
win.top_row, e_top,
"window.top_row out of sync with engine viewport"
);
}
#[test]
fn j_motion_via_keymap_updates_window_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
assert_eq!(
win_cursor_row(&app),
0,
"precondition: window cursor_row at 0"
);
let km_ev = km_char('j');
app.route_chord_key(App::km_to_crossterm(&km_ev));
assert_eq!(
win_cursor_row(&app),
1,
"j via keymap must update window cursor_row to 1"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn k_motion_via_keymap_updates_window_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2");
app.active_mut().editor.jump_cursor(2, 0);
app.sync_viewport_from_editor();
assert_eq!(
win_cursor_row(&app),
2,
"precondition: window cursor_row at 2"
);
let km_ev = km_char('k');
app.route_chord_key(App::km_to_crossterm(&km_ev));
assert_eq!(
win_cursor_row(&app),
1,
"k via keymap must update window cursor_row to 1"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn line_start_zero_motion_via_keymap_updates_window_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 5);
app.sync_viewport_from_editor();
assert_eq!(
win_cursor_col(&app),
5,
"precondition: window cursor_col at 5"
);
let km_ev = km_char('0');
app.route_chord_key(App::km_to_crossterm(&km_ev));
assert_eq!(
win_cursor_col(&app),
0,
"0 via keymap must update window cursor_col to 0"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn line_end_dollar_motion_via_keymap_updates_window_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
assert_eq!(
win_cursor_col(&app),
0,
"precondition: window cursor_col at 0"
);
let km_ev = km_char('$');
app.route_chord_key(App::km_to_crossterm(&km_ev));
assert_eq!(
win_cursor_col(&app),
4,
"$ via keymap must update window cursor_col to 4 (last char of 'hello')"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn motion_via_keymap_scrolls_viewport_to_follow_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..50).map(|i| format!("line{i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(0, 0);
app.active_mut().editor.set_viewport_height(10);
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 10;
vp.top_row = 0;
}
app.sync_viewport_from_editor();
let fw = app.focused_window();
assert_eq!(
app.windows[fw].as_ref().unwrap().top_row,
0,
"precondition: window top_row at 0"
);
let km_ev = km_char('j');
for _ in 0..20 {
app.route_chord_key(App::km_to_crossterm(&km_ev));
}
let fw = app.focused_window();
let win = app.windows[fw].as_ref().unwrap();
assert_eq!(
win.cursor_row, 20,
"engine cursor should be at row 20 after 20 j's"
);
assert!(
win.top_row > 0,
"window top_row must advance so cursor stays visible; got top_row={}, cursor_row={}",
win.top_row,
win.cursor_row
);
let height = 10usize;
assert!(
win.cursor_row >= win.top_row && win.cursor_row < win.top_row + height,
"cursor must be inside viewport: top_row={}, height={}, cursor_row={}",
win.top_row,
height,
win.cursor_row
);
assert_window_synced_to_engine(&app);
}
#[test]
fn gg_via_pending_state_scrolls_viewport_to_top() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..50).map(|i| format!("line{i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(40, 0);
app.active_mut().editor.set_viewport_height(10);
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 10;
vp.top_row = 35;
}
app.sync_viewport_from_editor();
let fw = app.focused_window();
assert_eq!(
app.windows[fw].as_ref().unwrap().top_row,
35,
"precondition: window top_row at 35"
);
let km_g = km_char('g');
app.route_chord_key(App::km_to_crossterm(&km_g));
assert!(
app.pending_state.is_some(),
"after first g, pending_state must be Some(AfterG)"
);
app.active_mut().editor.after_g('g', 1);
app.sync_after_engine_mutation();
app.pending_state = None;
let fw = app.focused_window();
let win = app.windows[fw].as_ref().unwrap();
assert_eq!(win.cursor_row, 0, "gg must move cursor to row 0");
assert_eq!(
win.top_row, 0,
"gg must scroll viewport top_row to 0; got top_row={}",
win.top_row
);
assert_window_synced_to_engine(&app);
}
#[test]
fn count_prefix_motion_via_keymap_updates_window_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..10).map(|i| format!("line{i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
assert_eq!(
win_cursor_row(&app),
0,
"precondition: window cursor_row at 0"
);
assert!(
app.pending_count.try_accumulate('5'),
"digit '5' must be accepted by pending_count"
);
let km_ev = km_char('j');
app.route_chord_key(App::km_to_crossterm(&km_ev));
assert_eq!(
win_cursor_row(&app),
5,
"5j via keymap must update window cursor_row to 5"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn all_phase3_keymap_motions_keep_window_synced() {
use hjkl_vim::MotionKind;
let kinds = [
MotionKind::CharLeft,
MotionKind::CharRight,
MotionKind::LineDown,
MotionKind::LineUp,
MotionKind::FirstNonBlankDown,
MotionKind::FirstNonBlankUp,
MotionKind::WordForward,
MotionKind::BigWordForward,
MotionKind::WordBackward,
MotionKind::BigWordBackward,
MotionKind::WordEnd,
MotionKind::BigWordEnd,
MotionKind::LineStart,
MotionKind::FirstNonBlank,
MotionKind::LineEnd,
MotionKind::GotoLine,
MotionKind::FindRepeat,
MotionKind::FindRepeatReverse,
MotionKind::BracketMatch,
MotionKind::ViewportTop,
MotionKind::ViewportMiddle,
MotionKind::ViewportBottom,
MotionKind::HalfPageDown,
MotionKind::HalfPageUp,
MotionKind::FullPageDown,
MotionKind::FullPageUp,
];
for kind in kinds {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..50)
.map(|i| format!("line{i:02}-some-content-here"))
.collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(20, 5);
app.active_mut().editor.set_viewport_height(10);
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 10;
vp.top_row = 15;
}
app.sync_viewport_from_editor();
app.dispatch_action(
crate::keymap_actions::AppAction::Motion { kind, count: 1 },
1,
);
app.sync_after_engine_mutation();
assert_window_synced_to_engine(&app);
}
}
#[test]
fn visual_block_h_l_extend_selection() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "0123456789\nabcdefghij\nklmnopqrst\nuvwxyz1234");
app.active_mut().editor.jump_cursor(0, 2);
app.sync_viewport_from_editor();
{
use crossterm::event::{KeyCode, KeyEvent as CtKeyEvent, KeyModifiers};
hjkl_vim::handle_key(
&mut app.active_mut().editor,
CtKeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
);
}
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::VisualBlock,
"must be in VisualBlock mode after <C-v>"
);
let km_l = km_char('l');
for _ in 0..3 {
app.route_chord_key(App::km_to_crossterm(&km_l));
}
let (_, e_col) = app.active().editor.cursor();
assert_eq!(e_col, 5, "cursor must advance to col 5 after 3 l's");
let highlight = app
.active()
.editor
.block_highlight()
.expect("block_highlight must be Some in VisualBlock mode");
let (_top, _bot, _left, right) = highlight;
assert_eq!(
right, 5,
"block_vcol must follow cursor: expected right edge 5, got {right}"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn gg_via_pending_state_in_visual_mode() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..30).map(|i| format!("line{i:02}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(20, 0);
app.active_mut().editor.set_viewport_height(10);
app.sync_viewport_from_editor();
{
use crossterm::event::{KeyCode, KeyEvent as CtKeyEvent, KeyModifiers};
hjkl_vim::handle_key(
&mut app.active_mut().editor,
CtKeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE),
);
}
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Visual,
"must be in Visual mode after v"
);
app.pending_state = Some(hjkl_vim::PendingState::AfterG { count: 1 });
app.active_mut().editor.after_g('g', 1);
app.sync_after_engine_mutation();
app.pending_state = None;
let fw = app.focused_window();
let win = app.windows[fw].as_ref().unwrap();
assert_eq!(
win.cursor_row, 0,
"gg must move cursor to row 0 from row 20 in Visual mode"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn gg_via_pending_state_in_visual_line_mode() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..30).map(|i| format!("line{i:02}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(20, 0);
app.active_mut().editor.set_viewport_height(10);
app.sync_viewport_from_editor();
{
use crossterm::event::{KeyCode, KeyEvent as CtKeyEvent, KeyModifiers};
hjkl_vim::handle_key(
&mut app.active_mut().editor,
CtKeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE),
);
}
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::VisualLine,
"must be in VisualLine mode after V"
);
app.pending_state = Some(hjkl_vim::PendingState::AfterG { count: 1 });
app.active_mut().editor.after_g('g', 1);
app.sync_after_engine_mutation();
app.pending_state = None;
let fw = app.focused_window();
let win = app.windows[fw].as_ref().unwrap();
assert_eq!(
win.cursor_row, 0,
"gg must move cursor to row 0 from row 20 in VisualLine mode"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn gg_via_pending_state_in_visual_block_mode() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..30).map(|i| format!("line{i:02}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(20, 0);
app.active_mut().editor.set_viewport_height(10);
app.sync_viewport_from_editor();
{
use crossterm::event::{KeyCode, KeyEvent as CtKeyEvent, KeyModifiers};
hjkl_vim::handle_key(
&mut app.active_mut().editor,
CtKeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
);
}
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::VisualBlock,
"must be in VisualBlock mode after <C-v>"
);
app.pending_state = Some(hjkl_vim::PendingState::AfterG { count: 1 });
app.active_mut().editor.after_g('g', 1);
app.sync_after_engine_mutation();
app.pending_state = None;
let fw = app.focused_window();
let win = app.windows[fw].as_ref().unwrap();
assert_eq!(
win.cursor_row, 0,
"gg must move cursor to row 0 from row 20 in VisualBlock mode"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn gg_full_sequence_in_visual_line_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..30).map(|i| format!("line{i:02}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(20, 0);
app.sync_viewport_from_editor();
use crossterm::event::{KeyCode, KeyEvent as CtKeyEvent, KeyModifiers};
hjkl_vim::handle_key(
&mut app.active_mut().editor,
CtKeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE),
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::VisualLine,
"must be in VisualLine mode"
);
let g_key = CtKeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
let consumed = app.route_chord_key(g_key);
assert!(consumed, "first g must be consumed");
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { .. })
),
"first g must set pending_state to AfterG; got {:?}",
app.pending_state
);
let consumed = app.route_chord_key(g_key);
assert!(consumed, "second g must be consumed");
assert!(
app.pending_state.is_none(),
"after gg the reducer must clear pending_state"
);
assert_eq!(
app.active().editor.cursor().0,
0,
"gg must move engine cursor to row 0 from row 20"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn gg_full_sequence_in_visual_mode_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..30).map(|i| format!("line{i:02}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(20, 0);
app.sync_viewport_from_editor();
use crossterm::event::{KeyCode, KeyEvent as CtKeyEvent, KeyModifiers};
hjkl_vim::handle_key(
&mut app.active_mut().editor,
CtKeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE),
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Visual,
"must be in Visual mode"
);
let g_key = CtKeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
let consumed = app.route_chord_key(g_key);
assert!(consumed, "first g must be consumed");
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { .. })
),
"first g must set pending_state to AfterG; got {:?}",
app.pending_state
);
let consumed = app.route_chord_key(g_key);
assert!(consumed, "second g must be consumed");
assert!(
app.pending_state.is_none(),
"after gg the reducer must clear pending_state"
);
assert_eq!(
app.active().editor.cursor().0,
0,
"gg must move engine cursor to row 0 from row 20"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn gg_full_sequence_in_visual_block_mode_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..30).map(|i| format!("line{i:02}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(20, 0);
app.sync_viewport_from_editor();
use crossterm::event::{KeyCode, KeyEvent as CtKeyEvent, KeyModifiers};
hjkl_vim::handle_key(
&mut app.active_mut().editor,
CtKeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::VisualBlock,
"must be in VisualBlock mode"
);
let g_key = CtKeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
let consumed = app.route_chord_key(g_key);
assert!(consumed, "first g must be consumed");
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { .. })
),
"first g must set pending_state to AfterG; got {:?}",
app.pending_state
);
let consumed = app.route_chord_key(g_key);
assert!(consumed, "second g must be consumed");
assert!(
app.pending_state.is_none(),
"after gg the reducer must clear pending_state"
);
assert_eq!(
app.active().editor.cursor().0,
0,
"gg must move engine cursor to row 0 from row 20"
);
assert_window_synced_to_engine(&app);
}
fn ck(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
#[test]
fn visual_d_deletes_selection_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('v'));
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Visual,
"must be in Visual after v"
);
for _ in 0..4 {
let consumed = app.route_chord_key(ck('l'));
assert!(consumed, "l in Visual must be consumed by keymap");
}
let consumed = app.route_chord_key(ck('d'));
assert!(
consumed,
"d in Visual must be consumed by keymap (VisualOp)"
);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec![" world"],
"vd must delete selected chars; got {lines:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"must exit Visual mode after d"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn visual_y_yanks_selection_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('v'));
for _ in 0..4 {
app.route_chord_key(ck('l'));
}
let consumed = app.route_chord_key(ck('y'));
assert!(
consumed,
"y in Visual must be consumed by keymap (VisualOp)"
);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["hello world"],
"vy must not modify the buffer; got {lines:?}"
);
let reg = app.active().editor.yank();
assert!(
reg.contains("hello"),
"unnamed register must contain 'hello' after vy; got {reg:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"must exit Visual mode after y"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn visual_line_d_deletes_line_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "first line\nsecond line");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE),
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::VisualLine,
"must be in VisualLine after V"
);
let consumed = app.route_chord_key(ck('d'));
assert!(
consumed,
"d in VisualLine must be consumed by keymap (VisualOp)"
);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["second line"],
"Vd must delete first line; got {lines:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"must exit VisualLine mode after d"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn visual_c_enters_insert_mode_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('v'));
for _ in 0..4 {
app.route_chord_key(ck('l'));
}
let consumed = app.route_chord_key(ck('c'));
assert!(
consumed,
"c in Visual must be consumed by keymap (VisualOp)"
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Insert,
"vc must enter Insert mode; got {:?}",
app.active().editor.vim_mode()
);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec![" world"],
"vc must delete selected chars; got {lines:?}"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn gg_full_sequence_in_normal_mode_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..30).map(|i| format!("line{i:02}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.jump_cursor(20, 0);
app.sync_viewport_from_editor();
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"must be in Normal mode"
);
use crossterm::event::{KeyCode, KeyEvent as CtKeyEvent, KeyModifiers};
let g_key = CtKeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
let consumed = app.route_chord_key(g_key);
assert!(consumed, "first g must be consumed");
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::AfterG { .. })
),
"first g must set pending_state to AfterG; got {:?}",
app.pending_state
);
let consumed = app.route_chord_key(g_key);
assert!(consumed, "second g must be consumed");
assert!(
app.pending_state.is_none(),
"after gg the reducer must clear pending_state"
);
assert_eq!(
app.active().editor.cursor().0,
0,
"gg must move engine cursor to row 0 from row 20"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn visual_d_with_named_register_writes_to_register() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('"'));
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('a'));
assert_eq!(
app.active().editor.pending_register(),
Some('a'),
"pending_register must be Some('a') after \"a chord"
);
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('v'));
for _ in 0..4 {
app.route_chord_key(ck('l'));
}
let consumed = app.route_chord_key(ck('d'));
assert!(consumed, "d in Visual must be consumed");
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec![" world"],
"\"ad must delete selection; got {lines:?}"
);
let reg_a = &app.active().editor.registers().named[0]; assert!(
reg_a.text.contains("hello"),
"register 'a' must contain 'hello' after \"ad; got {:?}",
reg_a.text
);
assert_eq!(app.active().editor.vim_mode(), hjkl_engine::VimMode::Normal);
assert_window_synced_to_engine(&app);
}
#[test]
fn visual_line_d_deletes_single_line_via_range_mutation() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "only line\nsecond line");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE),
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::VisualLine
);
let consumed = app.route_chord_key(ck('d'));
assert!(consumed, "d in VisualLine must be consumed");
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["second line"],
"Vd on single line must delete it; got {lines:?}"
);
assert_eq!(app.active().editor.vim_mode(), hjkl_engine::VimMode::Normal);
assert_window_synced_to_engine(&app);
}
#[test]
fn visual_block_d_deletes_rectangle_via_range_mutation() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abcde\nfghij\nklmno");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::VisualBlock
);
for _ in 0..2 {
app.route_chord_key(ck('l'));
}
for _ in 0..2 {
app.route_chord_key(ck('j'));
}
let consumed = app.route_chord_key(ck('d'));
assert!(consumed, "d in VisualBlock must be consumed");
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["de", "ij", "no"],
"VisualBlock d must remove cols 0..=2 on each row; got {lines:?}"
);
assert_eq!(app.active().editor.vim_mode(), hjkl_engine::VimMode::Normal);
assert_window_synced_to_engine(&app);
}
#[test]
fn visual_block_y_yanks_rectangle_to_register() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abcde\nfghij\nklmno");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('"'));
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('a'));
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
);
app.route_chord_key(ck('l'));
app.route_chord_key(ck('j'));
let consumed = app.route_chord_key(ck('y'));
assert!(consumed, "y in VisualBlock must be consumed");
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["abcde", "fghij", "klmno"],
"VisualBlock y must not modify buffer"
);
let reg_a = &app.active().editor.registers().named[0];
assert!(
!reg_a.text.is_empty(),
"register 'a' must be non-empty after block yank"
);
assert!(
reg_a.text.contains("ab") && reg_a.text.contains("fg"),
"register 'a' must contain block text 'ab'/'fg'; got {:?}",
reg_a.text
);
assert_eq!(app.active().editor.vim_mode(), hjkl_engine::VimMode::Normal);
assert_window_synced_to_engine(&app);
}
#[test]
fn m_a_then_apostrophe_a_jumps_back_to_line() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "first line\n second line\nthird line");
app.active_mut().editor.jump_cursor(2, 3);
drive_key(&mut app, key(KeyCode::Char('m')));
drive_key(&mut app, key(KeyCode::Char('a')));
assert!(
app.pending_state.is_none(),
"pending_state must clear after ma"
);
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('\'')));
drive_key(&mut app, key(KeyCode::Char('a')));
assert!(
app.pending_state.is_none(),
"pending_state must clear after 'a"
);
assert_eq!(
app.active().editor.cursor().0,
2,
"'a must jump back to mark row"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn m_a_then_backtick_a_jumps_back_to_pos() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "first line\nsecond line\nthird line");
app.active_mut().editor.jump_cursor(1, 4);
drive_key(&mut app, key(KeyCode::Char('m')));
drive_key(&mut app, key(KeyCode::Char('a')));
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('`')));
drive_key(&mut app, key(KeyCode::Char('a')));
assert!(
app.pending_state.is_none(),
"pending_state must clear after `a"
);
assert_eq!(
app.active().editor.cursor(),
(1, 4),
"`a must jump to exact mark position"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn m_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 3);
drive_key(&mut app, key(KeyCode::Char('m')));
assert!(
app.pending_state.is_some(),
"m must enter SetMark pending state"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must cancel SetMark pending state"
);
assert_eq!(
app.active().editor.cursor(),
(0, 3),
"cursor must not move after m<Esc>"
);
}
#[test]
fn apostrophe_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 2);
drive_key(&mut app, key(KeyCode::Char('\'')));
assert!(
app.pending_state.is_some(),
"' must enter GotoMarkLine pending state"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must cancel GotoMarkLine pending state"
);
assert_eq!(
app.active().editor.cursor(),
(0, 2),
"cursor must not move after '<Esc>"
);
}
#[test]
fn backtick_in_visual_jumps_pos() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "first line\nsecond line\nthird line");
app.active_mut().editor.jump_cursor(2, 2);
drive_key(&mut app, key(KeyCode::Char('m')));
drive_key(&mut app, key(KeyCode::Char('b')));
app.active_mut().editor.jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('v')));
assert_eq!(app.active().editor.vim_mode(), hjkl_engine::VimMode::Visual);
app.route_chord_key(ck('`'));
app.route_chord_key(ck('b'));
assert!(
app.pending_state.is_none(),
"pending_state must clear after `b in Visual mode"
);
assert_eq!(
app.active().editor.cursor().0,
2,
"`b in Visual mode must jump to mark row"
);
}
fn macro_key_seq(app: &mut App, keys: &[KeyEvent]) {
for &k in keys {
if !app.route_chord_key(k) {
hjkl_vim::handle_key(&mut app.active_mut().editor, k);
}
app.sync_viewport_from_editor();
}
}
#[test]
fn q_then_esc_cancels_no_recording_started() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
drive_key(&mut app, ck('q'));
assert_eq!(
app.pending_state,
Some(hjkl_vim::PendingState::RecordMacroTarget),
"q must set RecordMacroTarget pending state"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc after q must clear pending_state"
);
assert!(
!app.active().editor.is_recording_macro(),
"Esc cancel must not start recording"
);
}
#[test]
fn bare_q_during_record_stops() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
drive_key(&mut app, ck('q'));
drive_key(&mut app, ck('a'));
assert!(
app.active().editor.is_recording_macro(),
"q a must start recording"
);
assert_eq!(app.active().editor.recording_register(), Some('a'));
drive_key(&mut app, ck('q'));
assert!(
!app.active().editor.is_recording_macro(),
"bare q must stop recording"
);
assert!(
app.pending_state.is_none(),
"pending_state must be clear after stop"
);
}
#[test]
fn record_macro_a_j_motion_replay_plays() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(
&mut app,
&[
ck('q'),
ck('a'), ck('j'), ck('q'), ],
);
assert!(
!app.active().editor.is_recording_macro(),
"recording must stop after second q"
);
assert_eq!(app.active().editor.cursor().0, 1);
macro_key_seq(&mut app, &[ck('@'), ck('a')]);
assert!(
!app.active().editor.is_replaying_macro(),
"replaying_macro must be false after replay finishes"
);
assert_eq!(
app.active().editor.cursor().0,
2,
"@a must replay j motion (move to row 2)"
);
}
#[test]
fn at_at_repeats_last_macro() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('q'), ck('a'), ck('j'), ck('q')]);
assert_eq!(app.active().editor.cursor().0, 1);
macro_key_seq(&mut app, &[ck('@'), ck('a')]);
assert_eq!(app.active().editor.cursor().0, 2);
macro_key_seq(&mut app, &[ck('@'), ck('@')]);
assert_eq!(
app.active().editor.cursor().0,
3,
"@@ must replay the last macro"
);
}
#[test]
fn play_macro_with_count_3() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4\nline5\nline6");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_mut().editor.start_macro_record('a');
app.active_mut().editor.record_input(hjkl_engine::Input {
key: hjkl_engine::Key::Char('j'),
..Default::default()
});
app.active_mut().editor.stop_macro_record();
let inputs = app.active_mut().editor.play_macro('a', 3);
app.active_mut().editor.end_macro_replay();
assert_eq!(
inputs.len(),
3,
"play_macro with count=3 must return 3 inputs"
);
for input in inputs {
let ct_key = engine_input_to_key_event(input);
if ct_key.code != KeyCode::Null {
let consumed = app.route_chord_key(ct_key);
if !consumed {
hjkl_vim::handle_key(&mut app.active_mut().editor, ct_key);
}
app.sync_viewport_from_editor();
}
}
assert_eq!(
app.active().editor.cursor().0,
3,
"3× j motions must move cursor to row 3"
);
}
#[test]
fn record_capital_appends_to_lowercase() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3");
app.active_mut().editor.jump_cursor(1, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('q'), ck('a'), ck('j'), ck('q')]);
assert_eq!(app.active().editor.cursor().0, 2);
macro_key_seq(&mut app, &[ck('q'), ck('A'), ck('k'), ck('q')]);
assert_eq!(app.active().editor.cursor().0, 1);
let start_row = app.active().editor.cursor().0; macro_key_seq(&mut app, &[ck('@'), ck('a')]);
assert_eq!(
app.active().editor.cursor().0,
start_row,
"@a with capital append must replay j+k (net zero from row {start_row})"
);
}
#[test]
fn at_colon_replays_last_ex() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.dispatch_ex("3");
assert_eq!(
app.active().editor.cursor().0,
2,
":3 must move cursor to row 2"
);
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
assert_eq!(app.active().editor.cursor().0, 0);
app.replay_last_ex();
assert_eq!(
app.active().editor.cursor().0,
2,
"replay_last_ex must re-run :3 and land on row 2"
);
}
#[test]
fn at_colon_via_play_macro_arm_replays() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.dispatch_ex("3");
assert_eq!(app.active().editor.cursor().0, 2);
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.dispatch_action(
crate::keymap_actions::AppAction::BeginPendingPlayMacro { count: 1 },
1,
);
assert_eq!(
app.pending_state,
Some(hjkl_vim::PendingState::PlayMacroTarget { count: 1 }),
"BeginPendingPlayMacro must set PlayMacroTarget pending state"
);
let consumed = app.route_chord_key(ck(':'));
assert!(consumed, "@: must be consumed by route_chord_key");
assert!(
app.pending_state.is_none(),
"pending_state must be cleared after @: commit"
);
assert_eq!(
app.active().editor.cursor().0,
2,
"@: chord must replay :3 and land on row 2"
);
}
#[test]
fn at_colon_with_count_3_replays_three_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_mut().editor.jump_cursor(2, 0);
app.sync_viewport_from_editor();
app.dispatch_ex("1");
assert_eq!(app.active().editor.cursor().0, 0, ":1 must go to row 0");
app.active_mut().editor.jump_cursor(4, 0);
app.sync_viewport_from_editor();
app.dispatch_action(
crate::keymap_actions::AppAction::BeginPendingPlayMacro { count: 3 },
1,
);
let consumed = app.route_chord_key(ck(':'));
assert!(consumed, "3@: must be consumed");
assert_eq!(
app.active().editor.cursor().0,
0,
"3@: of :1 must end with cursor at row 0"
);
assert_eq!(
app.last_ex_command.as_deref(),
Some("1"),
"last_ex_command must remain '1' after 3@:"
);
}
#[test]
fn at_colon_no_prior_ex_is_noop() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
assert!(
app.last_ex_command.is_none(),
"fresh app must have no last_ex_command"
);
app.replay_last_ex();
assert_eq!(
app.active().editor.cursor().0,
0,
"cursor must be unchanged"
);
assert!(
app.status_message.is_none(),
"no status message for no-op replay"
);
assert!(
!app.exit_requested,
"exit_requested must stay false on no-op"
);
}
#[test]
fn at_colon_within_macro_does_not_recurse() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.dispatch_ex("1");
assert_eq!(app.active().editor.cursor().0, 0, ":1 must go to row 0");
assert_eq!(app.last_ex_command.as_deref(), Some("1"));
app.active_mut().editor.jump_cursor(2, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('q'), ck('a')]);
assert!(
app.active().editor.is_recording_macro(),
"must be recording"
);
app.replay_last_ex();
assert_eq!(
app.active().editor.cursor().0,
0,
"replay_last_ex during recording must move cursor to row 0"
);
assert!(
app.active().editor.is_recording_macro(),
"replay_last_ex must not stop macro recording"
);
assert_eq!(
app.last_ex_command.as_deref(),
Some("1"),
"last_ex_command must remain '1' after replay_last_ex"
);
macro_key_seq(&mut app, &[ck('q')]);
assert!(!app.active().editor.is_recording_macro());
}
fn seed_numbered_lines(app: &mut App, count: usize) {
let content: String = (1..=count)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
seed_buffer(app, &content);
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
}
fn rck(app: &mut App, keys: &[char]) {
for &c in keys {
if !app.route_chord_key(ck(c)) {
hjkl_vim::handle_key(&mut app.active_mut().editor, ck(c));
}
app.sync_viewport_from_editor();
}
}
#[test]
fn count_before_op_5dd_deletes_5_lines() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 10);
app.pending_count.try_accumulate('5');
rck(&mut app, &['d', 'd']);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines.first().map(String::as_str),
Some("line6"),
"5dd must delete lines 1-5; first line must now be 'line6', got {lines:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"must be in Normal after 5dd"
);
}
#[test]
fn register_then_count_a5dd_targets_register_a() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 10);
rck(&mut app, &['"', 'a']);
assert_eq!(
app.active().editor.pending_register(),
Some('a'),
"pending_register must be 'a' after \"a"
);
app.pending_count.try_accumulate('5');
rck(&mut app, &['d', 'd']);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines.first().map(String::as_str),
Some("line6"),
"\"a5dd must delete 5 lines; first line must now be 'line6', got {lines:?}"
);
let reg_a = &app.active().editor.registers().named[0];
assert!(
reg_a.text.contains("line1"),
"register 'a' must contain deleted text; got {:?}",
reg_a.text
);
assert_eq!(
app.active().editor.pending_register(),
None,
"pending_register must be cleared after op"
);
}
#[test]
fn count_then_register_5_quote_a_dd_targets_register_a() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 10);
app.pending_count.try_accumulate('5');
rck(&mut app, &['"', 'a']);
assert_eq!(
app.active().editor.pending_register(),
Some('a'),
"pending_register must be 'a' after \"a"
);
assert_eq!(
app.pending_count.peek(),
5,
"pending_count must survive through register selection (5\"add regression)"
);
rck(&mut app, &['d', 'd']);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines.first().map(String::as_str),
Some("line6"),
"5\"add must delete 5 lines; first line must now be 'line6', got {lines:?}"
);
let reg_a = &app.active().editor.registers().named[0];
assert!(
reg_a.text.contains("line1"),
"register 'a' must contain deleted text; got {:?}",
reg_a.text
);
}
#[test]
fn outer_count_inner_count_2_quote_a_5dd_total_10() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 30);
app.pending_count.try_accumulate('2');
rck(&mut app, &['"', 'a']);
assert_eq!(
app.pending_count.peek(),
2,
"pending_count must be 2 after \"a"
);
app.pending_count.try_accumulate('5');
assert_eq!(
app.pending_count.peek(),
25,
"pending_count digits accumulate to 25"
);
rck(&mut app, &['d', 'd']);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines.first().map(String::as_str),
Some("line26"),
"2\"a5dd with digit-accumulation semantics must delete 25 lines; got {lines:?}"
);
let reg_a = &app.active().editor.registers().named[0];
assert!(
!reg_a.text.is_empty(),
"register 'a' must be non-empty after op"
);
}
#[test]
fn register_prefix_then_x_targets_register() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
rck(&mut app, &['"', 'a']);
assert_eq!(
app.active().editor.pending_register(),
Some('a'),
"pending_register must be 'a' after \"a"
);
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('x'));
app.sync_viewport_from_editor();
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines.first().map(String::as_str),
Some("ello world"),
"\"ax must delete 'h'; got {lines:?}"
);
let reg_a = &app.active().editor.registers().named[0];
assert_eq!(
reg_a.text, "h",
"register 'a' must contain 'h' after \"ax; got {:?}",
reg_a.text
);
}
#[test]
fn register_prefix_single_use_then_next_op_unnamed() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 5);
rck(&mut app, &['"', 'a', 'd', 'd']);
let reg_a_text = app.active().editor.registers().named[0].text.clone();
assert!(
reg_a_text.contains("line1"),
"first dd must land in reg 'a'; got {:?}",
reg_a_text
);
assert_eq!(
app.active().editor.pending_register(),
None,
"pending_register must be cleared after first op"
);
let unnamed_before = app.active().editor.registers().unnamed.text.clone();
rck(&mut app, &['d', 'd']);
let unnamed_after = app.active().editor.registers().unnamed.text.clone();
assert_ne!(
unnamed_after, unnamed_before,
"second dd must update unnamed register"
);
let reg_a_text2 = app.active().editor.registers().named[0].text.clone();
assert_eq!(
reg_a_text, reg_a_text2,
"register 'a' must not be overwritten by second dd; got {:?}",
reg_a_text2
);
}
#[test]
fn count_then_play_macro_3at_a_plays_three_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 10);
macro_key_seq(&mut app, &[ck('q'), ck('a')]);
assert!(
app.active().editor.is_recording_macro(),
"must be recording"
);
macro_key_seq(&mut app, &[ck('j')]);
macro_key_seq(&mut app, &[ck('q')]);
assert!(
!app.active().editor.is_recording_macro(),
"recording stopped"
);
let row_after_record = app.active().editor.cursor().0;
assert_eq!(row_after_record, 1, "recording 'j' moves cursor to row 1");
app.pending_count.try_accumulate('3');
rck(&mut app, &['@', 'a']);
let row_after_play = app.active().editor.cursor().0;
assert_eq!(
row_after_play, 4,
"3@a must play macro 3 times → cursor moves from row 1 to row 4; got {row_after_play}"
);
}
#[test]
fn count_then_dot_5_dot_repeats_five_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(&mut app.active_mut().editor, ck('x'));
app.sync_viewport_from_editor();
let lines_after_x = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines_after_x.first().map(String::as_str),
Some("ello world"),
"x must delete 'h'; got {lines_after_x:?}"
);
app.pending_count.try_accumulate('5');
let consumed = app.route_chord_key(ck('.'));
assert!(consumed, ". must be consumed by keymap");
app.sync_viewport_from_editor();
let lines_after_dot = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines_after_dot.first().map(String::as_str),
Some("world"),
"5. must repeat x 5 more times; got {lines_after_dot:?}"
);
}
#[test]
fn p64_i_enters_insert_mode() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('i'));
assert!(consumed, "i must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"i must enter Insert mode"
);
assert_eq!(
app.active().editor.host().cursor_shape(),
hjkl_engine::CursorShape::Bar,
"cursor must flip to Bar on entering Insert"
);
}
#[test]
fn p64_shift_i_enters_insert_at_line_start() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, " hello");
app.active_mut().editor.jump_cursor(0, 5);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('I'));
assert!(consumed, "I must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"I must enter Insert mode"
);
let (_, col) = app.active().editor.cursor();
assert_eq!(
col, 2,
"I must place cursor at first non-blank; got col {col}"
);
}
#[test]
fn p64_a_enters_insert_after_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('a'));
assert!(consumed, "a must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"a must enter Insert mode"
);
let (_, col) = app.active().editor.cursor();
assert_eq!(col, 1, "a must advance cursor to col 1; got {col}");
}
#[test]
fn p64_shift_a_enters_insert_at_eol() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('A'));
assert!(consumed, "A must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"A must enter Insert mode"
);
let (_, col) = app.active().editor.cursor();
assert_eq!(col, 5, "A must place cursor at EOL; got col {col}");
}
#[test]
fn p64_o_opens_line_below_and_enters_insert() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line1\nline2");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('o'));
assert!(consumed, "o must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"o must enter Insert mode"
);
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 1, "o must move cursor to new row 1; got row {row}");
}
#[test]
fn p64_shift_o_opens_line_above_and_enters_insert() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line1\nline2");
app.active_mut().editor.jump_cursor(1, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('O'));
assert!(consumed, "O must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"O must enter Insert mode"
);
let (row, _) = app.active().editor.cursor();
assert_eq!(
row, 1,
"O must place cursor on new row above; got row {row}"
);
}
#[test]
fn p64_x_deletes_char_forward() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('x'));
assert!(consumed, "x must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "ello", "x must delete 'h'; got {line:?}");
}
#[test]
fn p64_x_with_count_5_deletes_5_chars() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.pending_count.try_accumulate('5');
let consumed = app.route_chord_key(ck('x'));
assert!(consumed, "x must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, " world", "5x must delete 5 chars; got {line:?}");
}
#[test]
fn p64_big_x_deletes_char_backward() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 2);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('X'));
assert!(consumed, "X must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "hllo", "X at col 2 must delete 'e'; got {line:?}");
}
#[test]
fn p64_s_substitutes_char_enters_insert() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('s'));
assert!(consumed, "s must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"s must enter Insert mode"
);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "ello", "s must delete first char; got {line:?}");
}
#[test]
fn p64_big_s_substitutes_line_enters_insert() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\nline2");
app.active_mut().editor.jump_cursor(0, 3);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('S'));
assert!(consumed, "S must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"S must enter Insert mode"
);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "", "S must clear line contents; got {line:?}");
}
#[test]
fn p64_big_d_deletes_to_eol() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 5);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('D'));
assert!(consumed, "D must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(
line, "hello",
"D at col 5 must delete ' world'; got {line:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Normal,
"D must stay in Normal mode"
);
}
#[test]
fn p64_big_c_changes_to_eol() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 5);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('C'));
assert!(consumed, "C must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(
line, "hello",
"C at col 5 must delete ' world'; got {line:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"C must enter Insert mode"
);
}
#[test]
fn p64_big_y_yanks_to_eol() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 6);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('Y'));
assert!(consumed, "Y must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(
line, "hello world",
"Y must not modify buffer; got {line:?}"
);
let reg = app.active().editor.registers().unnamed.text.clone();
assert_eq!(
reg, "world",
"Y must yank 'world' to unnamed register; got {reg:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Normal,
"Y must stay in Normal mode"
);
}
#[test]
fn p64_big_j_joins_lines() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line1\nline2\nline3");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('J'));
assert!(consumed, "J must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert!(
line.contains("line1") && line.contains("line2"),
"J must join line1 and line2; got {line:?}"
);
}
#[test]
fn p64_big_j_with_count_10_joins_10_lines() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 15);
app.pending_count.try_accumulate('1');
app.pending_count.try_accumulate('0');
let consumed = app.route_chord_key(ck('J'));
assert!(consumed, "J must be consumed by keymap");
let lines = app.active().editor.buffer().lines().to_vec();
let first = lines.first().map(String::as_str).unwrap_or("");
assert!(
first.contains("line1") && (first.contains("line10") || first.contains("line11")),
"10J must join at least 10 lines into first line; got first: {first:?}"
);
let has_line12 = lines.iter().any(|l| l == "line12");
assert!(
has_line12,
"10J must leave 'line12' in buffer; got {lines:?}"
);
}
#[test]
fn p64_tilde_toggles_case() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('~'));
assert!(consumed, "~ must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert!(
line.starts_with('H'),
"~ must toggle 'h' to 'H'; got {line:?}"
);
}
#[test]
fn p64_p_pastes_after_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_mut().editor.delete_char_forward(1);
app.sync_after_engine_mutation();
let consumed = app.route_chord_key(ck('p'));
assert!(consumed, "p must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "ehllo", "p must paste 'h' after 'e'; got {line:?}");
}
#[test]
fn p64_big_p_pastes_before_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 2);
app.sync_viewport_from_editor();
app.active_mut().editor.delete_char_forward(1);
app.sync_after_engine_mutation();
let consumed = app.route_chord_key(ck('P'));
assert!(consumed, "P must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(
line, "hello",
"P must paste 'l' before cursor; got {line:?}"
);
}
#[test]
fn p64_p_with_count_3_pastes_three_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_mut().editor.delete_char_forward(1);
app.sync_after_engine_mutation();
app.pending_count.try_accumulate('3');
let consumed = app.route_chord_key(ck('p'));
assert!(consumed, "p must be consumed by keymap");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "baaac", "3p must paste 'a' 3 times; got {line:?}");
}
#[test]
fn p64_u_undoes_last_change() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_mut().editor.delete_char_forward(1);
app.sync_after_engine_mutation();
let line_after_del = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line_after_del, "ello");
let consumed = app.route_chord_key(ck('u'));
assert!(consumed, "u must be consumed by keymap");
let line_after_undo = app.active().editor.buffer().lines()[0].clone();
assert_eq!(
line_after_undo, "hello",
"u must undo the delete; got {line_after_undo:?}"
);
}
#[test]
fn p64_ctrl_r_redoes_after_undo() {
use crossterm::event::KeyModifiers;
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_mut().editor.delete_char_forward(1);
app.sync_after_engine_mutation();
app.active_mut().editor.undo();
app.sync_after_engine_mutation();
let line_after_undo = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line_after_undo, "hello");
let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
let consumed = app.route_chord_key(ctrl_r);
assert!(consumed, "<C-r> must be consumed by keymap");
let line_after_redo = app.active().editor.buffer().lines()[0].clone();
assert_eq!(
line_after_redo, "ello",
"<C-r> must redo the delete; got {line_after_redo:?}"
);
}
#[test]
fn p64_v_enters_visual_char_mode() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('v'));
assert!(consumed, "v must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Visual,
"v must enter Visual mode"
);
}
#[test]
fn p64_big_v_enters_visual_line_mode() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello\nworld");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('V'));
assert!(consumed, "V must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::VisualLine,
"V must enter VisualLine mode"
);
}
#[test]
fn p64_ctrl_v_enters_visual_block_mode() {
use crossterm::event::KeyModifiers;
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello\nworld");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
let consumed = app.route_chord_key(ctrl_v);
assert!(consumed, "<C-v> must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::VisualBlock,
"<C-v> must enter VisualBlock mode"
);
}
#[test]
fn p64_visual_o_toggles_anchor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_mut().editor.enter_visual_char();
app.sync_viewport_from_editor();
for _ in 0..4 {
app.route_chord_key(ck('l'));
}
let cursor_before = app.active().editor.cursor();
let consumed = app.route_chord_key(ck('o'));
assert!(
consumed,
"o in Visual must be consumed by keymap (VisualToggleAnchor)"
);
let cursor_after = app.active().editor.cursor();
assert_ne!(cursor_before, cursor_after, "o must swap cursor and anchor");
}
#[test]
fn p64_normal_o_opens_line_below_not_visual_toggle() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
assert_eq!(app.active().editor.vim_mode(), VimMode::Normal);
let consumed = app.route_chord_key(ck('o'));
assert!(consumed, "o in Normal must be consumed by keymap");
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"o in Normal must enter Insert (open line below), not toggle visual anchor"
);
}
#[test]
fn p64_n_repeats_search_forward() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foo bar foo baz foo");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.open_search_prompt(crate::app::SearchDir::Forward);
for c in ['f', 'o', 'o'] {
app.handle_search_field_key(KeyEvent::new(
KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
));
}
app.handle_search_field_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
let (_, col_after_first) = app.active().editor.cursor();
let consumed = app.route_chord_key(ck('n'));
assert!(consumed, "n must be consumed by keymap");
let (_, col_after_n) = app.active().editor.cursor();
assert!(
col_after_n > col_after_first || col_after_n == 0,
"n must advance cursor to next match; before col {col_after_first}, after col {col_after_n}"
);
}
#[test]
fn p64_star_searches_word_under_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world hello");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(ck('*'));
assert!(consumed, "* must be consumed by keymap");
app.sync_viewport_from_editor();
let (_, col) = app.active().editor.cursor();
assert_eq!(
col, 12,
"* must land on second 'hello' at col 12; got col {col}"
);
}
#[test]
fn p64_ctrl_e_is_consumed_by_keymap() {
use crossterm::event::KeyModifiers;
let mut app = App::new(None, false, None, None).unwrap();
let content: String = (1..=50)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
seed_buffer(&mut app, &content);
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let ctrl_e = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL);
let consumed = app.route_chord_key(ctrl_e);
assert!(
consumed,
"<C-e> must be consumed by keymap (ScrollLine Down)"
);
assert_eq!(app.active().editor.vim_mode(), VimMode::Normal);
}
#[test]
fn p64_ctrl_y_is_consumed_by_keymap() {
use crossterm::event::KeyModifiers;
let mut app = App::new(None, false, None, None).unwrap();
let content: String = (1..=50)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
seed_buffer(&mut app, &content);
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let ctrl_y = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL);
let consumed = app.route_chord_key(ctrl_y);
assert!(consumed, "<C-y> must be consumed by keymap (ScrollLine Up)");
assert_eq!(app.active().editor.vim_mode(), VimMode::Normal);
}
#[test]
fn p64_gv_reenters_last_visual_selection() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_mut().editor.enter_visual_char();
for _ in 0..4 {
app.route_chord_key(ck('l'));
}
app.active_mut().editor.exit_visual_to_normal();
app.sync_viewport_from_editor();
assert_eq!(app.active().editor.vim_mode(), VimMode::Normal);
rck(&mut app, &['g', 'v']);
let mode = app.active().editor.vim_mode();
assert!(
matches!(
mode,
VimMode::Visual | VimMode::VisualLine | VimMode::VisualBlock
),
"gv must reenter visual mode; got {mode:?}"
);
}
#[test]
fn p64_ctrl_o_is_consumed_by_keymap() {
use crossterm::event::KeyModifiers;
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 20);
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let ctrl_o = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL);
let consumed = app.route_chord_key(ctrl_o);
assert!(consumed, "<C-o> must be consumed by keymap (JumpBack)");
assert_eq!(app.active().editor.vim_mode(), VimMode::Normal);
}
#[test]
fn p64_ctrl_o_jumps_back_with_recorded_jump() {
use crossterm::event::KeyModifiers;
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 20);
app.active_mut().editor.jump_cursor(10, 0);
app.sync_viewport_from_editor();
app.active_mut().editor.record_jump((10, 0));
app.active_mut().editor.jump_cursor(15, 0);
app.sync_viewport_from_editor();
let ctrl_o = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL);
let consumed = app.route_chord_key(ctrl_o);
assert!(consumed, "<C-o> must be consumed by keymap");
app.sync_viewport_from_editor();
let (row_after, _) = app.active().editor.cursor();
assert_eq!(
row_after, 10,
"<C-o> must jump back to row 10; got row {row_after}"
);
}
#[test]
fn p64_macro_qa_insert_hello_esc_q_at_a_roundtrip() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('q'), ck('a')]);
assert!(
app.active().editor.is_recording_macro(),
"must be recording after qa"
);
macro_key_seq(&mut app, &[ck('i')]);
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"i must enter Insert"
);
for c in ['H', 'e', 'l', 'l', 'o'] {
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char(c), crossterm::event::KeyModifiers::NONE),
);
}
app.sync_after_engine_mutation();
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE),
);
app.sync_after_engine_mutation();
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Normal,
"Esc must return to Normal"
);
macro_key_seq(&mut app, &[ck('q')]);
assert!(
!app.active().editor.is_recording_macro(),
"must stop recording after q"
);
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
rck(&mut app, &['@', 'a']);
let line = app.active().editor.buffer().lines()[0].clone();
assert!(
line.starts_with("Hello"),
"@a replay must prepend 'Hello'; got {line:?}"
);
}
#[test]
fn p64_count_3p_pastes_three_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_mut().editor.delete_char_forward(1);
app.sync_after_engine_mutation();
app.pending_count.try_accumulate('3');
let consumed = app.route_chord_key(ck('p'));
assert!(consumed, "p must be consumed");
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "baaac", "3p must paste 'a' 3 times; got {line:?}");
}
#[test]
fn p64_count_2dd_still_works_after_64_additions() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 10);
app.pending_count.try_accumulate('2');
rck(&mut app, &['d', 'd']);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines.first().map(String::as_str),
Some("line3"),
"2dd must delete 2 lines; first line must be 'line3'; got {lines:?}"
);
}
fn enter_insert(app: &mut App) {
app.active_mut().editor.enter_insert_i(1);
app.sync_after_engine_mutation();
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"enter_insert: must be in Insert mode"
);
}
fn dik(app: &mut App, key: KeyEvent) {
app.dispatch_insert_key(key);
app.sync_after_engine_mutation();
}
#[test]
fn p65_insert_char_types_literal() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "");
enter_insert(&mut app);
for c in ['H', 'e', 'l', 'l', 'o'] {
dik(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
);
}
assert_eq!(
app.active().editor.buffer().lines()[0],
"Hello",
"insert_char must type 'Hello'"
);
assert_eq!(app.active().editor.vim_mode(), VimMode::Insert);
}
#[test]
fn p65_esc_exits_insert_mode() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 2);
enter_insert(&mut app);
dik(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Normal,
"Esc must return to Normal"
);
assert_eq!(
app.active().editor.host().cursor_shape(),
hjkl_engine::CursorShape::Block,
"cursor must flip back to Block on Esc"
);
}
#[test]
fn p65_backspace_deletes_previous_char() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 5);
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "hell", "Backspace must delete 'o'; got {line:?}");
}
#[test]
fn p65_backspace_at_col0_joins_lines() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello\nworld");
app.active_mut().editor.jump_cursor(1, 0);
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines.len(),
1,
"Backspace at col 0 must join lines; got {lines:?}"
);
assert_eq!(lines[0], "helloworld");
}
#[test]
fn p65_enter_inserts_newline() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 2);
enter_insert(&mut app);
dik(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let lines = app.active().editor.buffer().lines().to_vec();
assert_eq!(lines.len(), 2, "Enter must split line; got {lines:?}");
assert_eq!(lines[0], "he");
assert_eq!(lines[1], "llo");
}
#[test]
fn p65_delete_removes_char_under_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 1);
enter_insert(&mut app);
dik(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "hllo", "Delete must remove 'e'; got {line:?}");
}
#[test]
fn p65_arrow_left_moves_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 3);
enter_insert(&mut app);
let (_, col_before) = app.active().editor.cursor();
dik(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
let (_, col_after) = app.active().editor.cursor();
assert!(
col_after < col_before,
"Left arrow must move cursor left; before {col_before}, after {col_after}"
);
}
#[test]
fn p65_arrow_right_moves_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 1);
enter_insert(&mut app);
let (_, col_before) = app.active().editor.cursor();
dik(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
let (_, col_after) = app.active().editor.cursor();
assert!(
col_after > col_before,
"Right arrow must move cursor right; before {col_before}, after {col_after}"
);
}
#[test]
fn p65_arrow_down_moves_cursor_row() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello\nworld");
app.active_mut().editor.jump_cursor(0, 0);
enter_insert(&mut app);
let (row_before, _) = app.active().editor.cursor();
dik(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let (row_after, _) = app.active().editor.cursor();
assert!(
row_after > row_before,
"Down arrow must move cursor down; before row {row_before}, after {row_after}"
);
}
#[test]
fn p65_arrow_up_moves_cursor_row() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello\nworld");
app.active_mut().editor.jump_cursor(1, 0);
enter_insert(&mut app);
let (row_before, _) = app.active().editor.cursor();
dik(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let (row_after, _) = app.active().editor.cursor();
assert!(
row_after < row_before,
"Up arrow must move cursor up; before row {row_before}, after {row_after}"
);
}
#[test]
fn p65_home_moves_to_line_start() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 4);
enter_insert(&mut app);
dik(&mut app, KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
let (_, col) = app.active().editor.cursor();
assert_eq!(col, 0, "Home must move cursor to col 0; got col {col}");
}
#[test]
fn p65_end_moves_to_line_end() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
enter_insert(&mut app);
dik(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
let (_, col) = app.active().editor.cursor();
assert_eq!(
col, 4,
"End must move cursor to last char col 4; got col {col}"
);
}
#[test]
fn p65_pageup_does_not_crash() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 30);
app.active_mut().editor.jump_cursor(15, 0);
enter_insert(&mut app);
dik(&mut app, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
assert_eq!(app.active().editor.vim_mode(), VimMode::Insert);
}
#[test]
fn p65_pagedown_does_not_crash() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 30);
app.active_mut().editor.jump_cursor(0, 0);
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
);
assert_eq!(app.active().editor.vim_mode(), VimMode::Insert);
}
#[test]
fn p65_ctrl_w_deletes_word_backwards() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 11);
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "hello ", "Ctrl-W must delete 'world'; got {line:?}");
}
#[test]
fn p65_ctrl_u_deletes_to_line_start() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 11);
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "", "Ctrl-U must delete to line start; got {line:?}");
}
#[test]
fn p65_ctrl_h_is_alias_for_backspace() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 5);
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL),
);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "hell", "Ctrl-H must delete 'o'; got {line:?}");
}
#[test]
fn p65_ctrl_t_indents_line() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
);
let line = app.active().editor.buffer().lines()[0].clone();
assert!(
line.starts_with(' ') || line.starts_with('\t'),
"Ctrl-T must indent line; got {line:?}"
);
}
#[test]
fn p65_ctrl_d_outdents_indented_line() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, " hello");
app.active_mut().editor.jump_cursor(0, 4);
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
);
let line = app.active().editor.buffer().lines()[0].clone();
let leading = line.chars().take_while(|c| *c == ' ').count();
assert!(
leading < 4,
"Ctrl-D must outdent; before 4 spaces, after {leading} spaces; line {line:?}"
);
}
#[test]
fn p65_ctrl_o_one_shot_normal_round_trip() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "");
enter_insert(&mut app);
for c in ['h', 'e', 'l', 'l', 'o', ' '] {
dik(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
);
}
let line_before = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line_before, "hello ", "setup: line must be 'hello '");
dik(
&mut app,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL),
);
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Normal,
"<C-o> must flip to Normal for one-shot"
);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
);
app.sync_after_engine_mutation();
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"after one-shot Normal command, must return to Insert"
);
for c in [' ', 'w', 'o', 'r', 'l', 'd'] {
dik(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
);
}
dik(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.active().editor.vim_mode(), VimMode::Normal);
let line = app.active().editor.buffer().lines()[0].clone();
assert!(
line.contains("hello") && line.contains("world"),
"<C-o>w round-trip: line must contain 'hello' and 'world'; got {line:?}"
);
}
#[test]
fn p65_ctrl_r_register_paste() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello\n");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE),
);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
);
app.sync_after_engine_mutation();
app.active_mut().editor.jump_cursor(1, 0);
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
);
assert!(
app.active().editor.is_insert_register_pending(),
"<C-r> must arm register selector"
);
dik(
&mut app,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
assert!(
!app.active().editor.is_insert_register_pending(),
"register selector must clear after char"
);
let line = app.active().editor.buffer().lines()[1].clone();
assert!(
line.contains("hello"),
"<C-r>a must paste 'hello'; got {line:?}"
);
}
#[test]
fn p65_unrecognised_key_silently_dropped() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
app.active_mut().editor.jump_cursor(0, 0);
enter_insert(&mut app);
dik(&mut app, KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"F5 must be silently dropped; mode must remain Insert"
);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "hello", "buffer must be unchanged after F5");
}
#[test]
fn p65_shift_char_types_uppercase() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "");
enter_insert(&mut app);
dik(
&mut app,
KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT),
);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(line, "A", "SHIFT+Char('A') must type 'A'; got {line:?}");
}
#[test]
fn p65_i_hello_esc_types_literal() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "");
enter_insert(&mut app);
for c in ['H', 'e', 'l', 'l', 'o'] {
dik(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
);
}
dik(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.active().editor.vim_mode(), VimMode::Normal);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(
line, "Hello",
"iHello<Esc> must leave 'Hello' in buffer; got {line:?}"
);
}
#[test]
fn p65_replace_mode_overstrike() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let consumed = app.route_chord_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE));
assert!(
consumed,
"R must be consumed by keymap (EnterInsertReplace)"
);
assert_eq!(
app.active().editor.vim_mode(),
VimMode::Insert,
"R must enter Insert (Replace session)"
);
dik(
&mut app,
KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE),
);
let line = app.active().editor.buffer().lines()[0].clone();
assert_eq!(
line, "Xello world",
"Replace-mode overstrike must replace 'h' with 'X'; got {line:?}"
);
}
#[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().lines().to_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().lines().to_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.status_message
.as_deref()
.map(|m| !m.starts_with('E'))
.unwrap_or(true),
"`:e %%` must not produce an error; got: {:?}",
app.status_message
);
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"
);
}
fn setup_three_slot_app() -> App {
let path_a = std::env::temp_dir().join("hjkl_4c_a.txt");
let path_b = std::env::temp_dir().join("hjkl_4c_b.txt");
let path_c = std::env::temp_dir().join("hjkl_4c_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), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.dispatch_ex(&format!("e {}", path_c.display()));
app
}
#[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.status_message.clone().unwrap_or_default();
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
.status_message
.clone()
.or_else(|| app.info_popup.clone())
.unwrap_or_default();
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
.status_message
.clone()
.or_else(|| app.info_popup.clone())
.unwrap_or_default();
assert!(!msg.is_empty(), ":clipboard must produce output");
}
#[test]
fn colon_perf_toggles_overlay_on() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(!app.perf_overlay, "perf_overlay must start off");
app.dispatch_ex("perf");
assert!(app.perf_overlay, ":perf must enable perf_overlay");
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("on"), ":perf status must say 'on'");
}
#[test]
fn colon_perf_toggles_overlay_off() {
let mut app = App::new(None, false, None, None).unwrap();
app.perf_overlay = true;
app.dispatch_ex("perf");
assert!(!app.perf_overlay, ":perf must disable perf_overlay");
let msg = app.status_message.clone().unwrap_or_default();
assert!(msg.contains("off"), ":perf status must say 'off'");
}
#[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 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.status_message.clone().unwrap_or_default();
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.contains("Tab page 1"), "popup must list Tab page 1");
assert!(popup.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.status_message.clone().unwrap_or_default();
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}"
);
}
fn tab_key() -> KeyEvent {
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)
}
fn shift_tab_key() -> KeyEvent {
KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)
}
#[test]
fn colon_tab_single_match_inserts_fully() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "writ");
app.handle_command_field_key(tab_key());
assert_eq!(
app.command_field.as_ref().unwrap().text(),
"write",
"single match must insert fully"
);
assert!(
app.command_completion.is_none(),
"menu must close after single match"
);
}
#[test]
fn colon_tab_multi_match_extends_to_lcp() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
app.handle_command_field_key(tab_key());
assert!(
app.command_completion.is_some(),
"wildmenu must be open after Tab on multi-match"
);
let text = app.command_field.as_ref().unwrap().text();
assert!(
text.starts_with('w'),
"field must start with typed prefix: {text:?}"
);
}
#[test]
fn colon_tab_then_tab_cycles() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
app.handle_command_field_key(tab_key()); assert!(app.command_completion.is_some());
assert!(app.command_completion.as_ref().unwrap().selected.is_none());
app.handle_command_field_key(tab_key()); let idx0 = app.command_completion.as_ref().unwrap().selected;
assert_eq!(idx0, Some(0));
app.handle_command_field_key(tab_key()); let idx1 = app.command_completion.as_ref().unwrap().selected;
assert_eq!(idx1, Some(1));
}
#[test]
fn colon_shift_tab_cycles_backward() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
app.handle_command_field_key(tab_key()); app.handle_command_field_key(tab_key()); app.handle_command_field_key(tab_key()); let before = app.command_completion.as_ref().unwrap().selected.unwrap();
app.handle_command_field_key(shift_tab_key()); let after = app.command_completion.as_ref().unwrap().selected.unwrap();
assert_eq!(after, before - 1, "S-Tab must decrement selection");
}
#[test]
fn colon_esc_during_completion_reverts() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
app.handle_command_field_key(tab_key()); app.handle_command_field_key(tab_key()); let candidate_text = app.command_field.as_ref().unwrap().text();
app.handle_command_field_key(key(KeyCode::Esc));
assert!(
app.command_completion.is_none(),
"completion must be cleared after Esc"
);
assert!(
app.command_field.is_some(),
"command field must stay open after completion Esc"
);
assert_eq!(
app.command_field.as_ref().unwrap().text(),
"w",
"field must revert to original: was {candidate_text:?}"
);
}
#[test]
fn colon_other_key_during_completion_commits() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
app.handle_command_field_key(tab_key()); app.handle_command_field_key(tab_key()); let candidate_text = app.command_field.as_ref().unwrap().text();
app.handle_command_field_key(key(KeyCode::Char(' ')));
assert!(
app.command_completion.is_none(),
"completion must be cleared after non-Tab/non-Esc key"
);
let final_text = app.command_field.as_ref().unwrap().text();
assert!(
final_text.starts_with(&candidate_text),
"field must start with committed candidate: candidate={candidate_text:?} final={final_text:?}"
);
}