use crate::common::harness::{EditorTestHarness, HarnessOptions};
use crossterm::event::{KeyCode, KeyModifiers};
use fresh::config::Config;
#[test]
fn test_save_unnamed_buffer_shows_save_as_prompt() {
let mut harness = EditorTestHarness::new(100, 24).unwrap();
harness.new_buffer().unwrap();
harness.type_text("Hello world").unwrap();
harness.render().unwrap();
harness.assert_screen_contains("*");
harness
.send_key(KeyCode::Char('s'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("Save as:");
}
#[test]
fn test_quit_with_modified_buffers_shows_confirmation() {
let mut config = Config::default();
config.editor.hot_exit = false;
let mut harness = EditorTestHarness::with_config(100, 24, config).unwrap();
harness.type_text("Modified content").unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('q'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
assert!(
!harness.should_quit(),
"Editor should not quit immediately with unsaved changes"
);
}
#[test]
fn test_quit_without_modified_buffers() {
let mut harness = EditorTestHarness::new(100, 24).unwrap();
harness
.send_key(KeyCode::Char('q'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
assert!(
harness.should_quit(),
"Editor should quit when no modified buffers"
);
}
#[test]
fn test_quit_with_confirmation_discard() {
let mut config = Config::default();
config.editor.hot_exit = false;
let mut harness = EditorTestHarness::with_config(100, 24, config).unwrap();
harness.type_text("Modified").unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('q'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('d'), KeyModifiers::NONE)
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
assert!(harness.should_quit(), "Editor should quit after confirming");
}
#[test]
fn test_quit_prompt_offers_discard_when_hot_exit_enabled() {
let mut config = Config::default();
config.editor.hot_exit = true;
let mut harness = EditorTestHarness::with_temp_project_and_config(120, 24, config).unwrap();
let project_dir = harness.project_dir().unwrap();
let file_path = project_dir.join("notes.txt");
std::fs::write(&file_path, "initial\n").unwrap();
harness.open_file(&file_path).unwrap();
harness.type_text("oops").unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('q'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("(d)iscard and quit");
harness.assert_screen_contains("(q)uit (recoverable)");
}
#[test]
fn test_quit_with_discard_key_works_with_hot_exit() {
let mut config = Config::default();
config.editor.hot_exit = true;
let mut harness = EditorTestHarness::with_temp_project_and_config(120, 24, config).unwrap();
let project_dir = harness.project_dir().unwrap();
let file_path = project_dir.join("notes.txt");
std::fs::write(&file_path, "initial\n").unwrap();
harness.open_file(&file_path).unwrap();
harness.type_text("changes to throw away").unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('q'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('d'), KeyModifiers::NONE)
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
assert!(
harness.should_quit(),
"Editor should quit after pressing 'd' (discard) in hot_exit prompt"
);
}
#[test]
fn test_quit_save_chains_save_as_for_unnamed_buffer() {
let mut config = Config::default();
config.editor.hot_exit = false;
let mut harness = EditorTestHarness::with_temp_project_and_config(120, 24, config).unwrap();
let project_dir = harness.project_dir().unwrap();
harness.new_buffer().unwrap();
harness.type_text("scratch content").unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('q'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('s'), KeyModifiers::NONE)
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("Save as:");
assert!(
!harness.should_quit(),
"Editor should keep running until the unnamed buffer is named"
);
let target = project_dir.join("scratch.txt");
harness.type_text(target.to_str().unwrap()).unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
assert!(
harness.should_quit(),
"Editor should quit after the chained Save As completes"
);
let written = std::fs::read_to_string(&target).unwrap();
assert_eq!(written, "scratch content");
}
#[test]
fn test_quit_save_chains_save_as_for_multiple_unnamed_buffers() {
let mut config = Config::default();
config.editor.hot_exit = false;
let mut harness = EditorTestHarness::with_temp_project_and_config(120, 24, config).unwrap();
let project_dir = harness.project_dir().unwrap();
harness.new_buffer().unwrap();
harness.type_text("first scratch").unwrap();
harness.render().unwrap();
harness.new_buffer().unwrap();
harness.type_text("second scratch").unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('q'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('s'), KeyModifiers::NONE)
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("Save as:");
assert!(!harness.should_quit());
let first = project_dir.join("first.txt");
harness.type_text(first.to_str().unwrap()).unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("Save as:");
assert!(
!harness.should_quit(),
"Editor should keep prompting until every unnamed buffer is named"
);
let second = project_dir.join("second.txt");
harness.type_text(second.to_str().unwrap()).unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
assert!(
harness.should_quit(),
"Editor should finally quit once both unnamed buffers are saved"
);
let first_written = std::fs::read_to_string(&first).unwrap();
let second_written = std::fs::read_to_string(&second).unwrap();
let combined = format!("{first_written}|{second_written}");
assert!(
combined == "first scratch|second scratch" || combined == "second scratch|first scratch",
"Both unnamed buffers should be saved with distinct content. Got: {combined}"
);
}
#[test]
fn test_quit_save_chain_cancel_aborts_quit() {
let mut config = Config::default();
config.editor.hot_exit = false;
let mut harness = EditorTestHarness::with_temp_project_and_config(120, 24, config).unwrap();
harness.new_buffer().unwrap();
harness.type_text("draft").unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('q'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('s'), KeyModifiers::NONE)
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("Save as:");
harness.send_key(KeyCode::Esc, KeyModifiers::NONE).unwrap();
harness.render().unwrap();
assert!(
!harness.should_quit(),
"Cancelling the Save As during save-and-quit must keep the editor open"
);
}
#[test]
fn test_quit_with_confirmation_cancel() {
let mut config = Config::default();
config.editor.hot_exit = false;
let mut harness = EditorTestHarness::with_config(100, 24, config).unwrap();
harness.type_text("Modified").unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('q'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('c'), KeyModifiers::NONE)
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
assert!(
!harness.should_quit(),
"Editor should not quit after canceling"
);
}
#[test]
fn test_undo_restores_non_dirty_status() {
let mut harness = EditorTestHarness::new(100, 24).unwrap();
harness.render().unwrap();
let screen_before = harness.screen_to_string();
let tab_row: String = screen_before.lines().nth(1).unwrap_or("").to_string();
assert!(
!tab_row.contains('*'),
"New buffer should not show modified indicator"
);
harness.type_text("abc").unwrap();
harness.render().unwrap();
harness.assert_screen_contains("*");
harness
.send_key(KeyCode::Char('z'), KeyModifiers::CONTROL)
.unwrap();
harness
.send_key(KeyCode::Char('z'), KeyModifiers::CONTROL)
.unwrap();
harness
.send_key(KeyCode::Char('z'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
let screen_after = harness.screen_to_string();
let tab_row_after: String = screen_after.lines().nth(1).unwrap_or("").to_string();
assert!(
!tab_row_after.contains('*'),
"Buffer should not show modified indicator after undoing all changes"
);
}
#[test]
fn test_undo_after_save_modified_status() {
let mut harness = EditorTestHarness::with_temp_project(100, 24).unwrap();
let project_dir = harness.project_dir().unwrap();
let file_path = project_dir.join("test.txt");
std::fs::write(&file_path, "initial").unwrap();
harness.open_file(&file_path).unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
let tab_row: String = screen.lines().nth(1).unwrap_or("").to_string();
assert!(
!tab_row.contains('*'),
"Buffer should not be modified after opening"
);
harness.type_text("X").unwrap();
harness.render().unwrap();
harness.assert_screen_contains("*");
harness
.send_key(KeyCode::Char('s'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("Saved");
let screen_after_save = harness.screen_to_string();
let tab_row_after_save: String = screen_after_save.lines().nth(1).unwrap_or("").to_string();
assert!(
!tab_row_after_save.contains('*'),
"Buffer should not be modified after save"
);
harness.type_text("Y").unwrap();
harness.render().unwrap();
harness.assert_screen_contains("*");
harness
.send_key(KeyCode::Char('z'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
let screen_after_undo = harness.screen_to_string();
let tab_row_after_undo: String = screen_after_undo.lines().nth(1).unwrap_or("").to_string();
assert!(
!tab_row_after_undo.contains('*'),
"Buffer should not be modified after undoing to saved state"
);
}
#[test]
fn test_tabs_show_close_button() {
let mut harness = EditorTestHarness::new(100, 24).unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
assert!(screen.contains('×'), "Tab bar should show close button (×)");
}
#[test]
fn test_click_tab_close_button() {
use crate::common::harness::layout;
let mut harness = EditorTestHarness::new(100, 24).unwrap();
let temp_dir = tempfile::TempDir::new().unwrap();
let file1_path = temp_dir.path().join("first.txt");
let file2_path = temp_dir.path().join("to_close.txt");
std::fs::write(&file1_path, "First file content").unwrap();
std::fs::write(&file2_path, "UNIQUE_CONTENT_TO_CLOSE").unwrap();
harness.open_file(&file1_path).unwrap();
harness.render().unwrap();
harness.open_file(&file2_path).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("UNIQUE_CONTENT_TO_CLOSE");
let screen = harness.screen_to_string();
let tab_row: String = screen
.lines()
.nth(layout::TAB_BAR_ROW)
.unwrap_or("")
.to_string();
let tabs_before = tab_row.matches('×').count();
assert_eq!(tabs_before, 2, "Should have 2 tabs before close");
let x_positions: Vec<usize> = tab_row.match_indices('×').map(|(i, _)| i).collect();
let x_pos = x_positions[1];
harness
.mouse_click(x_pos as u16, layout::TAB_BAR_ROW as u16)
.unwrap();
harness.render().unwrap();
let screen_after = harness.screen_to_string();
assert!(
!screen_after.contains("UNIQUE_CONTENT_TO_CLOSE"),
"Content should no longer be visible after closing tab"
);
let tab_row_after: String = screen_after
.lines()
.nth(layout::TAB_BAR_ROW)
.unwrap_or("")
.to_string();
let tabs_after = tab_row_after.matches('×').count();
assert_eq!(tabs_after, 1, "Should have 1 tab after close");
}
#[test]
fn test_click_tab_close_button_modified_buffer() {
use crate::common::harness::layout;
let mut harness = EditorTestHarness::new(100, 24).unwrap();
harness.new_buffer().unwrap();
harness.type_text("Modified content").unwrap();
harness.render().unwrap();
harness.assert_screen_contains("*");
let screen = harness.screen_to_string();
let tab_row: String = screen
.lines()
.nth(layout::TAB_BAR_ROW)
.unwrap_or("")
.to_string();
if let Some(star_pos) = tab_row.find('*') {
if let Some(x_pos) = tab_row[star_pos..].find('×') {
let actual_x_pos = star_pos + x_pos;
harness
.mouse_click(actual_x_pos as u16, layout::TAB_BAR_ROW as u16)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("modified. (s)ave, (d)iscard, (C)ancel");
} else {
panic!("Could not find × close button after * in tab bar");
}
} else {
panic!("Could not find * modified indicator in tab bar");
}
}
#[test]
fn test_click_tab_close_modified_discard() {
use crate::common::harness::layout;
let mut harness = EditorTestHarness::new(100, 24).unwrap();
harness.new_buffer().unwrap();
harness.type_text("Will discard").unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
let tab_row: String = screen
.lines()
.nth(layout::TAB_BAR_ROW)
.unwrap_or("")
.to_string();
if let Some(star_pos) = tab_row.find('*') {
if let Some(x_pos) = tab_row[star_pos..].find('×') {
let actual_x_pos = star_pos + x_pos;
harness
.mouse_click(actual_x_pos as u16, layout::TAB_BAR_ROW as u16)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("modified. (s)ave, (d)iscard, (C)ancel");
harness
.send_key(KeyCode::Char('d'), KeyModifiers::NONE)
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("Buffer closed");
} else {
panic!("Could not find × close button after * in tab bar");
}
} else {
panic!("Could not find * modified indicator in tab bar");
}
}
#[test]
fn test_click_tab_close_modified_cancel() {
use crate::common::harness::layout;
let mut harness = EditorTestHarness::new(100, 24).unwrap();
harness.new_buffer().unwrap();
harness.type_text("Keep this").unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
let tab_row: String = screen
.lines()
.nth(layout::TAB_BAR_ROW)
.unwrap_or("")
.to_string();
if let Some(star_pos) = tab_row.find('*') {
if let Some(x_pos) = tab_row[star_pos..].find('×') {
let actual_x_pos = star_pos + x_pos;
harness
.mouse_click(actual_x_pos as u16, layout::TAB_BAR_ROW as u16)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("modified. (s)ave, (d)iscard, (C)ancel");
harness
.send_key(KeyCode::Char('c'), KeyModifiers::NONE)
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness.assert_screen_contains("Close cancelled");
harness.assert_screen_contains("Keep this");
} else {
panic!("Could not find × close button after * in tab bar");
}
} else {
panic!("Could not find * modified indicator in tab bar");
}
}
#[test]
fn test_next_buffer_skips_hidden_buffers() {
use fresh::primitives::text_property::TextPropertyEntry;
use fresh::services::plugins::api::PluginCommand;
use std::collections::HashMap;
let mut harness = EditorTestHarness::with_temp_project(100, 24).unwrap();
let project_dir = harness.project_dir().unwrap();
let file1_path = project_dir.join("visible1.txt");
let file2_path = project_dir.join("visible2.txt");
std::fs::write(&file1_path, "VISIBLE_BUFFER_1_CONTENT").unwrap();
std::fs::write(&file2_path, "VISIBLE_BUFFER_2_CONTENT").unwrap();
harness.open_file(&file1_path).unwrap();
harness.render().unwrap();
let hidden_cmd = PluginCommand::CreateVirtualBufferWithContent {
name: "*Hidden*".to_string(),
mode: "hidden-test".to_string(),
read_only: true,
entries: vec![TextPropertyEntry {
text: "HIDDEN_BUFFER_CONTENT".to_string(),
properties: HashMap::new(),
style: None,
inline_overlays: Vec::new(),
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}],
show_line_numbers: true,
show_cursors: true,
editing_disabled: true,
hidden_from_tabs: true, request_id: None,
};
harness
.editor_mut()
.handle_plugin_command(hidden_cmd)
.unwrap();
harness.render().unwrap();
harness.open_file(&file2_path).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("VISIBLE_BUFFER_2_CONTENT");
for i in 0..6 {
harness
.send_key(KeyCode::PageDown, KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
println!("After next_buffer #{}: screen:\n{}", i + 1, screen);
assert!(
!screen.contains("HIDDEN_BUFFER_CONTENT"),
"next_buffer should skip hidden buffer. Iteration {}. Screen:\n{}",
i + 1,
screen
);
assert!(
screen.contains("VISIBLE_BUFFER_1_CONTENT")
|| screen.contains("VISIBLE_BUFFER_2_CONTENT"),
"Should be on a visible buffer. Iteration {}. Screen:\n{}",
i + 1,
screen
);
}
for i in 0..6 {
harness
.send_key(KeyCode::PageUp, KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
println!("After prev_buffer #{}: screen:\n{}", i + 1, screen);
assert!(
!screen.contains("HIDDEN_BUFFER_CONTENT"),
"prev_buffer should skip hidden buffer. Iteration {}. Screen:\n{}",
i + 1,
screen
);
assert!(
screen.contains("VISIBLE_BUFFER_1_CONTENT")
|| screen.contains("VISIBLE_BUFFER_2_CONTENT"),
"Should be on a visible buffer. Iteration {}. Screen:\n{}",
i + 1,
screen
);
}
}
#[test]
fn test_close_returns_to_previous_focused() {
let mut harness = EditorTestHarness::new(100, 24).unwrap();
let temp_dir = tempfile::TempDir::new().unwrap();
let file_a = temp_dir.path().join("file_a.txt");
let file_b = temp_dir.path().join("file_b.txt");
let file_c = temp_dir.path().join("file_c.txt");
std::fs::write(&file_a, "CONTENT_A").unwrap();
std::fs::write(&file_b, "CONTENT_B").unwrap();
std::fs::write(&file_c, "CONTENT_C").unwrap();
harness.open_file(&file_a).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("CONTENT_A");
harness.open_file(&file_b).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("CONTENT_B");
harness.open_file(&file_c).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("CONTENT_C");
harness.open_file(&file_a).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("CONTENT_A");
harness.open_file(&file_b).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("CONTENT_B");
harness
.send_key(KeyCode::Char('w'), KeyModifiers::ALT)
.unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
assert!(
screen.contains("CONTENT_A"),
"After closing B, should return to previously focused A. Screen:\n{}",
screen
);
}
fn isolated_project_harness(config: Config) -> EditorTestHarness {
EditorTestHarness::create(
120,
30,
HarnessOptions::new()
.with_project_root()
.with_empty_plugins_dir()
.with_config(config),
)
.unwrap()
}
const TAB_BAR_ROW: u16 = 1;
#[test]
fn test_close_last_buffer_default_opens_explorer_and_empty_tab() {
let mut harness = isolated_project_harness(Config::default());
let project_root = harness.project_dir().unwrap();
let file = project_root.join("only.txt");
std::fs::write(&file, "only content").unwrap();
harness.open_file(&file).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("only content");
harness.editor_mut().close_tab();
harness
.wait_until(|h| h.screen_to_string().contains("File Explorer"))
.unwrap();
assert!(
harness.screen_row_text(TAB_BAR_ROW).contains("[No Name]"),
"Expected `[No Name]` tab in tab bar. Tab bar:\n{}",
harness.screen_row_text(TAB_BAR_ROW)
);
}
#[test]
fn test_close_last_buffer_does_not_open_explorer_when_disabled() {
let mut config = Config::default();
config.file_explorer.auto_open_on_last_buffer_close = false;
let mut harness = isolated_project_harness(config);
let project_root = harness.project_dir().unwrap();
let file = project_root.join("only.txt");
std::fs::write(&file, "only content").unwrap();
harness.open_file(&file).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("only content");
harness.editor_mut().close_tab();
harness
.wait_until(|h| h.screen_row_text(TAB_BAR_ROW).contains("[No Name]"))
.unwrap();
harness.assert_screen_not_contains("File Explorer");
}
#[test]
fn test_close_last_buffer_hides_empty_tab_when_disabled() {
let mut config = Config::default();
config.editor.auto_create_empty_buffer_on_last_buffer_close = false;
let mut harness = isolated_project_harness(config);
let project_root = harness.project_dir().unwrap();
let file = project_root.join("only.txt");
std::fs::write(&file, "only content").unwrap();
harness.open_file(&file).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("only content");
harness.editor_mut().close_tab();
harness
.wait_until(|h| h.screen_to_string().contains("File Explorer"))
.unwrap();
let tab_bar = harness.screen_row_text(TAB_BAR_ROW);
assert!(
!tab_bar.contains("[No Name]"),
"Expected no `[No Name]` tab. Tab bar:\n{}",
tab_bar
);
}
#[test]
fn test_close_last_buffer_blank_workspace_when_both_disabled() {
let mut config = Config::default();
config.file_explorer.auto_open_on_last_buffer_close = false;
config.editor.auto_create_empty_buffer_on_last_buffer_close = false;
let mut harness = isolated_project_harness(config);
let project_root = harness.project_dir().unwrap();
let file = project_root.join("only.txt");
std::fs::write(&file, "only content").unwrap();
harness.open_file(&file).unwrap();
harness.render().unwrap();
harness.assert_screen_contains("only content");
harness.editor_mut().close_tab();
harness
.wait_until_stable(|h| !h.screen_to_string().contains("only content"))
.unwrap();
harness.assert_screen_not_contains("File Explorer");
let tab_bar = harness.screen_row_text(TAB_BAR_ROW);
assert!(
!tab_bar.contains("[No Name]"),
"Expected no `[No Name]` tab. Tab bar:\n{}",
tab_bar
);
}