use super::*;
#[test]
fn app_new_no_file() {
let app = App::new(None, false, None, None).unwrap();
assert!(!app.active().dirty);
assert!(!app.active().is_new_file);
assert!(app.active().filename.is_none());
assert!(!app.active().editor.is_readonly());
}
#[test]
fn app_new_readonly_flag() {
let app = App::new(None, true, None, None).unwrap();
assert!(app.active().editor.is_readonly());
}
#[test]
fn app_new_not_found_sets_is_new_file() {
let path = tmp_path("hjkl_phase5_nonexistent_abc123.txt");
let _ = std::fs::remove_file(&path);
let app = App::new(Some(path), false, None, None).unwrap();
assert!(app.active().is_new_file);
assert!(!app.active().dirty);
}
#[test]
fn app_new_goto_line_clamps() {
let app = App::new(None, false, Some(999), None).unwrap();
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 0);
}
#[test]
fn ex_goto_line_100_via_dispatch() {
let mut app = App::new(None, false, None, None).unwrap();
let buf: String = (1..=120)
.map(|n| format!("line{n}"))
.collect::<Vec<_>>()
.join("\n");
use hjkl_buffer::{Edit, Position};
app.active_mut().editor.mutate_edit(Edit::InsertStr {
at: Position::new(0, 0),
text: buf,
});
app.active_mut().editor.jump_cursor(0, 0);
app.dispatch_ex("100");
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 99, "':100' must land on row 99");
}
#[test]
fn dot_repeat_replays_last_change() {
use crate::keymap_actions::AppAction;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use hjkl_buffer::{Edit, Position};
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().editor.mutate_edit(Edit::InsertStr {
at: Position::new(0, 0),
text: "abc".to_string(),
});
app.active_mut().editor.jump_cursor(0, 0);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
app.dispatch_action(AppAction::DotRepeat { count: 1 }, 1);
let line0 = app.active().editor.buffer().line(0).map(|l| l.to_string());
assert_eq!(
line0.as_deref(),
Some("c"),
"`.` after `x` must delete one more char, got {line0:?}"
);
}
#[test]
fn dot_repeat_with_count_3_replays_three_times() {
use crate::keymap_actions::AppAction;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use hjkl_buffer::{Edit, Position};
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().editor.mutate_edit(Edit::InsertStr {
at: Position::new(0, 0),
text: "abcdef".to_string(),
});
app.active_mut().editor.jump_cursor(0, 0);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
app.pending_count.try_accumulate('3');
app.dispatch_action(AppAction::DotRepeat { count: 1 }, 1);
let line0 = app.active().editor.buffer().line(0).map(|l| l.to_string());
assert_eq!(
line0.as_deref(),
Some("ef"),
"`3.` after `x` must delete 3 more chars, got {line0:?}"
);
}
#[test]
fn ex_goto_line_100_via_command_field_keys() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use hjkl_buffer::{Edit, Position};
let mut app = App::new(None, false, None, None).unwrap();
let buf: String = (1..=120)
.map(|n| format!("line{n}"))
.collect::<Vec<_>>()
.join("\n");
app.active_mut().editor.mutate_edit(Edit::InsertStr {
at: Position::new(0, 0),
text: buf,
});
app.active_mut().editor.jump_cursor(0, 0);
app.open_command_prompt();
for c in ['1', '0', '0'] {
app.handle_command_field_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
app.handle_command_field_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (row, _col) = app.active().editor.cursor();
assert_eq!(
row, 99,
"':100<Enter>' via command-field must land on row 99, got {row}"
);
let fw = app.focused_window();
let win = app.windows[fw].as_ref().unwrap();
assert_eq!(
win.cursor_row, 99,
"window cache cursor_row must follow engine cursor after `:100`"
);
}
#[test]
fn do_save_readonly_blocked() {
let mut app = App::new(None, true, None, None).unwrap();
app.active_mut().filename = Some(tmp_path("hjkl_phase5_ro_test.txt"));
app.do_save(None);
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("E45"),
"expected E45 readonly error, got: {msg}"
);
}
#[test]
fn do_save_no_filename_e32() {
let mut app = App::new(None, false, None, None).unwrap();
app.do_save(None);
let msg = app.bus.last_body_or_empty().to_string();
assert!(msg.contains("E32"), "expected E32, got: {msg}");
}
#[test]
fn start_screen_present_when_no_file() {
let app = App::new(None, false, None, None).unwrap();
assert!(
app.start_screen.is_some(),
"start_screen must be Some when no file given"
);
}
#[test]
fn start_screen_absent_when_file_given() {
let path = std::env::temp_dir().join("hjkl_splash_with_file.txt");
std::fs::write(&path, "x\n").unwrap();
let app = App::new(Some(path.clone()), false, None, None).unwrap();
assert!(
app.start_screen.is_none(),
"start_screen must be None when file given"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn mode_label_returns_start_during_splash() {
let app = App::new(None, false, None, None).unwrap();
assert!(app.start_screen.is_some());
assert_eq!(app.mode_label(), "START");
}
#[test]
fn with_config_updates_leader_and_reapplies_to_existing_slot() {
let app = App::new(None, false, None, None).unwrap();
assert_eq!(app.config.editor.leader, ' ');
let mut cfg = hjkl_app::config::Config::default();
cfg.editor.leader = '\\';
cfg.editor.tab_width = 2;
let app = app.with_config(cfg);
assert_eq!(app.config.editor.leader, '\\');
assert_eq!(app.config.editor.tab_width, 2);
assert_eq!(
app.slots.len(),
1,
"with_config should not add or drop slots"
);
}
#[test]
fn with_config_preserves_readonly_on_existing_slot() {
let app = App::new(None, true, None, None).unwrap();
assert!(app.active().editor.is_readonly());
let app = app.with_config(hjkl_app::config::Config::default());
assert!(
app.active().editor.is_readonly(),
"readonly state must survive with_config re-application"
);
}
#[test]
fn config_load_from_disk_then_with_config_propagates_overrides() {
use std::io::Write as _;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(
tmp,
r#"
[editor]
leader = "\\"
tab_width = 2
[theme]
name = "dark"
"#
)
.unwrap();
let cfg = hjkl_app::config::load_from(tmp.path()).expect("load_from must succeed");
assert_eq!(cfg.editor.huge_file_threshold, 50_000);
assert!(cfg.editor.expandtab);
assert_eq!(cfg.editor.leader, '\\');
assert_eq!(cfg.editor.tab_width, 2);
use hjkl_config::Validate;
cfg.validate()
.expect("merged user+default config must validate");
let app = App::new(None, false, None, None).unwrap().with_config(cfg);
assert_eq!(app.config.editor.leader, '\\');
assert_eq!(app.config.editor.tab_width, 2);
}
#[test]
fn config_load_from_disk_validation_failure_surfaces() {
use std::io::Write as _;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp, "[editor]\nhuge_file_threshold = 0").unwrap();
let cfg = hjkl_app::config::load_from(tmp.path()).expect("parse must succeed");
use hjkl_config::Validate;
let err = cfg.validate().unwrap_err();
assert_eq!(err.field, "editor.huge_file_threshold");
}
#[test]
fn set_cursorline_flips_setting() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(
app.active().editor.settings().cursorline,
"cursorline must default to true"
);
app.dispatch_ex("set nocursorline");
assert!(
!app.active().editor.settings().cursorline,
":set nocursorline must disable cursorline"
);
app.dispatch_ex("set cursorline");
assert!(
app.active().editor.settings().cursorline,
":set cursorline must enable cursorline"
);
}
#[test]
fn set_cul_alias_works() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set cul");
assert!(
app.active().editor.settings().cursorline,
":set cul must enable cursorline"
);
}
#[test]
fn set_cursorcolumn_flips_setting() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(!app.active().editor.settings().cursorcolumn);
app.dispatch_ex("set cuc");
assert!(app.active().editor.settings().cursorcolumn);
app.dispatch_ex("set nocuc");
assert!(!app.active().editor.settings().cursorcolumn);
}
#[test]
fn set_signcolumn_yes() {
use hjkl_engine::types::SignColumnMode;
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(
app.active().editor.settings().signcolumn,
SignColumnMode::Auto,
"signcolumn defaults to auto"
);
app.dispatch_ex("set signcolumn=yes");
assert_eq!(
app.active().editor.settings().signcolumn,
SignColumnMode::Yes
);
}
#[test]
fn set_signcolumn_scl_alias() {
use hjkl_engine::types::SignColumnMode;
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_ex("set scl=no");
assert_eq!(
app.active().editor.settings().signcolumn,
SignColumnMode::No
);
}
#[test]
fn set_foldcolumn_stores_value() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.active().editor.settings().foldcolumn, 0);
app.dispatch_ex("set foldcolumn=4");
assert_eq!(app.active().editor.settings().foldcolumn, 4);
app.dispatch_ex("set fdc=0");
assert_eq!(app.active().editor.settings().foldcolumn, 0);
}
#[test]
fn set_colorcolumn_stores_value() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.active().editor.settings().colorcolumn, "");
app.dispatch_ex("set cc=80");
assert_eq!(app.active().editor.settings().colorcolumn, "80");
app.dispatch_ex("set colorcolumn=80,120");
assert_eq!(app.active().editor.settings().colorcolumn, "80,120");
app.dispatch_ex("set cc=");
assert_eq!(app.active().editor.settings().colorcolumn, "");
}
#[test]
fn leader_slash_no_inline_intercept_regression() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none(), "picker must start None");
assert!(app.search_field.is_none(), "search_field must start None");
app.route_chord_key(key(KeyCode::Char(' ')));
app.route_chord_key(key(KeyCode::Char('/')));
assert!(
app.picker.is_some(),
"<leader>/ must open the grep picker, not the search prompt"
);
assert!(
app.search_field.is_none(),
"<leader>/ must NOT open the search prompt"
);
}
#[test]
fn colon_opens_command_prompt_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.command_field.is_none());
app.route_chord_key(key(KeyCode::Char(':')));
assert!(
app.command_field.is_some(),
"`:` must open the command prompt via keymap dispatch"
);
}
#[test]
fn slash_opens_search_prompt_forward_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.search_field.is_none());
app.route_chord_key(key(KeyCode::Char('/')));
assert!(
app.search_field.is_some(),
"`/` must open the search prompt via keymap dispatch"
);
}
#[test]
fn question_opens_search_prompt_backward_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.search_field.is_none());
app.route_chord_key(key(KeyCode::Char('?')));
assert!(
app.search_field.is_some(),
"`?` must open the search prompt via keymap dispatch"
);
}
#[test]
fn ctrl_caret_triggers_buffer_alt_via_keymap() {
let mut app = App::new(None, false, None, None).unwrap();
app.route_chord_key(ctrl_key('^'));
assert!(app.picker.is_none(), "ctrl-^ must not open picker");
}
#[test]
fn h_single_slot_fallback_to_viewport_top() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.slots.len(), 1, "test requires single slot");
let consumed = app.route_chord_key(key(KeyCode::Char('H')));
assert!(consumed, "H must be consumed by keymap (BufferCycleH)");
}
#[test]
fn l_single_slot_fallback_to_viewport_bottom() {
let mut app = App::new(None, false, None, None).unwrap();
assert_eq!(app.slots.len(), 1, "test requires single slot");
let consumed = app.route_chord_key(key(KeyCode::Char('L')));
assert!(consumed, "L must be consumed by keymap (BufferCycleL)");
}
#[test]
fn ctrl_h_single_window_no_tmux_no_panic() {
let mut app = App::new(None, false, None, None).unwrap();
let consumed = app.route_chord_key(ctrl_key('h'));
assert!(consumed, "<C-h> must be consumed by keymap (TmuxNavigate)");
}
#[test]
fn handle_keypress_ctrl_c_breaks() {
use crate::app::event_loop::KeyOutcome;
let mut app = App::new(None, false, None, None).unwrap();
let outcome = app.handle_keypress(ctrl_key('c'));
assert!(
matches!(outcome, KeyOutcome::Break),
"Ctrl-C with no overlay must return Break"
);
}
#[test]
fn handle_keypress_ctrl_c_dismisses_command_field() {
use crate::app::event_loop::KeyOutcome;
let mut app = App::new(None, false, None, None).unwrap();
app.open_command_prompt();
assert!(app.command_field.is_some());
let outcome = app.handle_keypress(ctrl_key('c'));
assert!(
matches!(outcome, KeyOutcome::Continue),
"Ctrl-C with command field open must dismiss and return Continue"
);
assert!(app.command_field.is_none());
}
#[test]
fn handle_keypress_colon_opens_command_prompt() {
use crate::app::event_loop::KeyOutcome;
let mut app = App::new(None, false, None, None).unwrap();
let outcome = app.handle_keypress(key(KeyCode::Char(':')));
assert!(
matches!(outcome, KeyOutcome::Continue),
"`:` must return Continue after opening command prompt"
);
assert!(
app.command_field.is_some(),
"`:` must open the command prompt"
);
}
#[test]
fn handle_keypress_leader_slash_opens_grep_picker() {
use crate::app::event_loop::KeyOutcome;
let mut app = App::new(None, false, None, None).unwrap();
let o1 = app.handle_keypress(key(KeyCode::Char(' ')));
assert!(
matches!(o1, KeyOutcome::Continue),
"<leader> first key must return Continue"
);
let o2 = app.handle_keypress(key(KeyCode::Char('/')));
assert!(
matches!(o2, KeyOutcome::Continue),
"<leader>/ second key must return Continue"
);
assert!(
app.picker.is_some(),
"<leader>/ via handle_keypress must open grep picker"
);
assert!(
app.search_field.is_none(),
"<leader>/ via handle_keypress must NOT open search prompt"
);
}
#[test]
fn dispatch_picker_action_opens_file_picker() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.picker.is_none(), "picker starts closed");
app.dispatch_action(AppAction::OpenFilePicker, 1);
assert!(app.picker.is_some(), "OpenFilePicker must open picker");
}
#[test]
fn dispatch_picker_action_opens_buffer_picker() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_action(AppAction::OpenBufferPicker, 1);
assert!(app.picker.is_some(), "OpenBufferPicker must open picker");
}
#[test]
fn dispatch_git_action_status_sets_picker_or_notification() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_action(AppAction::GitStatus, 1);
let reacted = app.picker.is_some() || app.bus.last_body().is_some();
assert!(reacted, "GitStatus must open picker or set status message");
}
#[test]
fn dispatch_lsp_action_lsp_rename_sets_notification() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_action(AppAction::LspRename, 1);
assert!(
app.bus.last_body().is_some(),
"LspRename must push a notification"
);
let msg = app.bus.last_body_or_empty();
assert!(
msg.contains("Rename"),
"LspRename status must mention :Rename, got: {msg}"
);
}
#[test]
fn dispatch_window_action_focus_left_on_single_window_no_panic() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_action(AppAction::FocusLeft, 1);
}
#[test]
fn dispatch_buffer_action_buffer_next_single_slot_sets_message() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_action(AppAction::BufferNext, 1);
assert!(
app.bus.last_body().is_some(),
"BufferNext on single slot must push a notification"
);
}
#[test]
fn dispatch_prompt_action_open_command_prompt_opens_command_field() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_action(AppAction::OpenCommandPrompt, 1);
assert!(
app.command_field.is_some(),
"OpenCommandPrompt must open command_field"
);
}
#[test]
fn dispatch_prompt_action_open_search_prompt_opens_search_field() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_action(AppAction::OpenSearchPrompt(SearchDir::Forward), 1);
assert!(
app.search_field.is_some(),
"OpenSearchPrompt must open search_field"
);
}
#[test]
fn dispatch_pending_state_action_begin_pending_replace_sets_state() {
let mut app = App::new(None, false, None, None).unwrap();
assert!(app.pending_state.is_none(), "pending_state starts None");
app.dispatch_action(AppAction::BeginPendingReplace { count: 1 }, 1);
assert!(
app.pending_state.is_some(),
"BeginPendingReplace must set pending_state"
);
assert!(
matches!(
app.pending_state,
Some(hjkl_vim::PendingState::Replace { .. })
),
"pending_state must be Replace variant"
);
}
#[test]
fn dispatch_engine_action_dot_repeat_no_panic_on_empty_buffer() {
let mut app = App::new(None, false, None, None).unwrap();
app.dispatch_action(AppAction::DotRepeat { count: 1 }, 1);
}
#[test]
fn dispatch_action_stays_small() {
let src = include_str!("../dispatch.rs");
let start = src
.find("pub fn dispatch_action")
.expect("dispatch_action must exist in dispatch.rs");
let rest = &src[start..];
let mut brace_depth = 0usize;
let mut line_count = 0usize;
let mut found_open = false;
for line in rest.lines() {
line_count += 1;
for ch in line.chars() {
match ch {
'{' => {
brace_depth += 1;
found_open = true;
}
'}' => {
brace_depth = brace_depth.saturating_sub(1);
}
_ => {}
}
}
if found_open && brace_depth == 0 {
break;
}
}
assert!(
line_count < 100,
"dispatch_action must be < 100 lines, got {line_count}"
);
}
#[test]
fn sync_appends_edit_log_with_correct_dirty_gen() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "a\nb\nc\nd\ne");
app.sync_after_engine_mutation();
app.active_mut().dirty_rows_log.clear();
app.active_mut().editor.jump_cursor(2, 0);
app.sync_viewport_from_editor();
enter_insert(&mut app);
dik(
&mut app,
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
),
);
let current_dg = app.active().editor.buffer().dirty_gen();
let log = &app.active().dirty_rows_log;
assert!(
!log.is_empty(),
"dirty_rows_log must have at least one entry after an edit"
);
let entry_for_row2 = log
.iter()
.find(|(dg, range)| *dg == current_dg && range.contains(&2));
assert!(
entry_for_row2.is_some(),
"dirty_rows_log must have an entry with dirty_gen={current_dg} containing row 2; \
log={log:?}"
);
}