use super::*;
#[test]
fn quote_a_then_dd_deletes_into_register_a() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\nline two");
assert_eq!(app.active_editor().cursor().0, 0);
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('a')));
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('d')));
let text = app
.active_editor()
.with_registers(|r| r.read('a').map(|s| s.text.clone()));
assert!(text.is_some(), "register 'a' should be set after \"add");
let text = text.unwrap();
assert!(
text.contains("hello world"),
"register 'a' should contain 'hello world', got {text:?}"
);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(lines, vec!["line two"], "\"add must delete first line");
}
#[test]
fn quote_a_then_yy_then_quote_a_then_p_pastes_named_register() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "first line\nsecond line");
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('a')));
drive_key(&mut app, key(KeyCode::Char('y')));
drive_key(&mut app, key(KeyCode::Char('y')));
let text = app
.active_editor()
.with_registers(|r| r.read('a').map(|s| s.text.clone()));
assert!(text.is_some(), "register 'a' must be set after \"ayy");
let text = text.unwrap();
assert!(
text.contains("first line"),
"register 'a' should contain 'first line', got {text:?}"
);
drive_key(&mut app, key(KeyCode::Char('j')));
assert_eq!(
app.active_editor().cursor().0,
1,
"cursor must be on line 1"
);
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('a')));
drive_key(&mut app, key(KeyCode::Char('p')));
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert!(lines.len() >= 3, "paste must add a line, got {lines:?}");
assert!(
lines.iter().any(|l| l.contains("first line")),
"pasted content must contain 'first line', got {lines:?}"
);
}
#[test]
fn quote_underscore_then_dd_blackhole_no_unnamed_change() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "keep me\ndelete me\nkeep too");
drive_key(&mut app, key(KeyCode::Char('y')));
drive_key(&mut app, key(KeyCode::Char('y')));
let baseline = app
.active_editor()
.with_registers(|r| r.read('"').map(|s| s.text.clone()))
.unwrap_or_default();
drive_key(&mut app, key(KeyCode::Char('j')));
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('_')));
drive_key(&mut app, key(KeyCode::Char('d')));
drive_key(&mut app, key(KeyCode::Char('d')));
let after = app
.active_editor()
.with_registers(|r| r.read('"').map(|s| s.text.clone()))
.unwrap_or_default();
assert_eq!(
baseline, after,
"\"_dd must not overwrite the unnamed register"
);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert!(
!lines.iter().any(|l| l.contains("delete me")),
"\"_dd must still delete the line from the buffer, got {lines:?}"
);
}
#[test]
fn quote_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello");
drive_key(&mut app, key(KeyCode::Char('"')));
assert!(
app.pending_state.is_some(),
"\" must set app pending_state to SelectRegister"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must clear pending_state after \""
);
}
#[test]
fn quote_invalid_char_no_register_set() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world\nsecond");
drive_key(&mut app, key(KeyCode::Char('y')));
drive_key(&mut app, key(KeyCode::Char('y')));
let baseline_unnamed = app
.active_editor()
.with_registers(|r| r.read('"').map(|s| s.text.clone()))
.unwrap_or_default();
drive_key(&mut app, key(KeyCode::Char('"')));
drive_key(&mut app, key(KeyCode::Char('!')));
assert!(
app.pending_state.is_none(),
"invalid register char must cancel pending_state"
);
let exists = app
.active_editor()
.with_registers(|r| r.read('!').is_some());
assert!(!exists, "register '!' must not exist");
let after = app
.active_editor()
.with_registers(|r| r.read('"').map(|s| s.text.clone()))
.unwrap_or_default();
assert_eq!(
baseline_unnamed, after,
"unnamed register must be unchanged after \"!"
);
}
#[test]
fn m_a_then_apostrophe_a_jumps_back_to_line() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "first line\n second line\nthird line");
app.active_editor_mut().jump_cursor(2, 3);
drive_key(&mut app, key(KeyCode::Char('m')));
drive_key(&mut app, key(KeyCode::Char('a')));
assert!(
app.pending_state.is_none(),
"pending_state must clear after ma"
);
app.active_editor_mut().jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('\'')));
drive_key(&mut app, key(KeyCode::Char('a')));
assert!(
app.pending_state.is_none(),
"pending_state must clear after 'a"
);
assert_eq!(
app.active_editor().cursor().0,
2,
"'a must jump back to mark row"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn m_a_then_backtick_a_jumps_back_to_pos() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "first line\nsecond line\nthird line");
app.active_editor_mut().jump_cursor(1, 4);
drive_key(&mut app, key(KeyCode::Char('m')));
drive_key(&mut app, key(KeyCode::Char('a')));
app.active_editor_mut().jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('`')));
drive_key(&mut app, key(KeyCode::Char('a')));
assert!(
app.pending_state.is_none(),
"pending_state must clear after `a"
);
assert_eq!(
app.active_editor().cursor(),
(1, 4),
"`a must jump to exact mark position"
);
assert_window_synced_to_engine(&app);
}
#[test]
fn m_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_editor_mut().jump_cursor(0, 3);
drive_key(&mut app, key(KeyCode::Char('m')));
assert!(
app.pending_state.is_some(),
"m must enter SetMark pending state"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must cancel SetMark pending state"
);
assert_eq!(
app.active_editor().cursor(),
(0, 3),
"cursor must not move after m<Esc>"
);
}
#[test]
fn apostrophe_then_esc_cancels() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_editor_mut().jump_cursor(0, 2);
drive_key(&mut app, key(KeyCode::Char('\'')));
assert!(
app.pending_state.is_some(),
"' must enter GotoMarkLine pending state"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc must cancel GotoMarkLine pending state"
);
assert_eq!(
app.active_editor().cursor(),
(0, 2),
"cursor must not move after '<Esc>"
);
}
#[test]
fn backtick_in_visual_jumps_pos() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "first line\nsecond line\nthird line");
app.active_editor_mut().jump_cursor(2, 2);
drive_key(&mut app, key(KeyCode::Char('m')));
drive_key(&mut app, key(KeyCode::Char('b')));
app.active_editor_mut().jump_cursor(0, 0);
drive_key(&mut app, key(KeyCode::Char('v')));
assert_eq!(app.active_editor().vim_mode(), hjkl_engine::VimMode::Visual);
app.route_chord_key(ck('`'));
app.route_chord_key(ck('b'));
assert!(
app.pending_state.is_none(),
"pending_state must clear after `b in Visual mode"
);
assert_eq!(
app.active_editor().cursor().0,
2,
"`b in Visual mode must jump to mark row"
);
}
#[test]
fn q_then_esc_cancels_no_recording_started() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
drive_key(&mut app, ck('q'));
assert_eq!(
app.pending_state,
Some(hjkl_vim::PendingState::RecordMacroTarget),
"q must set RecordMacroTarget pending state"
);
drive_key(&mut app, key(KeyCode::Esc));
assert!(
app.pending_state.is_none(),
"Esc after q must clear pending_state"
);
assert!(
!app.active_editor().is_recording_macro(),
"Esc cancel must not start recording"
);
}
#[test]
fn bare_q_during_record_stops() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
drive_key(&mut app, ck('q'));
drive_key(&mut app, ck('a'));
assert!(
app.active_editor().is_recording_macro(),
"q a must start recording"
);
assert_eq!(app.active_editor().recording_register(), Some('a'));
drive_key(&mut app, ck('q'));
assert!(
!app.active_editor().is_recording_macro(),
"bare q must stop recording"
);
assert!(
app.pending_state.is_none(),
"pending_state must be clear after stop"
);
}
#[test]
fn record_macro_a_j_motion_replay_plays() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(
&mut app,
&[
ck('q'),
ck('a'), ck('j'), ck('q'), ],
);
assert!(
!app.active_editor().is_recording_macro(),
"recording must stop after second q"
);
assert_eq!(app.active_editor().cursor().0, 1);
macro_key_seq(&mut app, &[ck('@'), ck('a')]);
assert!(
!app.active_editor().is_replaying_macro(),
"replaying_macro must be false after replay finishes"
);
assert_eq!(
app.active_editor().cursor().0,
2,
"@a must replay j motion (move to row 2)"
);
}
#[test]
fn record_macro_a_insert_text_esc_motion_replays_full() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(
&mut app,
&[
ck('q'),
ck('a'), ck('A'), ck('t'),
ck('e'),
ck('s'),
ck('t'),
key(KeyCode::Esc), ck('0'), ck('j'), ck('q'), ],
);
assert!(!app.active_editor().is_recording_macro());
assert_eq!(
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 0),
"line0test",
"recording itself must append 'test' to line0"
);
assert_eq!(
app.active_editor().cursor(),
(1, 0),
"after recording, cursor must be at row 1, col 0"
);
macro_key_seq(&mut app, &[ck('@'), ck('a')]);
assert!(!app.active_editor().is_replaying_macro());
assert_eq!(
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 1),
"line1test",
"@a must re-execute A+test on line1"
);
assert_eq!(
app.active_editor().cursor(),
(2, 0),
"@a must end with cursor at row 2, col 0 (0 then j)"
);
}
#[test]
fn record_macro_a_comma_text_esc_j0_replays() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(
&mut app,
&[
ck('q'),
ck('a'),
ck('A'),
ck(','),
ck(' '),
ck('t'),
ck('h'),
ck('i'),
ck('s'),
ck(' '),
ck('i'),
ck('s'),
ck(' '),
ck('a'),
ck(' '),
ck('t'),
ck('e'),
ck('s'),
ck('t'),
key(KeyCode::Esc),
ck('j'),
ck('0'),
ck('q'),
],
);
assert!(!app.active_editor().is_recording_macro());
assert_eq!(
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 0),
"line0, this is a test",
"recording itself must append the text"
);
macro_key_seq(&mut app, &[ck('@'), ck('a')]);
assert_eq!(
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 1),
"line1, this is a test",
"@a must re-append the text on line1"
);
}
#[test]
fn gcc_toggles_comment_on_current_line_via_app_layer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "let x = 1;\nlet y = 2;");
app.active_editor_mut().set_filetype("rust");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('g'), ck('c'), ck('c')]);
assert_eq!(
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 0),
"// let x = 1;",
"gcc must toggle a comment marker onto line 0"
);
}
#[test]
fn gcc_on_doc_comment_uncomments_via_app_layer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "/// doc line\nfn foo() {}");
app.active_editor_mut().set_filetype("rust");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('g'), ck('c'), ck('c')]);
let line0 = hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 0);
assert_ne!(
line0, "/// doc line",
"gcc must mutate the doc-comment line; got {line0:?} (no-op == bug)"
);
}
#[test]
fn gc_in_visual_line_toggles_selection_via_app_layer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "let x = 1;\nlet y = 2;\nlet z = 3;");
app.active_editor_mut().set_filetype("rust");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('V'), ck('j'), ck('g'), ck('c')]);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines[0], "// let x = 1;",
"visual gc must comment line 0; got {lines:?}"
);
assert_eq!(
lines[1], "// let y = 2;",
"visual gc must comment line 1; got {lines:?}"
);
assert_eq!(
lines[2], "let z = 3;",
"visual gc must NOT touch line 2 (outside selection); got {lines:?}"
);
}
#[test]
fn gc_in_visual_charwise_toggles_selection_via_app_layer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "let x = 1;\nlet y = 2;");
app.active_editor_mut().set_filetype("rust");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('v'), ck('j'), ck('g'), ck('c')]);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(lines[0], "// let x = 1;");
assert_eq!(lines[1], "// let y = 2;");
}
#[test]
fn gcc_then_gcc_round_trips_after_routing_fix() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "let x = 1;\nlet y = 2;");
app.active_editor_mut().set_filetype("rust");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('g'), ck('c'), ck('c')]);
assert_eq!(
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 0),
"// let x = 1;"
);
assert!(!app.active_editor().is_chord_pending());
assert!(app.pending_state.is_none());
macro_key_seq(&mut app, &[ck('g'), ck('c'), ck('c')]);
assert_eq!(
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 0),
"let x = 1;",
"second gcc must uncomment after the routing fix"
);
assert!(!app.active_editor().is_chord_pending());
}
#[test]
fn gcc_with_count_3_toggles_3_lines_via_app_layer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "a\nb\nc\nd\ne");
app.active_editor_mut().set_filetype("rust");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('3'), ck('g'), ck('c'), ck('c')]);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(lines[0], "// a");
assert_eq!(lines[1], "// b");
assert_eq!(lines[2], "// c");
assert_eq!(lines[3], "d", "line 3 must stay outside the count range");
assert_eq!(lines[4], "e");
}
#[test]
fn gc_with_motion_j_toggles_2_lines_via_app_layer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "a\nb\nc");
app.active_editor_mut().set_filetype("rust");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('g'), ck('c'), ck('j')]);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(lines[0], "// a");
assert_eq!(lines[1], "// b");
assert_eq!(lines[2], "c");
}
#[test]
fn gcc_on_indented_doc_comment_via_app_layer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, " /// indented doc\nfn foo() {}");
app.active_editor_mut().set_filetype("rust");
app.active_editor_mut().jump_cursor(0, 4);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('g'), ck('c'), ck('c')]);
let line0 = hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 0);
assert_ne!(
line0, " /// indented doc",
"gcc on indented doc-comment must mutate; got {line0:?}"
);
}
#[test]
fn opening_rust_file_auto_sets_filetype_so_gcc_works() {
let dir = std::env::temp_dir().join(format!("hjkl-gcc-filetype-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file_path = dir.join("a.rs");
std::fs::write(&file_path, "let x = 1;\nlet y = 2;\n").unwrap();
let mut app = App::new(Some(file_path.clone()), false, None, None).unwrap();
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
assert_eq!(
app.active_editor().settings().filetype,
"rust",
"opening a .rs file must seed the editor's filetype to \"rust\""
);
macro_key_seq(&mut app, &[ck('g'), ck('c'), ck('c')]);
assert_eq!(
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 0),
"// let x = 1;",
"gcc on a freshly-opened .rs file must toggle a comment marker"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn at_at_repeats_last_macro() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('q'), ck('a'), ck('j'), ck('q')]);
assert_eq!(app.active_editor().cursor().0, 1);
macro_key_seq(&mut app, &[ck('@'), ck('a')]);
assert_eq!(app.active_editor().cursor().0, 2);
macro_key_seq(&mut app, &[ck('@'), ck('@')]);
assert_eq!(
app.active_editor().cursor().0,
3,
"@@ must replay the last macro"
);
}
#[test]
fn play_macro_returns_single_iteration() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4\nline5\nline6");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_editor_mut().start_macro_record('a');
app.active_editor_mut().record_input(hjkl_engine::Input {
key: hjkl_engine::Key::Char('j'),
..Default::default()
});
app.active_editor_mut().stop_macro_record();
let inputs = app.active_editor_mut().play_macro('a');
app.active_editor_mut().end_macro_replay();
assert_eq!(
inputs.len(),
1,
"play_macro must return one iteration of the macro keys"
);
app.play_macro_chord('a', 3);
app.sync_viewport_from_editor();
assert_eq!(
app.active_editor().cursor().0,
3,
"play_macro_chord with count=3 must move cursor to row 3"
);
}
#[test]
fn record_capital_appends_to_lowercase() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3");
app.active_editor_mut().jump_cursor(1, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('q'), ck('a'), ck('j'), ck('q')]);
assert_eq!(app.active_editor().cursor().0, 2);
macro_key_seq(&mut app, &[ck('q'), ck('A'), ck('k'), ck('q')]);
assert_eq!(app.active_editor().cursor().0, 1);
let start_row = app.active_editor().cursor().0; macro_key_seq(&mut app, &[ck('@'), ck('a')]);
assert_eq!(
app.active_editor().cursor().0,
start_row,
"@a with capital append must replay j+k (net zero from row {start_row})"
);
}
#[test]
fn self_referential_macro_terminates_without_stack_overflow() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abcdef");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_editor_mut()
.set_named_register_text('a', "x@a".to_string());
macro_key_seq(&mut app, &[ck('@'), ck('a')]);
assert!(
!app.active_editor().is_replaying_macro(),
"replaying_macro must be cleared after an aborted replay"
);
assert!(
app.macro_replay.is_none(),
"macro replay queue must be torn down after abort"
);
assert!(
app.bus.last_body_or_empty().contains("E169"),
"aborted recursive replay must emit E169, got {:?}",
app.bus.last_body_or_empty()
);
let text = app.active_editor().buffer().rope().to_string();
assert_eq!(
text.trim_end_matches('\n'),
"",
"recursive x@a must have consumed the whole line before aborting"
);
}
#[test]
fn huge_count_macro_replay_is_bounded() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_editor_mut()
.set_named_register_text('a', "x".to_string());
for _ in 0..9 {
app.pending_count.try_accumulate('9');
}
rck(&mut app, &['@', 'a']);
assert!(
!app.active_editor().is_replaying_macro(),
"replaying_macro must be cleared after the capped replay"
);
assert!(app.macro_replay.is_none(), "replay queue must be torn down");
assert!(
app.bus.last_body_or_empty().contains("E169"),
"input-cap abort must emit E169, got {:?}",
app.bus.last_body_or_empty()
);
let text = app.active_editor().buffer().rope().to_string();
assert_eq!(
text.trim_end_matches('\n'),
"",
"xxx must have emptied the line"
);
}
#[test]
fn nested_finite_macro_replays() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abcdef");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.active_editor_mut()
.set_named_register_text('a', "x@b".to_string());
app.active_editor_mut()
.set_named_register_text('b', "x".to_string());
macro_key_seq(&mut app, &[ck('@'), ck('a')]);
let text = app.active_editor().buffer().rope().to_string();
assert_eq!(
text.trim_end_matches('\n'),
"cdef",
"@a (x@b) must delete exactly two chars"
);
assert!(!app.active_editor().is_replaying_macro());
assert!(app.macro_replay.is_none());
assert!(
!app.bus.last_body_or_empty().contains("E169"),
"finite nesting must not trip the recursion guard"
);
}
#[test]
fn at_colon_replays_last_ex() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.dispatch_ex("3");
assert_eq!(
app.active_editor().cursor().0,
2,
":3 must move cursor to row 2"
);
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
assert_eq!(app.active_editor().cursor().0, 0);
app.replay_last_ex();
assert_eq!(
app.active_editor().cursor().0,
2,
"replay_last_ex must re-run :3 and land on row 2"
);
}
#[test]
fn at_colon_via_play_macro_arm_replays() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.dispatch_ex("3");
assert_eq!(app.active_editor().cursor().0, 2);
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.dispatch_action(
crate::keymap_actions::AppAction::BeginPendingPlayMacro { count: 1 },
1,
);
assert_eq!(
app.pending_state,
Some(hjkl_vim::PendingState::PlayMacroTarget { count: 1 }),
"BeginPendingPlayMacro must set PlayMacroTarget pending state"
);
let consumed = app.route_chord_key(ck(':'));
assert!(consumed, "@: must be consumed by route_chord_key");
assert!(
app.pending_state.is_none(),
"pending_state must be cleared after @: commit"
);
assert_eq!(
app.active_editor().cursor().0,
2,
"@: chord must replay :3 and land on row 2"
);
}
#[test]
fn at_colon_with_count_3_replays_three_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_editor_mut().jump_cursor(2, 0);
app.sync_viewport_from_editor();
app.dispatch_ex("1");
assert_eq!(app.active_editor().cursor().0, 0, ":1 must go to row 0");
app.active_editor_mut().jump_cursor(4, 0);
app.sync_viewport_from_editor();
app.dispatch_action(
crate::keymap_actions::AppAction::BeginPendingPlayMacro { count: 3 },
1,
);
let consumed = app.route_chord_key(ck(':'));
assert!(consumed, "3@: must be consumed");
assert_eq!(
app.active_editor().cursor().0,
0,
"3@: of :1 must end with cursor at row 0"
);
assert_eq!(
app.last_ex_command.as_deref(),
Some("1"),
"last_ex_command must remain '1' after 3@:"
);
}
#[test]
fn at_colon_no_prior_ex_is_noop() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
assert!(
app.last_ex_command.is_none(),
"fresh app must have no last_ex_command"
);
app.replay_last_ex();
assert_eq!(
app.active_editor().cursor().0,
0,
"cursor must be unchanged"
);
assert!(
app.bus.last_body().is_none(),
"no status message for no-op replay"
);
assert!(
!app.exit_requested,
"exit_requested must stay false on no-op"
);
}
#[test]
fn at_colon_within_macro_does_not_recurse() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3\nline4");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.dispatch_ex("1");
assert_eq!(app.active_editor().cursor().0, 0, ":1 must go to row 0");
assert_eq!(app.last_ex_command.as_deref(), Some("1"));
app.active_editor_mut().jump_cursor(2, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('q'), ck('a')]);
assert!(
app.active_editor().is_recording_macro(),
"must be recording"
);
app.replay_last_ex();
assert_eq!(
app.active_editor().cursor().0,
0,
"replay_last_ex during recording must move cursor to row 0"
);
assert!(
app.active_editor().is_recording_macro(),
"replay_last_ex must not stop macro recording"
);
assert_eq!(
app.last_ex_command.as_deref(),
Some("1"),
"last_ex_command must remain '1' after replay_last_ex"
);
macro_key_seq(&mut app, &[ck('q')]);
assert!(!app.active_editor().is_recording_macro());
}
#[test]
fn count_before_op_5dd_deletes_5_lines() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 10);
app.pending_count.try_accumulate('5');
rck(&mut app, &['d', 'd']);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines.first().map(String::as_str),
Some("line6"),
"5dd must delete lines 1-5; first line must now be 'line6', got {lines:?}"
);
assert_eq!(
app.active_editor().vim_mode(),
hjkl_engine::VimMode::Normal,
"must be in Normal after 5dd"
);
}
#[test]
fn register_then_count_a5dd_targets_register_a() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 10);
rck(&mut app, &['"', 'a']);
assert_eq!(
app.active_editor().pending_register(),
Some('a'),
"pending_register must be 'a' after \"a"
);
app.pending_count.try_accumulate('5');
rck(&mut app, &['d', 'd']);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines.first().map(String::as_str),
Some("line6"),
"\"a5dd must delete 5 lines; first line must now be 'line6', got {lines:?}"
);
let reg_a = app.active_editor().with_registers(|r| r.named[0].clone());
assert!(
reg_a.text.contains("line1"),
"register 'a' must contain deleted text; got {:?}",
reg_a.text
);
assert_eq!(
app.active_editor().pending_register(),
None,
"pending_register must be cleared after op"
);
}
#[test]
fn count_then_register_5_quote_a_dd_targets_register_a() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 10);
app.pending_count.try_accumulate('5');
rck(&mut app, &['"', 'a']);
assert_eq!(
app.active_editor().pending_register(),
Some('a'),
"pending_register must be 'a' after \"a"
);
assert_eq!(
app.pending_count.peek(),
5,
"pending_count must survive through register selection (5\"add regression)"
);
rck(&mut app, &['d', 'd']);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines.first().map(String::as_str),
Some("line6"),
"5\"add must delete 5 lines; first line must now be 'line6', got {lines:?}"
);
let reg_a = app.active_editor().with_registers(|r| r.named[0].clone());
assert!(
reg_a.text.contains("line1"),
"register 'a' must contain deleted text; got {:?}",
reg_a.text
);
}
#[test]
fn outer_count_inner_count_2_quote_a_5dd_total_10() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 30);
app.pending_count.try_accumulate('2');
rck(&mut app, &['"', 'a']);
assert_eq!(
app.pending_count.peek(),
2,
"pending_count must be 2 after \"a"
);
app.pending_count.try_accumulate('5');
assert_eq!(
app.pending_count.peek(),
25,
"pending_count digits accumulate to 25"
);
rck(&mut app, &['d', 'd']);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines.first().map(String::as_str),
Some("line26"),
"2\"a5dd with digit-accumulation semantics must delete 25 lines; got {lines:?}"
);
let reg_a = app.active_editor().with_registers(|r| r.named[0].clone());
assert!(
!reg_a.text.is_empty(),
"register 'a' must be non-empty after op"
);
}
#[test]
fn register_prefix_then_x_targets_register() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
rck(&mut app, &['"', 'a']);
assert_eq!(
app.active_editor().pending_register(),
Some('a'),
"pending_register must be 'a' after \"a"
);
hjkl_vim_tui::handle_key(app.active_editor_mut(), ck('x'));
app.sync_viewport_from_editor();
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines.first().map(String::as_str),
Some("ello world"),
"\"ax must delete 'h'; got {lines:?}"
);
let reg_a = app.active_editor().with_registers(|r| r.named[0].clone());
assert_eq!(
reg_a.text, "h",
"register 'a' must contain 'h' after \"ax; got {:?}",
reg_a.text
);
}
#[test]
fn register_prefix_single_use_then_next_op_unnamed() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 5);
rck(&mut app, &['"', 'a', 'd', 'd']);
let reg_a_text = app
.active_editor()
.with_registers(|r| r.named[0].text.clone());
assert!(
reg_a_text.contains("line1"),
"first dd must land in reg 'a'; got {:?}",
reg_a_text
);
assert_eq!(
app.active_editor().pending_register(),
None,
"pending_register must be cleared after first op"
);
let unnamed_before = app
.active_editor()
.with_registers(|r| r.unnamed.text.clone());
rck(&mut app, &['d', 'd']);
let unnamed_after = app
.active_editor()
.with_registers(|r| r.unnamed.text.clone());
assert_ne!(
unnamed_after, unnamed_before,
"second dd must update unnamed register"
);
let reg_a_text2 = app
.active_editor()
.with_registers(|r| r.named[0].text.clone());
assert_eq!(
reg_a_text, reg_a_text2,
"register 'a' must not be overwritten by second dd; got {:?}",
reg_a_text2
);
}
#[test]
fn count_then_play_macro_3at_a_plays_three_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_numbered_lines(&mut app, 10);
macro_key_seq(&mut app, &[ck('q'), ck('a')]);
assert!(
app.active_editor().is_recording_macro(),
"must be recording"
);
macro_key_seq(&mut app, &[ck('j')]);
macro_key_seq(&mut app, &[ck('q')]);
assert!(
!app.active_editor().is_recording_macro(),
"recording stopped"
);
let row_after_record = app.active_editor().cursor().0;
assert_eq!(row_after_record, 1, "recording 'j' moves cursor to row 1");
app.pending_count.try_accumulate('3');
rck(&mut app, &['@', 'a']);
let row_after_play = app.active_editor().cursor().0;
assert_eq!(
row_after_play, 4,
"3@a must play macro 3 times → cursor moves from row 1 to row 4; got {row_after_play}"
);
}
#[test]
fn count_then_dot_5_dot_repeats_five_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello world");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
hjkl_vim_tui::handle_key(app.active_editor_mut(), ck('x'));
app.sync_viewport_from_editor();
let lines_after_x = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines_after_x.first().map(String::as_str),
Some("ello world"),
"x must delete 'h'; got {lines_after_x:?}"
);
app.pending_count.try_accumulate('5');
let consumed = app.route_chord_key(ck('.'));
assert!(consumed, ". must be consumed by keymap");
app.sync_viewport_from_editor();
let lines_after_dot = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines_after_dot.first().map(String::as_str),
Some("world"),
"5. must repeat x 5 more times; got {lines_after_dot:?}"
);
}
#[test]
fn count_p_pastes_register_count_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "x");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('y'), ck('y'), ck('5'), ck('p')]);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines.len(),
6,
"5p on a 1-line linewise yank must produce 6 lines (1 + 5); got {lines:?}"
);
assert!(
lines.iter().all(|l| l == "x"),
"every line should be 'x'; got {lines:?}"
);
}
#[test]
fn count_with_zero_digits_pastes_correctly() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "x");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('y'), ck('y'), ck('1'), ck('0'), ck('p')]);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines.len(),
11,
"10p must produce 11 lines (1 + 10); got {lines:?}"
);
}
#[test]
fn count_p_charwise_yank_pastes_count_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(&mut app, &[ck('y'), ck('l'), ck('5'), ck('p')]);
let first_line = hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), 0);
assert_eq!(
first_line, "aaaaaabc",
"5p of charwise 'a' must produce 'aaaaaabc'; got {first_line:?}"
);
}
#[test]
fn count_100p_pastes_100_times() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "x");
app.active_editor_mut().jump_cursor(0, 0);
app.sync_viewport_from_editor();
macro_key_seq(
&mut app,
&[ck('y'), ck('y'), ck('1'), ck('0'), ck('0'), ck('p')],
);
let lines = app
.active_editor()
.buffer()
.rope()
.lines()
.map(|s| {
let s = s.to_string();
s.strip_suffix('\n').map(str::to_string).unwrap_or(s)
})
.collect::<Vec<_>>();
assert_eq!(
lines.len(),
101,
"100p must produce 101 lines (1 + 100); got {} lines",
lines.len()
);
}
#[test]
fn registers_are_global_across_buffers() {
let path_a = std::env::temp_dir().join("hjkl_reg_global_a.txt");
let path_b = std::env::temp_dir().join("hjkl_reg_global_b.txt");
std::fs::write(&path_a, "alpha\n").unwrap();
std::fs::write(&path_b, "beta\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("e {}", path_b.display()));
app.dispatch_ex(&format!("e {}", path_a.display()));
macro_key_seq(&mut app, &[ck('y'), ck('y')]);
app.dispatch_ex(&format!("e {}", path_b.display()));
macro_key_seq(&mut app, &[ck('p')]);
let content = (*app.active_editor().buffer().content_joined()).clone();
assert!(
content.contains("alpha"),
"yank in buffer A must paste in buffer B (global registers); got {content:?}"
);
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}