use super::*;
#[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_content = app
.info_popup
.as_ref()
.map(|p| p.content.as_str())
.unwrap_or("");
assert!(
popup_content.contains('a'),
"should list `a` Normal mapping"
);
assert!(
!popup_content.contains("<leader>f"),
"must not list built-in <leader>f"
);
assert!(
!popup_content.contains('c'),
"imap c must not appear in nmap list"
);
app.dispatch_ex("imap");
let popup = app
.info_popup
.as_ref()
.map(|p| p.content.as_str())
.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.bus.last_body_or_empty().to_string();
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.bus.last_body_or_empty().to_string();
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_ref()
.map(|p| p.content.as_str())
.unwrap_or("");
assert!(popup.contains('p'), "popup should list the `p` binding");
}
#[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 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"
);
}
#[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()
);
}
#[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");
}
#[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 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);
}
#[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:?}"
);
}
#[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 e_then_jjj_preserves_column_across_empty_line() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\n\nanother line");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_rules! dispatch_with_sync {
($app:expr, $key:expr) => {{
$app.sync_viewport_to_editor();
hjkl_vim::handle_key(&mut $app.active_mut().editor, $key);
$app.sync_viewport_from_editor();
}};
}
dispatch_with_sync!(app, key(KeyCode::Char('e')));
let (row, col) = app.active().editor.cursor();
assert_eq!(
(row, col),
(0, 4),
"after e: expected (0, 4), got ({row}, {col})"
);
dispatch_with_sync!(app, key(KeyCode::Char('j')));
let (row, col) = app.active().editor.cursor();
assert_eq!(row, 1, "after first j: expected row 1, got {row}");
assert_eq!(
col, 0,
"after first j: expected col 0 on empty line, got {col}"
);
dispatch_with_sync!(app, key(KeyCode::Char('j')));
let (row, col) = app.active().editor.cursor();
assert_eq!(row, 2, "after second j: expected row 2, got {row}");
assert_eq!(
col, 4,
"sticky_col must persist across viewport_sync — \
viewport_sync.rs jump_cursor used to clobber it; got col {col}"
);
}
#[test]
fn j_through_empty_line_preserves_column_app_level() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\n\nanother line");
app.active_mut().editor.jump_cursor(0, 4);
app.sync_viewport_from_editor();
macro_rules! dk {
($app:expr, $key:expr) => {{
hjkl_vim::handle_key(&mut $app.active_mut().editor, $key);
$app.sync_viewport_from_editor();
}};
}
dk!(app, key(KeyCode::Char('j'))); let (row, _) = app.active().editor.cursor();
assert_eq!(row, 1);
dk!(app, key(KeyCode::Char('j'))); let (row, col) = app.active().editor.cursor();
assert_eq!(row, 2, "expected row 2 after second j");
assert_eq!(
col, 4,
"sticky_col must survive without per-keypress sync_to; got col {col}"
);
}
#[test]
fn j_through_short_line_preserves_column_app_level() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\nhi\nhello world again");
app.active_mut().editor.jump_cursor(0, 7);
app.sync_viewport_from_editor();
macro_rules! dk {
($app:expr, $key:expr) => {{
hjkl_vim::handle_key(&mut $app.active_mut().editor, $key);
$app.sync_viewport_from_editor();
}};
}
dk!(app, key(KeyCode::Char('j'))); let (_, col1) = app.active().editor.cursor();
assert!(col1 < 7, "clamped to short line");
dk!(app, key(KeyCode::Char('j'))); let (row, col) = app.active().editor.cursor();
assert_eq!(row, 2);
assert_eq!(
col, 7,
"sticky_col must restore after short-line clamp; got {col}"
);
}
#[test]
fn gj_through_empty_line_preserves_column_app_level() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\n\nanother line");
app.active_mut().editor.jump_cursor(0, 4);
app.sync_viewport_from_editor();
macro_rules! dk {
($app:expr, $key:expr) => {{
hjkl_vim::handle_key(&mut $app.active_mut().editor, $key);
$app.sync_viewport_from_editor();
}};
}
dk!(app, key(KeyCode::Char('g')));
dk!(app, key(KeyCode::Char('j'))); let (row, _) = app.active().editor.cursor();
assert_eq!(row, 1, "gj should move to row 1");
dk!(app, key(KeyCode::Char('g')));
dk!(app, key(KeyCode::Char('j'))); let (row, col) = app.active().editor.cursor();
assert_eq!(row, 2, "gj should move to row 2");
assert_eq!(
col, 4,
"sticky_col must survive gj through empty line; got {col}"
);
}
#[test]
fn gk_through_empty_line_preserves_column_app_level() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\n\nanother line");
app.active_mut().editor.jump_cursor(2, 4);
app.sync_viewport_from_editor();
macro_rules! dk {
($app:expr, $key:expr) => {{
hjkl_vim::handle_key(&mut $app.active_mut().editor, $key);
$app.sync_viewport_from_editor();
}};
}
dk!(app, key(KeyCode::Char('g')));
dk!(app, key(KeyCode::Char('k'))); let (row, _) = app.active().editor.cursor();
assert_eq!(row, 1, "gk should reach row 1");
dk!(app, key(KeyCode::Char('g')));
dk!(app, key(KeyCode::Char('k'))); let (row, col) = app.active().editor.cursor();
assert_eq!(row, 0, "gk should reach row 0");
assert_eq!(
col, 4,
"sticky_col must survive gk through empty line; got {col}"
);
}
#[test]
fn search_n_preserves_column_through_event_loop() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\nfind me\nhello world");
app.active_mut().editor.jump_cursor(0, 6);
app.sync_viewport_from_editor();
macro_rules! dk {
($app:expr, $key:expr) => {{
hjkl_vim::handle_key(&mut $app.active_mut().editor, $key);
$app.sync_viewport_from_editor();
}};
}
dk!(app, key(KeyCode::Char('j'))); let (row, col) = app.active().editor.cursor();
assert_eq!(row, 1, "j should reach row 1");
assert_eq!(
col, 6,
"sticky_col=6 should land at col 6 on 'find me'; got {col}"
);
dk!(app, key(KeyCode::Char('j'))); let (row, col) = app.active().editor.cursor();
assert_eq!(row, 2, "second j should reach row 2");
assert_eq!(col, 6, "sticky_col must survive across rows; got {col}");
}
#[test]
fn mark_jump_resets_sticky_through_event_loop() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\nanother line here\nshort");
app.active_mut().editor.jump_cursor(0, 9);
app.sync_viewport_from_editor();
macro_rules! dk {
($app:expr, $key:expr) => {{
hjkl_vim::handle_key(&mut $app.active_mut().editor, $key);
$app.sync_viewport_from_editor();
}};
}
dk!(app, key(KeyCode::Char('m')));
dk!(app, key(KeyCode::Char('a')));
app.active_mut().editor.jump_cursor(2, 0);
app.sync_viewport_from_editor();
dk!(app, key(KeyCode::Char('\'')));
dk!(app, key(KeyCode::Char('a')));
let (row, col) = app.active().editor.cursor();
assert_eq!(row, 0, "mark jump should land at row 0");
let _ = col;
dk!(app, key(KeyCode::Char('j')));
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 1, "j after mark jump should reach row 1");
}
#[test]
fn split_then_independent_cursors() {
use crate::app::window::{LayoutRect, LayoutTree, SplitDir, Tab, Window};
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line one\nline two\nline three");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let win1 = app.next_window_id;
app.next_window_id += 1;
app.windows.push(Some(Window::with_scroll(0, 0, 0, 0, 0)));
let split_rect = LayoutRect::new(0, 0, 80, 24);
app.tabs[0] = Tab::new(
LayoutTree::Split {
dir: SplitDir::Horizontal,
ratio: 0.5,
a: Box::new(LayoutTree::Leaf(0)),
b: Box::new(LayoutTree::Leaf(win1)),
last_rect: Some(split_rect),
},
0,
);
if let Some(Some(w)) = app.windows.get_mut(0) {
w.last_rect = Some(LayoutRect::new(0, 0, 80, 12));
}
if let Some(Some(w)) = app.windows.get_mut(win1) {
w.last_rect = Some(LayoutRect::new(0, 12, 80, 12));
}
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('j')));
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('j')));
app.sync_viewport_from_editor();
let (row0, _) = app.active().editor.cursor();
assert_eq!(row0, 2, "win0 cursor should be at row 2");
assert_eq!(win_cursor_row(&app), 2, "win0 snapshot should match");
app.switch_focus(win1);
assert_eq!(
win_cursor_row(&app),
0,
"win1 snapshot row should be 0 after focus switch"
);
}
#[test]
fn split_then_edit_in_one_window_snapshot_stable_in_other() {
use crate::app::window::{LayoutRect, LayoutTree, SplitDir, Tab, Window};
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line one\nline two\nline three");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let win1 = app.next_window_id;
app.next_window_id += 1;
app.windows.push(Some(Window::with_scroll(0, 0, 0, 1, 0)));
let split_rect = LayoutRect::new(0, 0, 80, 24);
app.tabs[0] = Tab::new(
LayoutTree::Split {
dir: SplitDir::Horizontal,
ratio: 0.5,
a: Box::new(LayoutTree::Leaf(0)),
b: Box::new(LayoutTree::Leaf(win1)),
last_rect: Some(split_rect),
},
0,
);
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')));
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE),
);
app.sync_viewport_from_editor();
let win1_snap_row = app.windows[win1].as_ref().unwrap().cursor_row;
assert_eq!(
win1_snap_row, 1,
"win1 cursor snapshot must not be changed by edits in win0; got row {win1_snap_row}"
);
}
#[test]
fn close_window_keeps_buffer_for_other_window() {
use crate::app::window::{LayoutRect, LayoutTree, SplitDir, Tab, Window};
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line one\nline two\nline three");
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
let win1 = app.next_window_id;
app.next_window_id += 1;
app.windows.push(Some(Window::with_scroll(0, 0, 0, 2, 0)));
let split_rect = LayoutRect::new(0, 0, 80, 24);
app.tabs[0] = Tab::new(
LayoutTree::Split {
dir: SplitDir::Horizontal,
ratio: 0.5,
a: Box::new(LayoutTree::Leaf(0)),
b: Box::new(LayoutTree::Leaf(win1)),
last_rect: Some(split_rect),
},
0,
);
if let Some(Some(w)) = app.windows.get_mut(0) {
w.last_rect = Some(LayoutRect::new(0, 0, 80, 12));
}
if let Some(Some(w)) = app.windows.get_mut(win1) {
w.last_rect = Some(LayoutRect::new(0, 12, 80, 12));
}
app.switch_focus(win1);
assert_eq!(app.focused_window(), win1);
app.close_focused_window();
assert_eq!(
app.focused_window(),
0,
"win0 should regain focus after win1 closed"
);
let (row, _) = app.active().editor.cursor();
assert_eq!(
row, 0,
"win0's cursor (row 0) should be restored into editor after win1 closed; got {row}"
);
}