mod common;
use fresh::model::filesystem::StdFileSystem;
use fresh::{
model::cursor::Cursors,
model::event::{CursorId, Event, EventLog},
state::EditorState,
view::overlay::OverlayNamespace,
view::theme,
};
fn test_fs() -> std::sync::Arc<dyn fresh::model::filesystem::FileSystem + Send + Sync> {
std::sync::Arc::new(StdFileSystem)
}
#[test]
fn test_buffer_cursor_adjustment_on_insert() {
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
let original_primary = cursors.primary_id();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "hello world".to_string(),
cursor_id: original_primary,
},
);
assert_eq!(cursors.get(original_primary).unwrap().position, 11);
state.apply(
&mut cursors,
&Event::AddCursor {
cursor_id: CursorId(1),
position: 6,
anchor: None,
},
);
assert_eq!(cursors.get(CursorId(1)).unwrap().position, 6);
assert_eq!(cursors.primary_id(), CursorId(1));
let insert_len = "INSERTED ".len();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "INSERTED ".to_string(),
cursor_id: original_primary, },
);
assert_eq!(
cursors.get(original_primary).unwrap().position,
insert_len,
"Cursor that made the edit should be at end of insertion"
);
assert_eq!(
cursors.get(CursorId(1)).unwrap().position,
6 + insert_len,
"Non-editing cursor should be adjusted by insertion length"
);
assert_eq!(state.buffer.to_string().unwrap(), "INSERTED hello world");
}
#[test]
fn test_buffer_cursor_adjustment_on_delete() {
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
let cursor_id = cursors.primary_id();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "hello beautiful world".to_string(),
cursor_id,
},
);
state.apply(
&mut cursors,
&Event::AddCursor {
cursor_id: CursorId(1),
position: 16,
anchor: None,
},
);
let cursor_id = cursors.primary_id();
state.apply(
&mut cursors,
&Event::Delete {
range: 6..16,
deleted_text: "beautiful ".to_string(),
cursor_id,
},
);
if let Some(cursor) = cursors.get(CursorId(1)) {
assert_eq!(cursor.position, 6);
}
assert_eq!(state.buffer.to_string().unwrap(), "hello world");
}
#[test]
fn test_state_eventlog_undo_redo() {
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut log = EventLog::new();
let mut cursors = Cursors::new();
let cursor_id = cursors.primary_id();
let event1 = Event::Insert {
position: 0,
text: "a".to_string(),
cursor_id,
};
log.append(event1.clone());
state.apply(&mut cursors, &event1);
let event2 = Event::Insert {
position: state.buffer.len(),
text: "b".to_string(),
cursor_id,
};
log.append(event2.clone());
state.apply(&mut cursors, &event2);
let event3 = Event::Insert {
position: state.buffer.len(),
text: "c".to_string(),
cursor_id,
};
log.append(event3.clone());
state.apply(&mut cursors, &event3);
assert_eq!(state.buffer.to_string().unwrap(), "abc");
while log.can_undo() {
let events = log.undo();
for (event, _displaced) in events {
state.apply(&mut cursors, &event);
}
}
assert_eq!(state.buffer.to_string().unwrap(), "");
while log.can_redo() {
let events = log.redo();
for event in events {
state.apply(&mut cursors, &event);
}
}
assert_eq!(state.buffer.to_string().unwrap(), "abc");
}
#[test]
fn test_undo_redo_cursor_positions() {
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut log = EventLog::new();
let mut cursors = Cursors::new();
let cursor_id = cursors.primary_id();
for ch in "hello".chars() {
let pos = state.buffer.len();
let event = Event::Insert {
position: pos,
text: ch.to_string(),
cursor_id,
};
log.append(event.clone());
state.apply(&mut cursors, &event);
}
assert_eq!(state.buffer.to_string().unwrap(), "hello");
let cursor_after_typing = cursors.primary().position;
assert_eq!(cursor_after_typing, 5);
for _ in 0..2 {
let events = log.undo();
for (event, _displaced) in events {
state.apply(&mut cursors, &event);
}
}
assert_eq!(state.buffer.to_string().unwrap(), "hel");
assert_eq!(cursors.primary().position, 3);
for _ in 0..2 {
let events = log.redo();
for event in events {
state.apply(&mut cursors, &event);
}
}
assert_eq!(state.buffer.to_string().unwrap(), "hello");
assert_eq!(cursors.primary().position, 5);
}
#[test]
fn test_viewport_tracks_cursor_through_edits() {
let mut state = EditorState::new(
80,
10,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
); let mut cursors = Cursors::new();
let cursor_id = cursors.primary_id();
for i in 0..20 {
let event = Event::Insert {
position: state.buffer.len(),
text: format!("Line {i}\n"),
cursor_id,
};
state.apply(&mut cursors, &event);
}
let cursor_pos = cursors.primary().position;
assert!(cursor_pos > 0);
assert!(
cursor_pos <= state.buffer.len(),
"Cursor should be within buffer bounds"
);
}
#[test]
fn test_multi_cursor_normalization() {
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
let cursor_id = cursors.primary_id();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "hello world".to_string(),
cursor_id,
},
);
state.apply(
&mut cursors,
&Event::AddCursor {
cursor_id: CursorId(1),
position: 5,
anchor: None,
},
);
state.apply(
&mut cursors,
&Event::AddCursor {
cursor_id: CursorId(2),
position: 6,
anchor: None,
},
);
assert_eq!(cursors.count(), 3);
for (_, cursor) in cursors.iter() {
assert!(cursor.position <= state.buffer.len());
}
}
#[test]
fn test_cursor_within_buffer_bounds() {
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
let cursor_id = cursors.primary_id();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "line1\nline2\nline3\nline4\nline5\n".to_string(),
cursor_id,
},
);
let cursor_id = cursors.primary_id();
state.apply(
&mut cursors,
&Event::MoveCursor {
cursor_id,
old_position: 0,
new_position: 12, old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
},
);
let cursor_pos = cursors.primary().position;
assert!(
cursor_pos <= state.buffer.len(),
"Cursor should be within buffer bounds"
);
}
#[test]
fn test_overlay_events() {
use fresh::model::event::{OverlayFace, UnderlineStyle};
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "hello world".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::AddOverlay {
namespace: Some(OverlayNamespace::from_string("error".to_string())),
range: 0..5,
face: OverlayFace::Underline {
color: (255, 0, 0),
style: UnderlineStyle::Wavy,
},
priority: 100,
message: Some("Error here".to_string()),
extend_to_line_end: false,
url: None,
},
);
let overlays_at_pos = state.overlays.at_position(2, &state.marker_list);
assert_eq!(overlays_at_pos.len(), 1);
assert_eq!(
overlays_at_pos[0].namespace,
Some(OverlayNamespace::from_string("error".to_string()))
);
state.apply(
&mut cursors,
&Event::AddOverlay {
namespace: Some(OverlayNamespace::from_string("warning".to_string())),
range: 3..8,
face: OverlayFace::Underline {
color: (255, 255, 0),
style: UnderlineStyle::Wavy,
},
priority: 50,
message: Some("Warning here".to_string()),
extend_to_line_end: false,
url: None,
},
);
let overlays_at_4 = state.overlays.at_position(4, &state.marker_list);
assert_eq!(overlays_at_4.len(), 2);
assert_eq!(overlays_at_4[0].priority, 50); assert_eq!(overlays_at_4[1].priority, 100);
state.apply(
&mut cursors,
&Event::ClearNamespace {
namespace: OverlayNamespace::from_string("error".to_string()),
},
);
let overlays_at_4 = state.overlays.at_position(4, &state.marker_list);
assert_eq!(overlays_at_4.len(), 1);
assert_eq!(
overlays_at_4[0].namespace,
Some(OverlayNamespace::from_string("warning".to_string()))
);
state.apply(&mut cursors, &Event::ClearOverlays);
let overlays_after_clear = state.overlays.at_position(4, &state.marker_list);
assert_eq!(overlays_after_clear.len(), 0);
}
#[test]
fn test_popup_events() {
use fresh::model::event::{
PopupContentData, PopupData, PopupKindHint, PopupListItemData, PopupPositionData,
};
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
let popup_data = PopupData {
kind: PopupKindHint::List,
title: Some("Test Popup".to_string()),
description: None,
transient: false,
content: PopupContentData::List {
items: vec![
PopupListItemData {
text: "Item 1".to_string(),
detail: Some("First item".to_string()),
icon: Some("📄".to_string()),
data: None,
},
PopupListItemData {
text: "Item 2".to_string(),
detail: Some("Second item".to_string()),
icon: Some("📄".to_string()),
data: None,
},
PopupListItemData {
text: "Item 3".to_string(),
detail: Some("Third item".to_string()),
icon: Some("📄".to_string()),
data: None,
},
],
selected: 0,
},
position: PopupPositionData::Centered,
width: 40,
max_height: 10,
bordered: true,
};
state.apply(&mut cursors, &Event::ShowPopup { popup: popup_data });
assert!(state.popups.is_visible());
let popup = state.popups.top().unwrap();
assert_eq!(popup.title, Some("Test Popup".to_string()));
state.apply(&mut cursors, &Event::PopupSelectNext);
let popup = state.popups.top().unwrap();
let selected_item = popup.selected_item().unwrap();
assert_eq!(selected_item.text, "Item 2");
state.apply(&mut cursors, &Event::PopupSelectNext);
let popup = state.popups.top().unwrap();
let selected_item = popup.selected_item().unwrap();
assert_eq!(selected_item.text, "Item 3");
state.apply(&mut cursors, &Event::PopupSelectPrev);
let popup = state.popups.top().unwrap();
let selected_item = popup.selected_item().unwrap();
assert_eq!(selected_item.text, "Item 2");
state.apply(&mut cursors, &Event::HidePopup);
assert!(!state.popups.is_visible());
}
#[test]
fn test_overlay_undo_redo() {
use fresh::model::event::{OverlayFace, UnderlineStyle};
let mut log = EventLog::new();
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
let event1 = Event::Insert {
position: 0,
text: "hello".to_string(),
cursor_id: CursorId(0),
};
log.append(event1.clone());
state.apply(&mut cursors, &event1);
let event2 = Event::AddOverlay {
namespace: Some(OverlayNamespace::from_string("test".to_string())),
range: 0..5,
face: OverlayFace::Underline {
color: (255, 0, 0),
style: UnderlineStyle::Wavy,
},
priority: 100,
message: None,
extend_to_line_end: false,
url: None,
};
log.append(event2.clone());
state.apply(&mut cursors, &event2);
assert_eq!(state.overlays.at_position(2, &state.marker_list).len(), 1);
let undo_events = log.undo();
for (event, _displaced) in &undo_events {
state.apply(&mut cursors, event);
}
assert_eq!(state.buffer.len(), 0);
assert_eq!(state.overlays.at_position(2, &state.marker_list).len(), 0);
assert!(
!undo_events.is_empty(),
"Should have returned events to undo"
);
let redo_events = log.redo();
for event in &redo_events {
state.apply(&mut cursors, event);
}
assert_eq!(state.buffer.to_string().unwrap(), "hello");
assert_eq!(state.overlays.at_position(2, &state.marker_list).len(), 1);
assert!(
!redo_events.is_empty(),
"Should have returned events to redo"
);
}
#[test]
fn test_lsp_diagnostic_to_overlay() {
use fresh::{
config::LARGE_FILE_THRESHOLD_BYTES, model::buffer::Buffer,
services::lsp::diagnostics::diagnostic_to_overlay,
};
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
let buffer = Buffer::from_str(
"let x = 5;\nlet y = 10;",
LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let diagnostic = Diagnostic {
range: Range {
start: Position {
line: 0,
character: 4,
},
end: Position {
line: 0,
character: 5,
},
},
severity: Some(DiagnosticSeverity::ERROR),
code: None,
code_description: None,
source: Some("rust-analyzer".to_string()),
message: "unused variable: `x`".to_string(),
related_information: None,
tags: None,
data: None,
};
let theme = fresh::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
let result = diagnostic_to_overlay(&diagnostic, &buffer, &theme);
assert!(result.is_some());
let (range, face, priority, theme_key) = result.unwrap();
assert_eq!(range.start, 4);
assert_eq!(range.end, 5);
assert_eq!(theme_key, "diagnostic.error_bg");
assert_eq!(priority, 100);
match face {
fresh::view::overlay::OverlayFace::Background { color } => {
assert_eq!(color, theme.diagnostic_error_bg);
}
_ => panic!("Expected background face for error diagnostic"),
}
}
#[test]
fn test_overlay_priority_layering() {
use fresh::model::event::{OverlayFace, UnderlineStyle};
let mut state = EditorState::new(
80,
24,
fresh::config::LARGE_FILE_THRESHOLD_BYTES as usize,
test_fs(),
);
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::Insert {
position: 0,
text: "hello world".to_string(),
cursor_id: CursorId(0),
},
);
state.apply(
&mut cursors,
&Event::AddOverlay {
namespace: Some(OverlayNamespace::from_string("hint".to_string())),
range: 0..5,
face: OverlayFace::Underline {
color: (128, 128, 128),
style: UnderlineStyle::Dotted,
},
priority: 10,
message: Some("Hint message".to_string()),
extend_to_line_end: false,
url: None,
},
);
state.apply(
&mut cursors,
&Event::AddOverlay {
namespace: Some(OverlayNamespace::from_string("error".to_string())),
range: 2..7,
face: OverlayFace::Underline {
color: (255, 0, 0),
style: UnderlineStyle::Wavy,
},
priority: 100,
message: Some("Error message".to_string()),
extend_to_line_end: false,
url: None,
},
);
let overlays = state.overlays.at_position(3, &state.marker_list);
assert_eq!(overlays.len(), 2);
assert_eq!(overlays[0].priority, 10); assert_eq!(overlays[1].priority, 100);
assert_eq!(
overlays[0].namespace,
Some(OverlayNamespace::from_string("hint".to_string()))
);
assert_eq!(
overlays[1].namespace,
Some(OverlayNamespace::from_string("error".to_string()))
);
}
#[test]
fn test_diagnostic_overlay_visual_rendering() {
use common::harness::EditorTestHarness;
use fresh::model::event::{OverlayFace, UnderlineStyle};
use ratatui::style::{Color, Modifier};
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness.type_text("let x = 5;").unwrap();
harness.render().unwrap();
let state = harness.editor_mut().active_state_mut();
let mut cursors = Cursors::new();
state.apply(
&mut cursors,
&Event::AddOverlay {
namespace: Some(OverlayNamespace::from_string("lsp-diagnostic".to_string())),
range: 4..5, face: OverlayFace::Underline {
color: (255, 0, 0), style: UnderlineStyle::Wavy,
},
priority: 100,
message: Some("unused variable: `x`".to_string()),
extend_to_line_end: false,
url: None,
},
);
harness.render().unwrap();
let gutter_width = harness.editor().active_state().margins.left_total_width() as u16;
let x_column = gutter_width + 4; let (content_first_row, _) = harness.content_area_rows();
let x_row = content_first_row as u16;
let style = harness.get_cell_style(x_column, x_row);
assert!(
style.is_some(),
"Expected cell at ({x_column}, {x_row}) to have a style"
);
let style = style.unwrap();
assert_eq!(
style.fg,
Some(Color::Rgb(255, 0, 0)),
"Expected 'x' to be rendered in red (RGB 255,0,0) due to error diagnostic"
);
assert!(
style.add_modifier.contains(Modifier::UNDERLINED),
"Expected 'x' to have underline modifier"
);
let text = harness.get_cell(x_column, x_row);
assert_eq!(
text,
Some("x".to_string()),
"Expected 'x' character at position"
);
}
mod event_inverse_tests {
use fresh::model::event::{CursorId, Event, OverlayFace, UnderlineStyle};
use fresh::view::overlay::{OverlayHandle, OverlayNamespace};
#[test]
fn test_insert_inverse() {
let event = Event::Insert {
position: 10,
text: "hello".to_string(),
cursor_id: CursorId(0),
};
let inverse = event.inverse().expect("Insert should have inverse");
match inverse {
Event::Delete {
range,
deleted_text,
cursor_id,
} => {
assert_eq!(range, 10..15);
assert_eq!(deleted_text, "hello");
assert_eq!(cursor_id, CursorId::UNDO_SENTINEL);
}
_ => panic!("Insert inverse should be Delete"),
}
}
#[test]
fn test_delete_inverse() {
let event = Event::Delete {
range: 5..10,
deleted_text: "world".to_string(),
cursor_id: CursorId(1),
};
let inverse = event.inverse().expect("Delete should have inverse");
match inverse {
Event::Insert {
position,
text,
cursor_id,
} => {
assert_eq!(position, 5);
assert_eq!(text, "world");
assert_eq!(cursor_id, CursorId::UNDO_SENTINEL);
}
_ => panic!("Delete inverse should be Insert"),
}
}
#[test]
fn test_add_cursor_inverse() {
let event = Event::AddCursor {
cursor_id: CursorId(2),
position: 42,
anchor: Some(10),
};
let inverse = event.inverse().expect("AddCursor should have inverse");
match inverse {
Event::RemoveCursor {
cursor_id,
position,
anchor,
} => {
assert_eq!(cursor_id, CursorId(2));
assert_eq!(position, 42);
assert_eq!(anchor, Some(10));
}
_ => panic!("AddCursor inverse should be RemoveCursor"),
}
}
#[test]
fn test_remove_cursor_inverse() {
let event = Event::RemoveCursor {
cursor_id: CursorId(3),
position: 100,
anchor: None,
};
let inverse = event.inverse().expect("RemoveCursor should have inverse");
match inverse {
Event::AddCursor {
cursor_id,
position,
anchor,
} => {
assert_eq!(cursor_id, CursorId(3));
assert_eq!(position, 100);
assert_eq!(anchor, None);
}
_ => panic!("RemoveCursor inverse should be AddCursor"),
}
}
#[test]
fn test_move_cursor_inverse() {
let event = Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 10,
new_position: 20,
old_anchor: None,
new_anchor: Some(15),
old_sticky_column: 5,
new_sticky_column: 10,
};
let inverse = event.inverse().expect("MoveCursor should have inverse");
match inverse {
Event::MoveCursor {
cursor_id,
old_position,
new_position,
old_anchor,
new_anchor,
old_sticky_column,
new_sticky_column,
} => {
assert_eq!(cursor_id, CursorId(0));
assert_eq!(old_position, 20); assert_eq!(new_position, 10); assert_eq!(old_anchor, Some(15)); assert_eq!(new_anchor, None); assert_eq!(old_sticky_column, 10); assert_eq!(new_sticky_column, 5); }
_ => panic!("MoveCursor inverse should be MoveCursor"),
}
}
#[test]
fn test_add_overlay_no_inverse() {
let event = Event::AddOverlay {
namespace: Some(OverlayNamespace::from_string("test-overlay".to_string())),
range: 0..10,
face: OverlayFace::Underline {
color: (255, 0, 0),
style: UnderlineStyle::Wavy,
},
priority: 100,
message: Some("error".to_string()),
extend_to_line_end: false,
url: None,
};
assert!(event.inverse().is_none());
}
#[test]
fn test_remove_overlay_no_inverse() {
let event = Event::RemoveOverlay {
handle: OverlayHandle::from_string("test".to_string()),
};
assert!(event.inverse().is_none());
}
#[test]
fn test_scroll_inverse() {
let event = Event::Scroll { line_offset: 5 };
let inverse = event.inverse().expect("Scroll should have inverse");
match inverse {
Event::Scroll { line_offset } => {
assert_eq!(line_offset, -5); }
_ => panic!("Scroll inverse should be Scroll with negated offset"),
}
}
#[test]
fn test_set_viewport_no_inverse() {
let event = Event::SetViewport { top_line: 10 };
assert!(event.inverse().is_none());
}
#[test]
fn test_change_mode_no_inverse() {
let event = Event::ChangeMode {
mode: "insert".to_string(),
};
assert!(event.inverse().is_none());
}
#[test]
fn test_batch_inverse() {
let batch = Event::Batch {
events: vec![
Event::Insert {
position: 0,
text: "a".to_string(),
cursor_id: CursorId(0),
},
Event::Insert {
position: 1,
text: "b".to_string(),
cursor_id: CursorId(0),
},
Event::Insert {
position: 2,
text: "c".to_string(),
cursor_id: CursorId(0),
},
],
description: "Insert abc".to_string(),
};
let inverse = batch.inverse().expect("Batch should have inverse");
match inverse {
Event::Batch {
events,
description,
} => {
assert_eq!(events.len(), 3);
assert_eq!(description, "Undo: Insert abc");
match &events[0] {
Event::Delete {
range,
deleted_text,
..
} => {
assert_eq!(*range, 2..3);
assert_eq!(deleted_text, "c");
}
_ => panic!("Expected Delete"),
}
match &events[2] {
Event::Delete {
range,
deleted_text,
..
} => {
assert_eq!(*range, 0..1);
assert_eq!(deleted_text, "a");
}
_ => panic!("Expected Delete"),
}
}
_ => panic!("Batch inverse should be Batch"),
}
}
#[test]
fn test_batch_with_non_invertible_events() {
let batch = Event::Batch {
events: vec![
Event::Insert {
position: 0,
text: "a".to_string(),
cursor_id: CursorId(0),
},
Event::SetViewport { top_line: 10 }, ],
description: "Mixed batch".to_string(),
};
assert!(batch.inverse().is_none());
}
#[test]
fn test_nested_batch_inverse() {
let inner_batch = Event::Batch {
events: vec![
Event::Insert {
position: 0,
text: "x".to_string(),
cursor_id: CursorId(0),
},
Event::Insert {
position: 1,
text: "y".to_string(),
cursor_id: CursorId(0),
},
],
description: "Inner".to_string(),
};
let outer_batch = Event::Batch {
events: vec![
Event::Insert {
position: 0,
text: "a".to_string(),
cursor_id: CursorId(0),
},
inner_batch,
Event::Insert {
position: 3,
text: "z".to_string(),
cursor_id: CursorId(0),
},
],
description: "Outer".to_string(),
};
let inverse = outer_batch
.inverse()
.expect("Nested batch should have inverse");
match inverse {
Event::Batch {
events,
description,
} => {
assert_eq!(events.len(), 3);
assert_eq!(description, "Undo: Outer");
match &events[1] {
Event::Batch {
events: inner_events,
description: inner_desc,
} => {
assert_eq!(inner_events.len(), 2);
assert_eq!(inner_desc, "Undo: Inner");
}
_ => panic!("Expected nested Batch"),
}
}
_ => panic!("Outer batch inverse should be Batch"),
}
}
#[test]
fn test_double_inverse_equals_original() {
let original = Event::Insert {
position: 5,
text: "test".to_string(),
cursor_id: CursorId(0),
};
let inverse = original.inverse().expect("Should have inverse");
let double_inverse = inverse.inverse().expect("Should have double inverse");
match double_inverse {
Event::Insert {
position,
text,
cursor_id,
} => {
assert_eq!(position, 5);
assert_eq!(text, "test");
assert_eq!(cursor_id, CursorId::UNDO_SENTINEL);
}
_ => panic!("Double inverse should be Insert"),
}
}
#[test]
fn test_move_cursor_double_inverse() {
let original = Event::MoveCursor {
cursor_id: CursorId(0),
old_position: 10,
new_position: 20,
old_anchor: None,
new_anchor: Some(15),
old_sticky_column: 5,
new_sticky_column: 10,
};
let inverse = original.inverse().expect("Should have inverse");
let double_inverse = inverse.inverse().expect("Should have double inverse");
match double_inverse {
Event::MoveCursor {
cursor_id,
old_position,
new_position,
old_anchor,
new_anchor,
old_sticky_column,
new_sticky_column,
} => {
assert_eq!(cursor_id, CursorId(0));
assert_eq!(old_position, 10);
assert_eq!(new_position, 20);
assert_eq!(old_anchor, None);
assert_eq!(new_anchor, Some(15));
assert_eq!(old_sticky_column, 5);
assert_eq!(new_sticky_column, 10);
}
_ => panic!("Double inverse should be MoveCursor"),
}
}
}
#[test]
fn test_crlf_syntax_highlighting_offset() {
use common::fixtures::TestFixture;
use common::harness::EditorTestHarness;
use ratatui::style::Color;
let content = "public int x = 1;\r\npublic int x = 2;\r\npublic int x = 3;\r\npublic int x = 4;\r\npublic int x = 5;\r\npublic int x = 6;\r\n";
let fixture = TestFixture::new("test_crlf.java", content).unwrap();
let mut harness = EditorTestHarness::create(
80,
24,
common::harness::HarnessOptions::new().with_full_grammar_registry(),
)
.unwrap();
harness.open_file(&fixture.path).unwrap();
harness.render().unwrap();
std::thread::sleep(std::time::Duration::from_millis(100));
harness.render().unwrap();
eprintln!("Screen content:");
for row in 0..10 {
let row_text = harness.get_row_text(row);
eprintln!("Row {}: {:?}", row, row_text);
}
eprintln!("Has highlighter: {}", harness.has_highlighter());
eprintln!(
"Highlighter backend: {}",
harness.editor().active_state().highlighter.backend_name()
);
let buffer_content = harness.get_buffer_content().unwrap_or_default();
let has_crlf = buffer_content.contains("\r\n");
eprintln!("Buffer has CRLF: {}", has_crlf);
eprintln!("Buffer content bytes: {:?}", buffer_content.as_bytes());
let find_char_col = |harness: &EditorTestHarness, row: u16, ch: char| -> Option<u16> {
let row_text = harness.get_row_text(row);
for (col, c) in row_text.chars().enumerate() {
if c == ch {
return Some(col as u16);
}
}
None
};
let line1_p_col = find_char_col(&harness, 2, 'p').expect("Should find 'p' on line 1");
let line2_p_col = find_char_col(&harness, 3, 'p').expect("Should find 'p' on line 2");
let line3_p_col = find_char_col(&harness, 4, 'p').expect("Should find 'p' on line 3");
let line4_p_col = find_char_col(&harness, 5, 'p').expect("Should find 'p' on line 4");
let line5_p_col = find_char_col(&harness, 6, 'p').expect("Should find 'p' on line 5");
let line6_p_col = find_char_col(&harness, 7, 'p').expect("Should find 'p' on line 6");
eprintln!(
"Found 'p' at columns: 1={}, 2={}, 3={}, 4={}, 5={}, 6={}",
line1_p_col, line2_p_col, line3_p_col, line4_p_col, line5_p_col, line6_p_col
);
assert_eq!(
line1_p_col, line2_p_col,
"Line 1 and Line 2 'pub' should be at same column"
);
assert_eq!(
line2_p_col, line3_p_col,
"Line 2 and Line 3 'pub' should be at same column"
);
let get_fg_color = |harness: &EditorTestHarness, row: u16, col: u16| -> Option<Color> {
harness.get_cell_style(col, row).and_then(|s| s.fg)
};
let line1_p_color = get_fg_color(&harness, 2, line1_p_col);
let line2_p_color = get_fg_color(&harness, 3, line2_p_col);
let line3_p_color = get_fg_color(&harness, 4, line3_p_col);
let line4_p_color = get_fg_color(&harness, 5, line4_p_col);
let line5_p_color = get_fg_color(&harness, 6, line5_p_col);
let line6_p_color = get_fg_color(&harness, 7, line6_p_col);
eprintln!("Colors at 'p' position:");
eprintln!(" Line 1 (row 2, col {}): {:?}", line1_p_col, line1_p_color);
eprintln!(" Line 2 (row 3, col {}): {:?}", line2_p_col, line2_p_color);
eprintln!(" Line 3 (row 4, col {}): {:?}", line3_p_col, line3_p_color);
eprintln!(" Line 4 (row 5, col {}): {:?}", line4_p_col, line4_p_color);
eprintln!(" Line 5 (row 6, col {}): {:?}", line5_p_col, line5_p_color);
eprintln!(" Line 6 (row 7, col {}): {:?}", line6_p_col, line6_p_color);
eprintln!("Chars at 'p' position:");
eprintln!(
" Line 1: '{}'",
harness.get_cell(line1_p_col, 2).unwrap_or_default()
);
eprintln!(
" Line 2: '{}'",
harness.get_cell(line2_p_col, 3).unwrap_or_default()
);
eprintln!(
" Line 3: '{}'",
harness.get_cell(line3_p_col, 4).unwrap_or_default()
);
eprintln!(
" Line 4: '{}'",
harness.get_cell(line4_p_col, 5).unwrap_or_default()
);
eprintln!(
" Line 5: '{}'",
harness.get_cell(line5_p_col, 6).unwrap_or_default()
);
eprintln!(
" Line 6: '{}'",
harness.get_cell(line6_p_col, 7).unwrap_or_default()
);
let num_offset = 15;
let line1_num_color = get_fg_color(&harness, 2, line1_p_col + num_offset);
let line6_num_color = get_fg_color(&harness, 7, line6_p_col + num_offset);
eprintln!("Number colors:");
eprintln!(
" Line 1 number (col {}): {:?}, char: '{}'",
line1_p_col + num_offset,
line1_num_color,
harness
.get_cell(line1_p_col + num_offset, 2)
.unwrap_or_default()
);
eprintln!(
" Line 6 number (col {}): {:?}, char: '{}'",
line6_p_col + num_offset,
line6_num_color,
harness
.get_cell(line6_p_col + num_offset, 7)
.unwrap_or_default()
);
assert_ne!(
line1_p_color, line1_num_color,
"Keyword 'pub' and number should have different colors. Both are {:?}. \
This suggests syntax highlighting isn't working.",
line1_p_color
);
let all_p_colors = [
line1_p_color,
line2_p_color,
line3_p_color,
line4_p_color,
line5_p_color,
line6_p_color,
];
for (i, color) in all_p_colors.iter().enumerate() {
assert_eq!(
*color,
line1_p_color,
"Line {} 'pub' keyword should have same highlight color as line 1. \
Line 1: {:?}, Line {}: {:?}. \
If colors differ, CRLF highlight offset is broken.",
i + 1,
line1_p_color,
i + 1,
color
);
}
}