use super::*;
#[test]
fn palette_open_and_submit_runs_dispatch_and_closes() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
assert!(app.command_field.is_some());
type_str(&mut app, "quit");
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.completion.is_none(), "popup must be dismissed");
app.handle_command_field_key(key(KeyCode::Enter));
assert!(app.command_field.is_none());
assert!(app.exit_requested);
}
#[test]
fn wq_no_filename_does_not_exit() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "unsaved work");
app.active_mut().dirty = true;
app.dispatch_ex("wq");
assert!(!app.exit_requested, "wq must not exit when save fails");
let msg = app.bus.last_body_or_empty().to_string();
assert!(msg.contains("E32"), "expected E32, got: {msg}");
}
#[test]
fn wq_readonly_does_not_exit() {
let mut app = App::new(None, true, None, None).unwrap();
app.active_mut().filename = Some(tmp_path("hjkl_wq_ro_test.txt"));
app.dispatch_ex("wq");
assert!(!app.exit_requested, "wq must not exit when save fails");
let msg = app.bus.last_body_or_empty().to_string();
assert!(msg.contains("E45"), "expected E45, got: {msg}");
}
#[test]
fn palette_esc_in_insert_drops_to_normal_then_motions_apply() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "xyz");
if app.completion.is_some() {
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.completion.is_none());
}
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.command_field.is_some());
let f = app.command_field.as_ref().unwrap();
assert_eq!(f.coarse_mode(), hjkl_form::CoarseMode::Normal);
assert_eq!(f.text(), "xyz");
app.handle_command_field_key(key(KeyCode::Char('b')));
app.handle_command_field_key(key(KeyCode::Char('d')));
app.handle_command_field_key(key(KeyCode::Char('w')));
let f = app.command_field.as_ref().unwrap();
assert_eq!(f.text(), "");
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.command_field.is_none());
}
#[test]
fn palette_ctrl_c_cancels_without_quitting_app() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "wq");
let cc = ctrl_key('c');
if app.command_field.is_some()
&& cc.code == KeyCode::Char('c')
&& cc.modifiers.contains(KeyModifiers::CONTROL)
{
app.command_field = None;
}
assert!(app.command_field.is_none());
assert!(!app.exit_requested);
}
#[test]
fn search_open_and_type_drives_live_preview() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foo bar foo baz");
app.open_search_prompt(SearchDir::Forward);
assert!(app.search_field.is_some());
type_search(&mut app, "foo");
assert_eq!(app.search_field.as_ref().unwrap().text(), "foo");
assert!(app.active_editor().search_state().pattern.is_some());
}
#[test]
fn search_more_typing_updates_pattern() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foobar foozle");
app.open_search_prompt(SearchDir::Forward);
type_search(&mut app, "foo");
let p1 = app
.active_editor()
.search_state()
.pattern
.as_ref()
.unwrap()
.as_str()
.to_string();
type_search(&mut app, "z");
let p2 = app
.active_editor()
.search_state()
.pattern
.as_ref()
.unwrap()
.as_str()
.to_string();
assert_ne!(p1, p2, "pattern must update on further typing");
}
#[test]
fn search_motion_in_normal_edits_prompt_and_updates_highlight() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "alpha beta\ngamma");
app.open_search_prompt(SearchDir::Forward);
type_search(&mut app, "alpha beta");
app.handle_search_field_key(key(KeyCode::Esc));
assert_eq!(
app.search_field.as_ref().unwrap().coarse_mode(),
hjkl_form::CoarseMode::Normal
);
app.handle_search_field_key(key(KeyCode::Char('b')));
app.handle_search_field_key(key(KeyCode::Char('d')));
app.handle_search_field_key(key(KeyCode::Char('b')));
let new_text = app.search_field.as_ref().unwrap().text();
assert!(
new_text.len() < "alpha beta".len(),
"prompt text shrank: {new_text:?}"
);
}
#[test]
fn search_enter_commits_and_advances_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "alpha\nbeta\nfoo here\ndone");
app.open_search_prompt(SearchDir::Forward);
type_search(&mut app, "foo");
app.handle_search_field_key(key(KeyCode::Enter));
assert!(app.search_field.is_none());
let (row, col) = app.active_editor().cursor();
assert_eq!(row, 2);
assert_eq!(col, 0);
assert_eq!(app.active_editor().last_search(), Some("foo"));
assert!(app.active_editor().last_search_forward());
}
#[test]
fn search_esc_twice_cancels_and_clears_when_no_prior_search() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "abc def");
app.open_search_prompt(SearchDir::Forward);
type_search(&mut app, "abc");
app.handle_search_field_key(key(KeyCode::Esc));
assert!(app.search_field.is_some());
app.handle_search_field_key(key(KeyCode::Esc));
assert!(app.search_field.is_none());
assert!(app.active_editor().search_state().pattern.is_none());
}
#[test]
fn search_backward_prompt_uses_question_dir() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foo here\nbar there\nfoo again");
app.active_editor_mut().goto_line(3);
app.open_search_prompt(SearchDir::Backward);
type_search(&mut app, "foo");
app.handle_search_field_key(key(KeyCode::Enter));
let (row, _) = app.active_editor().cursor();
assert_eq!(row, 0);
assert!(!app.active_editor().last_search_forward());
}
#[test]
fn colon_set_background_dark_swaps_theme() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set background=dark");
let msg = app.bus.last_body_or_empty().to_string();
assert_eq!(
msg, "background=dark",
"expected background=dark, got: {msg}"
);
}
#[test]
fn colon_set_background_light_swaps_theme() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set background=light");
let msg = app.bus.last_body_or_empty().to_string();
assert_eq!(
msg, "background=light",
"expected background=light, got: {msg}"
);
}
#[test]
fn colon_set_background_unknown_errors() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set background=mauve");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.starts_with("E:"),
"expected E: error for unknown background, got: {msg}"
);
}
#[test]
fn esc_on_empty_command_prompt_dismisses() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
assert!(app.command_field.is_some());
if app.completion.is_some() {
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.completion.is_none(), "first Esc must dismiss popup");
assert!(
app.command_field.is_some(),
"field stays open after popup Esc"
);
}
app.handle_command_field_key(key(KeyCode::Esc));
assert!(
app.command_field.is_none(),
"empty : prompt should close on Esc"
);
}
#[test]
fn esc_on_nonempty_command_drops_to_normal_then_closes() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
app.handle_command_field_key(key(KeyCode::Char('w')));
assert!(app.completion.is_some(), "popup must be open after 'w'");
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.completion.is_none(), "Esc must dismiss popup");
assert!(app.command_field.is_some(), "field stays open after Esc");
assert_eq!(
app.command_field.as_ref().unwrap().coarse_mode(),
hjkl_form::CoarseMode::Normal,
"single Esc must also leave Insert mode"
);
app.handle_command_field_key(key(KeyCode::Esc));
assert!(app.command_field.is_none());
}
#[test]
fn esc_on_empty_search_prompt_dismisses() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_search_prompt(SearchDir::Forward);
assert!(app.search_field.is_some());
app.handle_search_field_key(key(KeyCode::Esc));
assert!(
app.search_field.is_none(),
"empty / prompt should close on Esc"
);
}
#[test]
fn edit_no_arg_no_filename_e32() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("e");
let msg = app.bus.last_body_or_empty().to_string();
assert!(msg.contains("E32"), "expected E32, got: {msg}");
}
#[test]
fn colon_typing_opens_completion_popup() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
assert!(
app.completion.is_some(),
"completion popup must open after typing in : prompt"
);
let popup = app.completion.as_ref().unwrap();
assert!(!popup.is_empty(), "popup must have candidates for 'w'");
for &idx in &popup.visible {
let label = &popup.all_items[idx].label;
assert!(
label.starts_with('w'),
"candidate {label:?} must start with 'w'"
);
}
}
#[test]
fn colon_popup_items_have_docs() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "e");
let popup = app.completion.as_ref().expect("popup must be open");
let edit_item = popup
.all_items
.iter()
.find(|i| i.label == "edit" || i.label.starts_with("edit"));
assert!(edit_item.is_some(), "should find an 'edit*' candidate");
let item = edit_item.unwrap();
assert!(
item.detail.is_some(),
"command item must have a detail/doc string"
);
}
#[test]
fn colon_tab_navigates_popup_forward() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
assert!(app.completion.is_some());
let initial_sel = app.completion.as_ref().unwrap().selected;
app.handle_command_field_key(tab_key()); let after_tab = app.completion.as_ref().unwrap().selected;
let visible_len = app.completion.as_ref().unwrap().visible.len();
if visible_len > 1 {
assert_ne!(initial_sel, after_tab, "Tab must advance selection");
}
}
#[test]
fn colon_shift_tab_navigates_popup_backward() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
assert!(app.completion.is_some());
let visible_len = app.completion.as_ref().unwrap().visible.len();
if visible_len > 1 {
app.handle_command_field_key(tab_key()); let after_tab = app.completion.as_ref().unwrap().selected;
app.handle_command_field_key(shift_tab_key()); let after_btab = app.completion.as_ref().unwrap().selected;
assert_ne!(after_tab, after_btab, "S-Tab must move selection back");
}
}
#[test]
fn colon_enter_accepts_selected_item_no_execute() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "e");
assert!(
app.completion.is_some(),
"popup must be open after typing 'e'"
);
app.handle_command_field_key(key(KeyCode::Tab));
app.handle_command_field_key(key(KeyCode::Enter));
assert!(
app.completion.is_none(),
"popup must close after Enter-accept"
);
assert!(
app.command_field.is_some(),
"command field must stay open after Enter-accept"
);
let text = app.command_field.as_ref().unwrap().text();
assert!(!text.is_empty(), "field must contain accepted text");
}
#[test]
fn colon_accept_arg_command_appends_space() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "edit");
assert!(app.completion.is_some(), "popup must be open");
let popup = app.completion.as_ref().unwrap();
let edit_vis_idx = popup
.visible
.iter()
.position(|&idx| popup.all_items[idx].label == "edit");
if let Some(vis_idx) = edit_vis_idx {
app.completion.as_mut().unwrap().selected = vis_idx;
app.accept_command_completion();
let text = app.command_field.as_ref().unwrap().text();
assert!(
text.ends_with(' '),
"arg-taking command 'edit' must have trailing space after accept: {text:?}"
);
}
}
#[test]
fn colon_enter_exact_match_executes_directly() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "wq");
let wq_vis_idx = app.completion.as_ref().and_then(|popup| {
popup
.visible
.iter()
.position(|&idx| popup.all_items[idx].label == "wq")
});
if let (Some(vis_idx), Some(popup)) = (wq_vis_idx, app.completion.as_mut()) {
popup.selected = vis_idx;
assert!(
!app.command_accept_would_change_line(),
"exact-match 'wq' should not change the line on accept"
);
}
app.handle_command_field_key(key(KeyCode::Enter));
assert!(
app.command_field.is_none(),
"exact-match command must execute on the first Enter (field closed)"
);
}
#[test]
fn colon_alias_command_executes_directly() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
assert!(app.completion.is_some(), "popup should be open after 'w'");
assert!(
app.command_line_is_runnable(),
"':w' must resolve as a runnable command via its alias"
);
app.handle_command_field_key(key(KeyCode::Enter));
assert!(
app.command_field.is_none(),
"alias command ':w' must execute on the first Enter (field closed)"
);
}
#[test]
fn colon_navigated_popup_still_accepts_over_runnable_alias() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
app.handle_command_field_key(key(KeyCode::Tab));
let selected_label = app
.completion
.as_ref()
.and_then(|p| p.selected_item())
.map(|i| i.label.clone());
app.handle_command_field_key(key(KeyCode::Enter));
assert!(
app.command_field.is_some(),
"navigated accept must not execute the alias"
);
if let Some(label) = selected_label {
let text = app.command_field.as_ref().unwrap().text();
assert_eq!(
text.trim_end(),
label,
"line must become the navigated candidate (modulo arg space)"
);
}
}
#[test]
fn colon_empty_prompt_has_no_popup_and_esc_closes_in_one_press() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
assert!(app.command_field.is_some());
assert!(
app.completion.is_none(),
"empty `:` prompt must not show a popup"
);
app.handle_command_field_key(key(KeyCode::Esc));
assert!(
app.command_field.is_none(),
"single Esc must close an empty `:` prompt"
);
}
#[test]
fn colon_esc_with_popup_open_clears_popup_and_propagates() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
let text_before = app.command_field.as_ref().unwrap().text();
assert!(app.completion.is_some());
assert_eq!(
app.command_field.as_ref().unwrap().coarse_mode(),
hjkl_form::CoarseMode::Insert,
"prompt starts in Insert mode"
);
app.handle_command_field_key(key(KeyCode::Esc));
assert!(
app.completion.is_none(),
"completion must be cleared after Esc with popup open"
);
assert!(
app.command_field.is_some(),
"command field must stay open (non-empty text → Normal, not closed)"
);
assert_eq!(
app.command_field.as_ref().unwrap().coarse_mode(),
hjkl_form::CoarseMode::Normal,
"Esc must also leave Insert mode, not only dismiss the popup"
);
let text_after = app.command_field.as_ref().unwrap().text();
assert_eq!(
text_before, text_after,
"Esc must keep typed text when dismissing popup"
);
}
#[test]
fn colon_popup_closes_when_no_match() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "zzzzzzz_no_such_cmd");
assert!(
app.completion.is_none(),
"popup must be None when no candidates match"
);
}
#[test]
fn colon_ctrl_n_navigates_popup_when_open() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
type_str(&mut app, "w");
assert!(app.completion.is_some());
let sel_before = app.completion.as_ref().unwrap().selected;
let visible_len = app.completion.as_ref().unwrap().visible.len();
app.handle_command_field_key(ctrl_key('n'));
if visible_len > 1 {
let sel_after = app.completion.as_ref().unwrap().selected;
assert_ne!(sel_before, sel_after, "C-n must advance selection in popup");
}
}