use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness, HarnessOptions};
use crate::common::tracing::init_tracing_from_env;
use crossterm::event::{KeyCode, KeyModifiers};
use fresh::config::Config;
use fresh::config_io::DirectoryContext;
use std::fs;
fn setup_search_replace_project() -> (tempfile::TempDir, std::path::PathBuf) {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_root = temp_dir.path().join("project_root");
fs::create_dir(&project_root).unwrap();
let plugins_dir = project_root.join("plugins");
fs::create_dir(&plugins_dir).unwrap();
copy_plugin_lib(&plugins_dir);
copy_plugin(&plugins_dir, "search_replace");
(temp_dir, project_root)
}
fn create_test_files(project_root: &std::path::Path) {
fs::write(
project_root.join("alpha.txt"),
"hello world\nfoo bar\nhello again\n",
)
.unwrap();
fs::write(
project_root.join("beta.txt"),
"hello from beta\nno match here\n",
)
.unwrap();
fs::write(
project_root.join("gamma.txt"),
"nothing relevant\njust filler\n",
)
.unwrap();
}
fn open_search_replace_via_palette(harness: &mut EditorTestHarness) {
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Search and Replace").unwrap();
harness
.wait_until(|h| {
let s = h.screen_to_string();
s.contains("Search and Replace") || s.contains("Search & Replace")
})
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
}
fn enter_search_and_replace(harness: &mut EditorTestHarness, search: &str, replace: &str) {
harness
.wait_until(|h| h.screen_to_string().contains("Search:"))
.unwrap();
harness.type_text(search).unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness.type_text(replace).unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
}
fn confirm_replace_all(harness: &mut EditorTestHarness) {
harness.send_key(KeyCode::Enter, KeyModifiers::ALT).unwrap();
harness.wait_for_prompt().unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.wait_for_prompt_closed().unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Replaced"))
.unwrap();
}
#[test]
fn test_search_replace_plugin_loads() {
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("alpha.txt");
let mut harness =
EditorTestHarness::with_config_and_working_dir(120, 30, Default::default(), project_root)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.type_text("Search and Replace").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Search and Replace"))
.unwrap();
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness.render().unwrap();
}
#[test]
fn test_search_replace_shows_results_panel() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("gamma.txt");
let mut harness =
EditorTestHarness::with_config_and_working_dir(120, 30, Default::default(), project_root)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "goodbye");
harness
.wait_until(|h| {
let s = h.screen_to_string();
s.contains("[v]") && s.contains("alpha.txt") && s.contains("beta.txt")
})
.unwrap();
let screen = harness.screen_to_string();
assert!(
!screen.contains("gamma.txt ("),
"gamma.txt should not appear in match results. Screen:\n{}",
screen
);
}
#[test]
fn test_search_replace_toggle_selection() {
let (_temp_dir, project_root) = setup_search_replace_project();
fs::write(
project_root.join("only.txt"),
"apple orange\napple banana\n",
)
.unwrap();
let start_file = project_root.join("only.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
120,
30,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "apple", "pear");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("[v]") && s.contains("only.txt")
})
.unwrap();
harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char(' '), KeyModifiers::NONE)
.unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("[ ]"))
.unwrap();
let screen = harness.screen_to_string();
assert!(
screen.contains("[ ]") && screen.contains("[v]"),
"Should have one deselected and one selected item. Screen:\n{}",
screen
);
}
#[test]
fn test_search_replace_escape_closes_panel() {
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("alpha.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
120,
30,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "NOPE");
harness
.wait_until(|h| h.screen_to_string().contains("Search/Replace"))
.unwrap();
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness
.wait_until(|h| !h.screen_to_string().contains("*Search/Replace*"))
.unwrap();
let alpha = fs::read_to_string(project_root.join("alpha.txt")).unwrap();
assert!(
alpha.contains("hello"),
"alpha.txt should be unchanged after Escape. Got:\n{}",
alpha
);
}
#[test]
fn test_search_replace_no_matches() {
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("alpha.txt");
let mut harness =
EditorTestHarness::with_config_and_working_dir(120, 30, Default::default(), project_root)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "ZZZZNOTFOUND", "whatever");
harness
.wait_until(|h| h.screen_to_string().contains("No matches"))
.unwrap();
}
#[test]
fn test_search_replace_cancel_at_search_field() {
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("alpha.txt");
let mut harness =
EditorTestHarness::with_config_and_working_dir(120, 30, Default::default(), project_root)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("Search:"))
.unwrap();
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness
.wait_until(|h| !h.screen_to_string().contains("*Search/Replace*"))
.unwrap();
}
#[test]
fn test_search_replace_escape_always_closes() {
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("alpha.txt");
let mut harness =
EditorTestHarness::with_config_and_working_dir(120, 30, Default::default(), project_root)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("Search:"))
.unwrap();
harness.type_text("hello").unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness
.wait_until(|h| !h.screen_to_string().contains("*Search/Replace*"))
.unwrap();
}
#[test]
fn test_search_replace_executes_replacement() {
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("gamma.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
120,
30,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "goodbye");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
confirm_replace_all(&mut harness);
let alpha = fs::read_to_string(project_root.join("alpha.txt")).unwrap();
assert!(
alpha.contains("goodbye") && !alpha.contains("hello"),
"alpha.txt should have 'hello' replaced with 'goodbye'. Got:\n{}",
alpha
);
let beta = fs::read_to_string(project_root.join("beta.txt")).unwrap();
assert!(
beta.contains("goodbye") && !beta.contains("hello"),
"beta.txt should have 'hello' replaced. Got:\n{}",
beta
);
let gamma = fs::read_to_string(project_root.join("gamma.txt")).unwrap();
assert_eq!(gamma, "nothing relevant\njust filler\n");
}
#[test]
fn test_search_replace_delete_pattern() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
fs::write(project_root.join("target.txt"), "remove_me stays\n").unwrap();
let start_file = project_root.join("target.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
120,
30,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("Search:"))
.unwrap();
harness.type_text("remove_me").unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
confirm_replace_all(&mut harness);
let content = fs::read_to_string(project_root.join("target.txt")).unwrap();
assert_eq!(
content, " stays\n",
"remove_me should be deleted. Got: {:?}",
content
);
}
#[test]
fn test_search_replace_multiple_matches_same_line() {
init_tracing_from_env();
let start = std::time::Instant::now();
let elapsed = || format!("{:.1}s", start.elapsed().as_secs_f64());
eprintln!(
"[DEBUG {}] test_search_replace_multiple_matches_same_line: starting",
elapsed()
);
let (_temp_dir, project_root) = setup_search_replace_project();
fs::write(project_root.join("multi.txt"), "aa bb aa cc aa\nno match\n").unwrap();
eprintln!("[DEBUG {}] project set up at {:?}", elapsed(), project_root);
let start_file = project_root.join("multi.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
120,
30,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
eprintln!("[DEBUG {}] file opened and initial render done", elapsed());
eprintln!(
"[DEBUG {}] screen after open:\n{}",
elapsed(),
harness.screen_to_string()
);
eprintln!("[DEBUG {}] opening command palette (Ctrl+P)", elapsed());
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
eprintln!("[DEBUG {}] command palette prompt is active", elapsed());
harness.type_text("Search and Replace").unwrap();
eprintln!(
"[DEBUG {}] typed 'Search and Replace' into palette",
elapsed()
);
harness
.wait_until(|h| {
let s = h.screen_to_string();
s.contains("Search and Replace") || s.contains("Search & Replace")
})
.unwrap();
eprintln!(
"[DEBUG {}] palette shows Search and Replace option",
elapsed()
);
eprintln!(
"[DEBUG {}] screen:\n{}",
elapsed(),
harness.screen_to_string()
);
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
eprintln!("[DEBUG {}] pressed Enter on palette item", elapsed());
eprintln!("[DEBUG {}] waiting for Search: field", elapsed());
{
let mut wait_iters = 0u64;
harness
.wait_until(|h| {
wait_iters += 1;
if wait_iters % 20 == 0 {
eprintln!(
"[DEBUG wait_until Search:] iteration {}, screen:\n{}",
wait_iters,
h.screen_to_string()
);
}
h.screen_to_string().contains("Search:")
})
.unwrap();
}
eprintln!("[DEBUG {}] Search: field visible", elapsed());
harness.type_text("aa").unwrap();
harness.render().unwrap();
eprintln!("[DEBUG {}] typed search term 'aa'", elapsed());
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
eprintln!(
"[DEBUG {}] pressed Enter to move to replace field",
elapsed()
);
harness.type_text("ZZ").unwrap();
harness.render().unwrap();
eprintln!("[DEBUG {}] typed replace term 'ZZ'", elapsed());
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
eprintln!(
"[DEBUG {}] pressed Enter to confirm and run search",
elapsed()
);
eprintln!(
"[DEBUG {}] screen after search submitted:\n{}",
elapsed(),
harness.screen_to_string()
);
eprintln!(
"[DEBUG {}] waiting for search results (matches + [v]) and stability",
elapsed()
);
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
eprintln!("[DEBUG {}] search results populated and stable", elapsed());
eprintln!(
"[DEBUG {}] screen:\n{}",
elapsed(),
harness.screen_to_string()
);
eprintln!("[DEBUG {}] pressing Alt+Enter to Replace All", elapsed());
harness.send_key(KeyCode::Enter, KeyModifiers::ALT).unwrap();
harness.wait_for_prompt().unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.wait_for_prompt_closed().unwrap();
eprintln!(
"[DEBUG {}] Alt+Enter sent and confirmation accepted",
elapsed()
);
eprintln!("[DEBUG {}] waiting for 'Replaced' confirmation", elapsed());
{
let mut wait_iters = 0u64;
harness
.wait_until(|h| {
wait_iters += 1;
if wait_iters % 20 == 0 {
eprintln!(
"[DEBUG wait_until Replaced] iteration {}, screen:\n{}",
wait_iters,
h.screen_to_string()
);
}
h.screen_to_string().contains("Replaced")
})
.unwrap();
}
eprintln!("[DEBUG {}] replacement confirmed", elapsed());
let content = fs::read_to_string(project_root.join("multi.txt")).unwrap();
eprintln!("[DEBUG {}] multi.txt content: {:?}", elapsed(), content);
assert!(
content.contains("ZZ bb ZZ cc ZZ"),
"All occurrences on the line should be replaced. Got:\n{}",
content
);
assert!(
!content.contains("aa"),
"No 'aa' should remain. Got:\n{}",
content
);
eprintln!("[DEBUG {}] test PASSED", elapsed());
}
#[test]
fn test_search_replace_split_not_restored_across_restart() {
init_tracing_from_env();
let temp_dir = tempfile::TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project_root");
std::fs::create_dir(&project_dir).unwrap();
let plugins_dir = project_dir.join("plugins");
std::fs::create_dir(&plugins_dir).unwrap();
copy_plugin_lib(&plugins_dir);
copy_plugin(&plugins_dir, "search_replace");
let file = project_dir.join("persist.txt");
fs::write(
&file,
"UNIQUEMARKERPERSIST alpha\nUNIQUEMARKERPERSIST beta\n",
)
.unwrap();
let dir_context = DirectoryContext::for_testing(temp_dir.path());
{
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_config(Config::default())
.with_working_dir(project_dir.clone())
.with_shared_dir_context(dir_context.clone())
.without_empty_plugins_dir(),
)
.unwrap();
harness.open_file(&file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("Search:"))
.unwrap();
harness.shutdown(true).unwrap();
}
{
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_config(Config::default())
.with_working_dir(project_dir.clone())
.with_shared_dir_context(dir_context.clone())
.without_empty_plugins_dir(),
)
.unwrap();
let restored = harness.startup(true, &[]).unwrap();
assert!(restored, "Workspace should have restored");
harness.render().unwrap();
let screen = harness.screen_to_string();
assert!(
!screen.contains("*Search/Replace*"),
"*Search/Replace* panel should not be restored after restart.\n\
Screen:\n{}",
screen
);
let marker_occurrences = screen.matches("UNIQUEMARKERPERSIST alpha").count();
assert_eq!(
marker_occurrences, 1,
"persist.txt content should appear once (single split), not \
duplicated by a leftover split from the replaced panel.\n\
Screen:\n{}",
screen
);
}
}
#[test]
fn test_search_replace_undo_marks_buffer_as_modified() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
fs::write(project_root.join("dirty.txt"), "hello one\nhello two\n").unwrap();
let start_file = project_root.join("dirty.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
160,
40,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "XYZ");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
confirm_replace_all(&mut harness);
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness
.wait_until(|h| !h.screen_to_string().contains("*Search/Replace*"))
.unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("XYZ one"))
.unwrap();
assert!(
!harness.screen_to_string().contains("dirty.txt*"),
"Right after replace, dirty.txt buffer should match disk (no `*`).\n\
Screen:\n{}",
harness.screen_to_string()
);
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Undo").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Undo the last edit"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.wait_for_prompt_closed().unwrap();
harness
.wait_until(|h| {
let s = h.screen_to_string();
s.contains("hello one") && s.contains("hello two") && !s.contains("XYZ one")
})
.unwrap();
let on_disk = fs::read_to_string(project_root.join("dirty.txt")).unwrap();
assert_eq!(
on_disk, "XYZ one\nXYZ two\n",
"Undo must not modify disk — it only reverts the in-memory buffer."
);
let screen = harness.screen_to_string();
assert!(
screen.contains("dirty.txt*") || screen.contains("dirty.txt [+]"),
"After undo, dirty.txt should be marked modified (buffer != disk).\n\
Screen:\n{}",
screen
);
}
#[test]
fn test_search_replace_tab_x_button_closes_whole_split() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("alpha.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
160,
40,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("Search:"))
.unwrap();
let (panel_buffer, panel_split) = {
let editor = harness.editor();
let split_id = editor.effective_active_split();
let buffer_id = editor.active_buffer();
(buffer_id, split_id)
};
harness
.editor_mut()
.close_tab_in_split(panel_buffer, panel_split);
harness.render().unwrap();
let screen = harness.screen_to_string();
assert!(
!screen.contains("*Search/Replace*"),
"Panel should be gone after × close.\nScreen:\n{}",
screen
);
let alpha_tab_count = screen.matches("alpha.txt ×").count();
assert_eq!(
alpha_tab_count, 1,
"alpha.txt should appear as exactly one tab after × close — no \
leftover split with a duplicate alpha.txt pane.\nScreen:\n{}",
screen
);
}
#[test]
fn test_search_replace_reopen_after_close_works() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("alpha.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
160,
40,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("Search:"))
.unwrap();
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness
.wait_until(|h| !h.screen_to_string().contains("*Search/Replace*"))
.unwrap();
open_search_replace_via_palette(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("Search:"))
.unwrap();
assert!(
harness.screen_to_string().contains("*Search/Replace*"),
"Panel should reopen after having been closed."
);
}
#[test]
fn test_search_replace_close_buffer_after_replace_closes_split() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("alpha.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
160,
40,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "XYZ");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
confirm_replace_all(&mut harness);
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Close Buffer").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Close the current buffer"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.wait_for_prompt_closed().unwrap();
harness
.wait_until(|h| !h.screen_to_string().contains("*Search/Replace*"))
.unwrap();
let screen = harness.screen_to_string();
let alpha_tab_count = screen.matches("alpha.txt ×").count();
assert_eq!(
alpha_tab_count, 1,
"alpha.txt should appear as exactly one tab after closing the panel.\n\
Screen:\n{}",
screen
);
assert!(
!screen.contains("beta.txt ×"),
"beta.txt (auto-opened hidden buffer) must not end up as a tab.\n\
Screen:\n{}",
screen
);
assert!(
!screen.contains("gamma.txt ×"),
"gamma.txt must not end up as a tab.\nScreen:\n{}",
screen
);
}
#[test]
fn test_search_replace_panel_not_duplicated_in_tabs() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
create_test_files(&project_root);
let start_file = project_root.join("alpha.txt");
let mut harness =
EditorTestHarness::with_config_and_working_dir(160, 40, Default::default(), project_root)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("Search:"))
.unwrap();
let screen = harness.screen_to_string();
let tab_occurrences = screen.matches("*Search/Replace* ×").count();
assert_eq!(
tab_occurrences, 1,
"The *Search/Replace* buffer should have exactly one tab on screen \
(in its own split), not duplicated as a tab in the source split.\n\
Screen:\n{}",
screen
);
}
#[test]
fn test_search_replace_confirmation_prompt_cancel_leaves_files_untouched() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
let original = "hello world\nhello again\n";
fs::write(project_root.join("cancel.txt"), original).unwrap();
let start_file = project_root.join("cancel.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
120,
30,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "XYZ");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
harness.send_key(KeyCode::Enter, KeyModifiers::ALT).unwrap();
harness.wait_for_prompt().unwrap();
let prompt_screen = harness.screen_to_string();
assert!(
prompt_screen.contains("Undo only covers"),
"Confirmation prompt should warn about undo scope. Screen:\n{}",
prompt_screen
);
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness.wait_for_prompt_closed().unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Replacement cancelled"))
.unwrap();
let after = fs::read_to_string(project_root.join("cancel.txt")).unwrap();
assert_eq!(
after, original,
"File must be unchanged after cancelling the confirmation prompt."
);
}
#[test]
fn test_search_replace_undo_survives_auto_revert_poll() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
let file = project_root.join("auto_revert.txt");
fs::write(&file, "hello one\nhello two\n").unwrap();
let mut harness = EditorTestHarness::with_config_and_working_dir(
160,
40,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "XYZ");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
confirm_replace_all(&mut harness);
harness
.editor_mut()
.handle_file_changed(file.to_str().unwrap());
harness.render().unwrap();
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness
.wait_until(|h| !h.screen_to_string().contains("*Search/Replace*"))
.unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("XYZ one"))
.unwrap();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Undo").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Undo the last edit"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.wait_for_prompt_closed().unwrap();
harness
.wait_until(|h| {
let s = h.screen_to_string();
s.contains("hello one") && s.contains("hello two") && !s.contains("XYZ one")
})
.unwrap();
}
#[test]
fn test_search_replace_is_undoable_with_multiple_open_buffers() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
fs::write(project_root.join("multi_a.txt"), "hello A1\nhello A2\n").unwrap();
fs::write(
project_root.join("multi_b.txt"),
"hello B1\nhello B2\nhello B3\n",
)
.unwrap();
let mut harness = EditorTestHarness::with_config_and_working_dir(
160,
40,
Default::default(),
project_root.clone(),
)
.unwrap();
harness
.open_file(&project_root.join("multi_a.txt"))
.unwrap();
harness
.open_file(&project_root.join("multi_b.txt"))
.unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "XYZ");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
confirm_replace_all(&mut harness);
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness
.wait_until(|h| !h.screen_to_string().contains("*Search/Replace*"))
.unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("XYZ B1"))
.unwrap();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Undo").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Undo the last edit"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.wait_for_prompt_closed().unwrap();
harness
.wait_until(|h| {
let s = h.screen_to_string();
s.contains("hello B1")
&& s.contains("hello B2")
&& s.contains("hello B3")
&& !s.contains("XYZ B1")
})
.unwrap();
}
#[test]
fn test_search_replace_is_undoable_via_command_palette() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
fs::write(
project_root.join("undo.txt"),
"hello world\nhello there\nfinal hello line\n",
)
.unwrap();
let start_file = project_root.join("undo.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
120,
30,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "XYZ");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
confirm_replace_all(&mut harness);
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness
.wait_until(|h| !h.screen_to_string().contains("*Search/Replace*"))
.unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("XYZ world"))
.unwrap();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Undo").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Undo the last edit"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.wait_for_prompt_closed().unwrap();
harness
.wait_until(|h| {
let s = h.screen_to_string();
s.contains("hello world")
&& s.contains("hello there")
&& s.contains("final hello line")
&& !s.contains("XYZ world")
})
.unwrap();
}
#[test]
fn test_search_replace_refreshes_match_list_after_replace() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
fs::write(
project_root.join("refresh.txt"),
"hello world\nhello again\nhello there\n",
)
.unwrap();
let start_file = project_root.join("refresh.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
120,
30,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "XYZ");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
let before = harness.screen_to_string();
assert!(
before.contains("hello world"),
"Expected pre-replacement match context on screen. Got:\n{}",
before
);
confirm_replace_all(&mut harness);
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
!s.contains("hello world") && !s.contains("hello again") && !s.contains("hello there")
})
.unwrap();
let after = harness.screen_to_string();
assert!(
!after.contains("hello world"),
"Match list must not display stale 'hello world' row after replacement.\n{}",
after
);
assert!(
!after.contains("hello again"),
"Match list must not display stale 'hello again' row after replacement.\n{}",
after
);
}
#[test]
fn test_search_replace_second_alt_enter_does_not_corrupt_files() {
init_tracing_from_env();
let (_temp_dir, project_root) = setup_search_replace_project();
fs::write(
project_root.join("corrupt.txt"),
"hello world\nhello there\nthis is a hello test\nfinal hello line\n",
)
.unwrap();
let start_file = project_root.join("corrupt.txt");
let mut harness = EditorTestHarness::with_config_and_working_dir(
120,
30,
Default::default(),
project_root.clone(),
)
.unwrap();
harness.open_file(&start_file).unwrap();
harness.render().unwrap();
open_search_replace_via_palette(&mut harness);
enter_search_and_replace(&mut harness, "hello", "XYZ");
harness
.wait_until_stable(|h| {
let s = h.screen_to_string();
s.contains("matches") && s.contains("[v]")
})
.unwrap();
confirm_replace_all(&mut harness);
let after_first = fs::read_to_string(project_root.join("corrupt.txt")).unwrap();
assert_eq!(
after_first, "XYZ world\nXYZ there\nthis is a XYZ test\nfinal XYZ line\n",
"First Alt+Enter should produce clean replacements. Got:\n{}",
after_first
);
harness
.wait_until_stable(|h| !h.screen_to_string().contains("Replacing"))
.unwrap();
harness.send_key(KeyCode::Enter, KeyModifiers::ALT).unwrap();
harness
.wait_until_stable(|h| h.screen_to_string().contains("Search:"))
.unwrap();
let after_second = fs::read_to_string(project_root.join("corrupt.txt")).unwrap();
assert_eq!(
after_second, after_first,
"Second Alt+Enter must not modify the file further (no stale offsets). \
After first: {:?}\nAfter second: {:?}",
after_first, after_second
);
}