use super::*;
use hjkl_ex::QfCommand;
use hjkl_quickfix::{QfEntry, QfKind};
fn entry(path: &std::path::Path, row: usize) -> QfEntry {
QfEntry {
path: path.to_path_buf(),
row,
col: 0,
kind: QfKind::Grep,
message: format!("hit at {row}"),
}
}
#[test]
fn quickfix_dock_nav_and_toggle() {
let mut app = App::new(None, false, None, None).unwrap();
let p = std::path::PathBuf::from("x.rs");
app.quickfix
.set(vec![entry(&p, 0), entry(&p, 1), entry(&p, 2)]);
app.handle_quickfix_command(QfCommand::Open);
assert!(app.quickfix_open());
assert!(app.bottom_dock.is_some());
app.handle_quickfix_command(QfCommand::Close);
assert!(!app.quickfix_open());
assert!(app.bottom_dock.is_none());
}
#[cfg(unix)]
#[test]
fn quickfix_make_parses_output_into_list() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("hjkl_make_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("fakemake.sh");
std::fs::write(
&script,
"#!/bin/sh\necho 'src/main.rs:3:5: error: boom' 1>&2\nexit 1\n",
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut().settings_mut().makeprg = script.to_string_lossy().into_owned();
app.handle_quickfix_command(QfCommand::Make(String::new()));
assert_eq!(app.quickfix.len(), 1, ":make should populate the list");
assert!(app.quickfix_open(), ":make with errors opens the dock");
let e = app.quickfix.current().unwrap();
assert_eq!((e.row, e.col), (2, 4)); assert_eq!(e.kind, QfKind::Error);
assert_eq!(e.message, "boom");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quickfix_open_empty_does_not_show() {
let mut app = App::new(None, false, None, None).unwrap();
app.handle_quickfix_command(QfCommand::Open);
assert!(
!app.quickfix_open(),
"empty quickfix list must not open the dock"
);
}
#[test]
fn quickfix_next_jumps_to_entry() {
let dir = std::env::temp_dir().join(format!("hjkl_qf_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("sample.txt");
std::fs::write(&file, "line zero\nline one\nline two\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.quickfix.set(vec![entry(&file, 0), entry(&file, 2)]);
app.handle_quickfix_command(QfCommand::Next);
assert_eq!(app.quickfix.cursor(), 1);
assert_eq!(
app.active_editor().cursor().0,
2,
"cnext should jump the editor cursor to the entry's row"
);
app.dispatch_action(crate::keymap_actions::AppAction::QuickfixPrev, 1);
assert_eq!(app.quickfix.cursor(), 0);
assert_eq!(app.active_editor().cursor().0, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn loclist_independent_from_quickfix() {
let mut app = App::new(None, false, None, None).unwrap();
let p = std::path::PathBuf::from("x.rs");
app.loclist.set(vec![entry(&p, 0), entry(&p, 1)]);
app.handle_loclist_command(QfCommand::Open);
assert!(app.loclist_open());
assert!(!app.quickfix_open());
app.loclist.next();
assert_eq!(app.loclist.cursor(), 1);
assert_eq!(app.quickfix.cursor(), 0, "quickfix list untouched");
app.handle_loclist_command(QfCommand::Close);
assert!(!app.loclist_open());
}
#[test]
fn loclist_next_jumps_and_dispatch_routes() {
let dir = std::env::temp_dir().join(format!("hjkl_ll_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("sample.txt");
std::fs::write(&file, "line zero\nline one\nline two\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.loclist.set(vec![entry(&file, 0), entry(&file, 2)]);
app.handle_loclist_command(QfCommand::Next);
assert_eq!(app.loclist.cursor(), 1);
assert_eq!(app.active_editor().cursor().0, 2);
app.dispatch_action(crate::keymap_actions::AppAction::LoclistPrev, 1);
assert_eq!(app.loclist.cursor(), 0);
assert_eq!(app.active_editor().cursor().0, 0);
let _ = std::fs::remove_dir_all(&dir);
}
fn make_app_with_buffer() -> App {
let mut app = App::new(None, false, None, None).unwrap();
let content = "line1\nline2\nline3\nline4\nline5\nline6\n";
app.active_editor_mut().set_content(content);
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app
}
#[test]
fn cexpr_populates_list_and_jumps_to_first() {
let mut app = make_app_with_buffer();
app.handle_quickfix_command(QfCommand::Expr {
text: r#""3:2:three\n5:1:five""#.to_string(),
append: false,
jump: true,
});
assert_eq!(app.quickfix.len(), 2, "should have 2 entries");
assert_eq!(app.quickfix.cursor(), 0);
let e0 = app.quickfix.current().unwrap();
assert_eq!(e0.row, 2, "entry 0 row should be 2 (0-based)");
assert_eq!(e0.col, 1, "entry 0 col should be 1 (0-based)");
assert_eq!(
app.active_editor().cursor().0,
2,
"editor row should be at first entry (row 2)"
);
assert_eq!(
app.active_editor().cursor().1,
1,
"editor col should be at first entry (col 1)"
);
}
#[test]
fn cexpr_then_cnext_moves_to_second_entry() {
let mut app = make_app_with_buffer();
app.handle_quickfix_command(QfCommand::Expr {
text: r#""3:2:three\n5:1:five""#.to_string(),
append: false,
jump: true,
});
app.handle_quickfix_command(QfCommand::Next);
assert_eq!(app.quickfix.cursor(), 1);
let e1 = app.quickfix.current().unwrap();
assert_eq!(e1.row, 4, "entry 1 row should be 4 (0-based from line 5)");
assert_eq!(e1.col, 0, "entry 1 col should be 0 (0-based from col 1)");
assert_eq!(
app.active_editor().cursor().0,
4,
"editor row should be at second entry (row 4)"
);
}
#[test]
fn cgetexpr_populates_but_does_not_jump() {
let mut app = make_app_with_buffer();
let initial_row = app.active_editor().cursor().0;
app.handle_quickfix_command(QfCommand::Expr {
text: r#""3:2:three""#.to_string(),
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 1, "list should have 1 entry");
assert_eq!(
app.active_editor().cursor().0,
initial_row,
"cgetexpr must not move cursor"
);
}
#[test]
fn caddexpr_appends_to_existing_list() {
let mut app = make_app_with_buffer();
app.handle_quickfix_command(QfCommand::Expr {
text: r#""2:0:two""#.to_string(),
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 1);
app.handle_quickfix_command(QfCommand::Expr {
text: r#""4:1:four""#.to_string(),
append: true,
jump: false,
});
assert_eq!(app.quickfix.len(), 2, "caddexpr should append to list");
assert_eq!(app.quickfix.cursor(), 0);
}
fn make_app_with_cbuffer_content() -> App {
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut().set_content("1:1:a\n2:1:b\n3:1:c\n");
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app
}
#[test]
fn cbuffer_populates_list_and_jumps_to_first() {
let mut app = make_app_with_cbuffer_content();
app.handle_quickfix_command(QfCommand::FromBuffer {
append: false,
jump: true,
});
assert_eq!(app.quickfix.len(), 3, "cbuffer should produce 3 entries");
assert_eq!(app.quickfix.cursor(), 0);
let e0 = app.quickfix.current().unwrap();
assert_eq!(e0.row, 0, "first entry row should be 0");
assert_eq!(e0.col, 0, "first entry col should be 0");
assert_eq!(
app.active_editor().cursor().0,
0,
"editor row should be at first entry (row 0)"
);
}
#[test]
fn cbuffer_then_cnext_moves_to_second_entry() {
let mut app = make_app_with_cbuffer_content();
app.handle_quickfix_command(QfCommand::FromBuffer {
append: false,
jump: true,
});
app.handle_quickfix_command(QfCommand::Next);
assert_eq!(app.quickfix.cursor(), 1);
let e1 = app.quickfix.current().unwrap();
assert_eq!(e1.row, 1, "second entry row should be 1");
assert_eq!(
app.active_editor().cursor().0,
1,
"editor row should be at second entry (row 1)"
);
}
#[test]
fn cgetbuffer_populates_but_does_not_jump() {
let mut app = make_app_with_cbuffer_content();
let initial_row = app.active_editor().cursor().0;
app.handle_quickfix_command(QfCommand::FromBuffer {
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 3, "cgetbuffer should produce 3 entries");
assert_eq!(
app.active_editor().cursor().0,
initial_row,
"cgetbuffer must not move cursor"
);
}
#[test]
fn caddbuffer_appends_to_existing_list() {
let mut app = make_app_with_cbuffer_content();
app.handle_quickfix_command(QfCommand::FromBuffer {
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 3);
app.active_editor_mut().set_content("4:1:d\n5:1:e\n");
app.handle_quickfix_command(QfCommand::FromBuffer {
append: true,
jump: false,
});
assert_eq!(
app.quickfix.len(),
5,
"caddbuffer should append to existing list"
);
assert_eq!(app.quickfix.cursor(), 0);
}
#[test]
fn cfile_populates_list_from_disk_and_jumps() {
let dir = std::env::temp_dir().join(format!("hjkl_cfile_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let errfile = dir.join("errors.txt");
let errfile_path = errfile.to_str().unwrap().to_string();
std::fs::write(&errfile, "1:1:alpha\n2:1:beta\n3:1:gamma\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app.handle_quickfix_command(QfCommand::FromFile {
path: errfile_path,
append: false,
jump: true,
});
assert_eq!(app.quickfix.len(), 3, "cfile should produce 3 entries");
let e0 = app.quickfix.current().unwrap();
assert_eq!(e0.row, 0, "first entry row should be 0");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cfile_missing_path_reports_error() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app.handle_quickfix_command(QfCommand::FromFile {
path: "/nonexistent/path/errors.err".to_string(),
append: false,
jump: false,
});
assert_eq!(
app.quickfix.len(),
0,
"cfile on missing file leaves list empty"
);
}
fn buf_row(app: &App, row: usize) -> String {
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), row)
}
#[test]
fn cdo_runs_command_per_entry() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut()
.set_content("alpha\nbeta\ngamma\ndelta\nepsilon\n");
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app.handle_quickfix_command(QfCommand::Expr {
text: r#""2:1:a\n4:1:b""#.to_string(),
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 2, "should have 2 entries");
app.handle_quickfix_command(QfCommand::Do {
cmd: "s/^/X/".to_string(),
per_file: false,
});
assert_eq!(buf_row(&app, 0), "alpha", "row 0 must be untouched");
assert!(
buf_row(&app, 1).starts_with('X'),
"row 1 must start with X, got: {:?}",
buf_row(&app, 1)
);
assert_eq!(buf_row(&app, 2), "gamma", "row 2 must be untouched");
assert!(
buf_row(&app, 3).starts_with('X'),
"row 3 must start with X, got: {:?}",
buf_row(&app, 3)
);
assert_eq!(buf_row(&app, 4), "epsilon", "row 4 must be untouched");
}
#[test]
fn cfdo_runs_once_per_file() {
let dir = std::env::temp_dir().join(format!("hjkl_cfdo_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file_a = dir.join("a.txt");
let file_b = dir.join("b.txt");
std::fs::write(&file_a, "alpha\nbeta\ngamma\ndelta\n").unwrap();
std::fs::write(&file_b, "uno\ndos\ntres\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.quickfix.set(vec![
QfEntry {
path: file_a.clone(),
row: 0,
col: 0,
kind: QfKind::Grep,
message: "a0".into(),
},
QfEntry {
path: file_a.clone(),
row: 1,
col: 0,
kind: QfKind::Grep,
message: "a1".into(),
},
QfEntry {
path: file_b.clone(),
row: 0,
col: 0,
kind: QfKind::Grep,
message: "b0".into(),
},
]);
app.handle_quickfix_command(QfCommand::Do {
cmd: "s/^/Z/".to_string(),
per_file: true,
});
app.dispatch_ex(&format!("e {}", file_a.display()));
let row0_a = buf_row(&app, 0);
let row1_a = buf_row(&app, 1);
assert!(
row0_a.starts_with('Z'),
"fileA row 0 must start with Z (cfdo visited first entry), got: {row0_a:?}"
);
assert!(
!row1_a.starts_with('Z'),
"fileA row 1 must NOT start with Z (second entry skipped by cfdo), got: {row1_a:?}"
);
app.dispatch_ex(&format!("e {}", file_b.display()));
let row0_b = buf_row(&app, 0);
assert!(
row0_b.starts_with('Z'),
"fileB row 0 must start with Z (cfdo visited its single entry), got: {row0_b:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn populate_expr(app: &mut App, text: &str) {
app.handle_quickfix_command(QfCommand::Expr {
text: text.to_string(),
append: false,
jump: false,
});
}
#[test]
fn colder_restores_previous_list() {
let mut app = make_app_with_buffer();
populate_expr(&mut app, r#""2:1:a""#);
let entries_a: Vec<_> = app.quickfix.entries().to_vec();
populate_expr(&mut app, r#""4:1:b""#);
let entries_b: Vec<_> = app.quickfix.entries().to_vec();
assert_eq!(app.quickfix.entries(), entries_b.as_slice());
app.handle_quickfix_command(QfCommand::Older(1));
assert_eq!(
app.quickfix.entries(),
entries_a.as_slice(),
"colder should restore list A"
);
assert!(
app.quickfix_older.is_empty(),
"older should be empty after single colder"
);
assert_eq!(app.quickfix_newer.len(), 1, "newer should hold list B");
app.handle_quickfix_command(QfCommand::Newer(1));
assert_eq!(
app.quickfix.entries(),
entries_b.as_slice(),
"cnewer should restore list B"
);
assert!(
app.quickfix_newer.is_empty(),
"newer should be empty after cnewer"
);
}
#[test]
fn populate_while_older_discards_newer_branch() {
let mut app = make_app_with_buffer();
populate_expr(&mut app, r#""2:1:a""#);
let entries_a: Vec<_> = app.quickfix.entries().to_vec();
populate_expr(&mut app, r#""4:1:b""#);
app.handle_quickfix_command(QfCommand::Older(1));
assert_eq!(app.quickfix.entries(), entries_a.as_slice());
assert_eq!(app.quickfix_newer.len(), 1);
populate_expr(&mut app, r#""6:1:c""#);
assert!(
app.quickfix_newer.is_empty(),
"newer branch must be cleared on fresh populate"
);
let entries_c: Vec<_> = app.quickfix.entries().to_vec();
app.handle_quickfix_command(QfCommand::Newer(1));
assert_eq!(
app.quickfix.entries(),
entries_c.as_slice(),
"cnewer should be no-op when newer is empty"
);
}
#[test]
fn colder_saturates_when_no_older() {
let mut app = make_app_with_buffer();
populate_expr(&mut app, r#""2:1:a""#);
let entries_a: Vec<_> = app.quickfix.entries().to_vec();
app.handle_quickfix_command(QfCommand::Older(1));
assert_eq!(
app.quickfix.entries(),
entries_a.as_slice(),
"colder on oldest list should not change current"
);
}
#[test]
fn cnewer_saturates_when_no_newer() {
let mut app = make_app_with_buffer();
populate_expr(&mut app, r#""2:1:a""#);
let entries_a: Vec<_> = app.quickfix.entries().to_vec();
app.handle_quickfix_command(QfCommand::Newer(1));
assert_eq!(
app.quickfix.entries(),
entries_a.as_slice(),
"cnewer on newest list should not change current"
);
}
#[test]
fn loclist_colder_cnewer_work_independently() {
let mut app = make_app_with_buffer();
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app.handle_loclist_command(QfCommand::Expr {
text: r#""2:1:la""#.to_string(),
append: false,
jump: false,
});
let loc_a: Vec<_> = app.loclist.entries().to_vec();
app.handle_loclist_command(QfCommand::Expr {
text: r#""4:1:lb""#.to_string(),
append: false,
jump: false,
});
let loc_b: Vec<_> = app.loclist.entries().to_vec();
assert!(app.quickfix.is_empty());
app.handle_loclist_command(QfCommand::Older(1));
assert_eq!(app.loclist.entries(), loc_a.as_slice());
app.handle_loclist_command(QfCommand::Newer(1));
assert_eq!(app.loclist.entries(), loc_b.as_slice());
}
fn make_lsp_diag(
start_row: usize,
start_col: usize,
severity: DiagSeverity,
message: &str,
) -> LspDiag {
LspDiag {
start_row,
start_col,
end_row: start_row,
end_col: start_col + message.len(),
severity,
message: message.to_string(),
source: None,
code: None,
}
}
#[test]
fn diagnostics_populates_quickfix_from_all_buffers() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().lsp_diags = vec![
make_lsp_diag(5, 3, DiagSeverity::Error, "type mismatch"),
make_lsp_diag(2, 0, DiagSeverity::Hint, "consider renaming"),
make_lsp_diag(2, 10, DiagSeverity::Warning, "unused variable"),
];
app.handle_quickfix_command(QfCommand::Diagnostics);
assert_eq!(app.quickfix.len(), 3, "quickfix should have 3 entries");
assert!(
app.quickfix_open(),
"dock should be open when diags present"
);
let entries = app.quickfix.entries();
assert_eq!(entries[0].row, 2);
assert_eq!(entries[0].col, 0);
assert_eq!(entries[0].kind, QfKind::Note, "Hint → Note");
assert_eq!(entries[1].row, 2);
assert_eq!(entries[1].col, 10);
assert_eq!(entries[1].kind, QfKind::Warning, "Warning → Warning");
assert_eq!(entries[2].row, 5);
assert_eq!(entries[2].col, 3);
assert_eq!(entries[2].kind, QfKind::Error, "Error → Error");
assert!(app.loclist.is_empty(), "loclist must not be touched");
}
#[test]
fn ldiagnostics_uses_current_buffer_only() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().lsp_diags = vec![
make_lsp_diag(1, 0, DiagSeverity::Info, "info message"),
make_lsp_diag(0, 5, DiagSeverity::Warning, "warn here"),
];
app.handle_loclist_command(QfCommand::Diagnostics);
assert_eq!(app.loclist.len(), 2, "loclist should have 2 entries");
assert!(app.loclist_open(), "loclist dock should be open");
let entries = app.loclist.entries();
assert_eq!(entries[0].row, 0);
assert_eq!(entries[0].col, 5);
assert_eq!(entries[0].kind, QfKind::Warning);
assert_eq!(entries[1].row, 1);
assert_eq!(entries[1].col, 0);
assert_eq!(entries[1].kind, QfKind::Info);
assert!(app.quickfix.is_empty(), "quickfix must not be touched");
assert!(!app.quickfix_open(), "quickfix dock must not be open");
}
#[test]
fn diagnostics_empty_no_dock() {
let mut app = App::new(None, false, None, None).unwrap();
app.handle_quickfix_command(QfCommand::Diagnostics);
assert!(app.quickfix.is_empty(), "list must remain empty");
assert!(!app.quickfix_open(), "dock must stay closed when no diags");
}
#[test]
fn cwindow_opens_on_entries_closes_on_empty() {
let mut app = App::new(None, false, None, None).unwrap();
let toasts_before = app.bus.history().count();
app.handle_quickfix_command(QfCommand::Window);
assert!(
!app.quickfix_open(),
":cwindow on an empty list must not open"
);
assert_eq!(
app.bus.history().count(),
toasts_before,
":cwindow on an empty list is silent in vim"
);
let p = std::path::PathBuf::from("x.rs");
app.quickfix.set(vec![entry(&p, 0)]);
app.handle_quickfix_command(QfCommand::Window);
assert!(
app.quickfix_open(),
":cwindow must open when the list has entries"
);
app.quickfix.set(vec![]);
app.handle_quickfix_command(QfCommand::Window);
assert!(
!app.quickfix_open(),
":cwindow must close the dock when the list is empty"
);
}
#[test]
fn lwindow_opens_on_entries_closes_on_empty() {
let mut app = App::new(None, false, None, None).unwrap();
app.handle_loclist_command(QfCommand::Window);
assert!(!app.loclist_open());
let p = std::path::PathBuf::from("x.rs");
app.loclist.set(vec![entry(&p, 0)]);
app.handle_loclist_command(QfCommand::Window);
assert!(app.loclist_open());
app.loclist.set(vec![]);
app.handle_loclist_command(QfCommand::Window);
assert!(!app.loclist_open());
}
fn make_app_with_qf_files() -> (App, std::path::PathBuf, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let file_a = dir.path().join("a.txt");
let file_b = dir.path().join("b.txt");
std::fs::write(&file_a, "a-line0\na-line1\na-line2\n").unwrap();
std::fs::write(&file_b, "b-line0\nb-line1\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.quickfix.set(vec![
QfEntry {
path: file_a.clone(),
row: 0,
col: 0,
kind: QfKind::Grep,
message: "first hit".into(),
},
QfEntry {
path: file_a.clone(),
row: 1,
col: 2,
kind: QfKind::Grep,
message: "second hit".into(),
},
QfEntry {
path: file_b.clone(),
row: 0,
col: 0,
kind: QfKind::Grep,
message: "third hit".into(),
},
]);
(app, file_a, dir)
}
#[test]
fn copen_creates_bottom_dock_with_matching_buffer_lines() {
let (mut app, file_a, _dir) = make_app_with_qf_files();
app.handle_quickfix_command(QfCommand::Open);
let dock = app.bottom_dock.as_ref().expect("bottom dock must be open");
assert_eq!(dock.kind, crate::app::dock::DockKind::Quickfix);
assert_eq!(
app.focused_window(),
dock.win_id,
":copen must focus the dock"
);
let rope = app.active_editor().buffer().rope();
assert_eq!(rope.len_lines(), 3, "one line per entry");
let line0 = hjkl_buffer::rope_line_str(&rope, 0);
let line1 = hjkl_buffer::rope_line_str(&rope, 1);
assert_eq!(
line0,
format!("{}:1:1 first hit", file_a.display()),
"line/col must be rendered 1-based, merged into the path column"
);
assert_eq!(line1, format!("{}:2:3 second hit", file_a.display()));
assert_eq!(
app.window_cursor(dock.win_id).0,
0,
"cursor starts at entry 0"
);
}
#[test]
fn dock_j_k_move_a_real_cursor_without_touching_the_list_cursor() {
let (mut app, _file_a, _dir) = make_app_with_qf_files();
app.handle_quickfix_command(QfCommand::Open);
let dock_win = app.bottom_dock.as_ref().unwrap().win_id;
super::macro_key_seq(&mut app, &[super::ck('j')]);
assert_eq!(
app.window_cursor(dock_win).0,
1,
"j must move the dock cursor"
);
super::macro_key_seq(&mut app, &[super::ck('j')]);
assert_eq!(app.window_cursor(dock_win).0, 2);
super::macro_key_seq(&mut app, &[super::ck('k')]);
assert_eq!(app.window_cursor(dock_win).0, 1);
assert_eq!(
app.quickfix.cursor(),
0,
"list cursor must stay put until <CR>"
);
}
#[test]
fn dock_yy_yanks_the_entry_line() {
let (mut app, file_a, _dir) = make_app_with_qf_files();
app.handle_quickfix_command(QfCommand::Open);
super::macro_key_seq(&mut app, &[super::ck('y'), super::ck('y')]);
let yanked = app
.active_editor()
.with_registers(|r| r.read('"').map(|s| s.text.clone()))
.unwrap_or_default();
assert_eq!(
yanked.trim_end_matches('\n'),
format!("{}:1:1 first hit", file_a.display()),
"yy must yank the exact rendered entry line, got {yanked:?}"
);
}
#[test]
fn dock_enter_jumps_to_entry_and_focus_lands_on_a_regular_window() {
let (mut app, file_a, _dir) = make_app_with_qf_files();
app.handle_quickfix_command(QfCommand::Open);
let dock_win = app.bottom_dock.as_ref().unwrap().win_id;
super::macro_key_seq(&mut app, &[super::ck('j')]);
super::macro_key_seq(&mut app, &[super::key(crossterm::event::KeyCode::Enter)]);
assert_eq!(
app.quickfix.cursor(),
1,
"<CR> must commit to the list cursor"
);
assert_ne!(
app.focused_window(),
dock_win,
"<CR> must move focus OFF the dock"
);
assert!(
app.window_is_regular(app.focused_window()),
"the jump target must be a regular window"
);
assert_eq!(app.active().filename.as_deref(), Some(file_a.as_path()));
assert_eq!(app.active_editor().cursor(), (1, 2));
assert!(
app.bottom_dock.is_some(),
"the dock itself must remain open after the jump (vim parity)"
);
}
#[test]
fn dock_buffer_is_readonly_and_rejects_edits() {
let (mut app, _file_a, _dir) = make_app_with_qf_files();
app.handle_quickfix_command(QfCommand::Open);
let before = app.active_editor().buffer().rope().to_string();
super::macro_key_seq(&mut app, &[super::ck('x')]);
assert_eq!(
app.active_editor().buffer().rope().to_string(),
before,
"x must not delete a character in the readonly dock"
);
super::macro_key_seq(&mut app, &[super::ck('d'), super::ck('d')]);
assert_eq!(
app.active_editor().buffer().rope().to_string(),
before,
"dd must not delete a line in the readonly dock"
);
super::macro_key_seq(
&mut app,
&[
super::ck('i'),
super::ck('X'),
super::key(crossterm::event::KeyCode::Esc),
],
);
assert_eq!(
app.active_editor().buffer().rope().to_string(),
before,
"insert must not add a character in the readonly dock"
);
}
#[test]
fn cnext_syncs_the_dock_cursor_row() {
let (mut app, _file_a, _dir) = make_app_with_qf_files();
app.handle_quickfix_command(QfCommand::Open);
let dock_win = app.bottom_dock.as_ref().unwrap().win_id;
assert_eq!(app.window_cursor(dock_win).0, 0);
app.handle_quickfix_command(QfCommand::Next);
assert_eq!(app.quickfix.cursor(), 1);
assert_eq!(
app.window_cursor(dock_win).0,
1,
":cnext must move the dock's cursor to the new current entry"
);
}
#[test]
fn lopen_reuses_the_open_quickfix_dock() {
let (mut app, _file_a, _dir) = make_app_with_qf_files();
app.handle_quickfix_command(QfCommand::Open);
let qf_dock_win = app.bottom_dock.as_ref().unwrap().win_id;
let p = std::path::PathBuf::from("loc.rs");
app.loclist.set(vec![entry(&p, 4)]);
app.handle_loclist_command(QfCommand::Open);
let dock = app.bottom_dock.as_ref().expect("dock must still be open");
assert_eq!(dock.win_id, qf_dock_win, "the SAME window/slot is reused");
assert_eq!(dock.kind, crate::app::dock::DockKind::Loclist);
assert!(!app.quickfix_open());
assert!(app.loclist_open());
let rope = app.active_editor().buffer().rope();
assert_eq!(rope.len_lines(), 1, "buffer now shows the loclist's entry");
let line0 = hjkl_buffer::rope_line_str(&rope, 0);
assert_eq!(line0, "loc.rs:5:1 hit at 4");
}
#[test]
fn dock_rebuild_handles_a_2000_entry_list_without_hanging() {
let mut app = App::new(None, false, None, None).unwrap();
let n = 2000;
let entries: Vec<QfEntry> = (0..n)
.map(|i| QfEntry {
path: std::path::PathBuf::from(format!("src/file_{i}.perf-smoke-ext")),
row: i % 500,
col: i % 80,
kind: QfKind::Grep,
message: format!("perf smoke entry {i}"),
})
.collect();
app.quickfix.set(entries);
app.handle_quickfix_command(QfCommand::Open);
let dock = app.bottom_dock.as_ref().expect("bottom dock must be open");
assert_eq!(dock.kind, crate::app::dock::DockKind::Quickfix);
let rope = app.active_editor().buffer().rope();
assert_eq!(rope.len_lines(), n, "every entry must render a line");
let last = hjkl_buffer::rope_line_str(&rope, n - 1);
assert!(
last.contains(&format!("perf smoke entry {}", n - 1)),
"last entry must be fully rendered too, got {last:?}"
);
}