use super::*;
fn editor(text: &str) -> PromptEditor {
PromptEditor::new(text, text.len())
}
#[test]
fn unicode_cursor_round_trip_uses_byte_offsets_and_character_columns() {
let editor = editor("aé界");
assert_eq!(editor.cursor(), "aé界".len());
assert_eq!(cursor_for_byte_offset("aé界", "aé".len()), (0, 2));
assert_eq!(
byte_offset_for_cursor(editor.textarea.borrow().lines(), (0, 2)),
"aé".len()
);
}
#[test]
fn leading_zero_width_cursor_movement_is_exact_for_scalar_positions() {
let combining = "\u{0301}";
let text = format!("{combining}abc");
let mut editor = editor(&text);
editor.move_to_byte(combining.len(), 1, 80);
assert_eq!(editor.cursor(), combining.len());
editor.move_to_byte(0, 1, 80);
assert_eq!(editor.cursor(), 0);
editor.move_to_byte(combining.len(), 1, 80);
editor.move_left(1, 80);
assert_eq!(editor.cursor(), 0);
editor.move_to_byte(combining.len(), 1, 80);
editor.extend_selection_left(1, 80);
assert_eq!(editor.cursor(), 0);
assert_eq!(editor.selection_text().as_deref(), Some(combining));
}
#[test]
fn zero_width_character_moves_are_exact_beyond_u16() {
let high = usize::from(u16::MAX) + 4;
let combining = "\u{0301}";
let text = combining.repeat(high + 2);
let mut editor = PromptEditor::new(&text, high * combining.len());
assert_eq!(editor.move_right(1, 80), PromptMovementOutcome::Moved);
assert_eq!(editor.cursor(), (high + 1) * 2);
assert_eq!(editor.move_left(1, 80), PromptMovementOutcome::Moved);
assert_eq!(editor.cursor(), high * 2);
assert_eq!(
editor.extend_selection_right(1, 80),
PromptMovementOutcome::Moved
);
assert_eq!(editor.cursor(), (high + 1) * 2);
assert_eq!(
editor.selection_byte_range(),
Some((high * 2, (high + 1) * 2))
);
assert_eq!(editor.move_to_byte_inner((high + 1) * 2, false), Ok(0));
assert_eq!(
editor.extend_selection_left(1, 80),
PromptMovementOutcome::Moved
);
assert_eq!(editor.cursor(), high * 2);
assert_eq!(
editor.selection_byte_range(),
Some((high * 2, (high + 1) * 2))
);
assert_eq!(editor.selection_text().as_deref(), Some(combining));
let textarea = editor.textarea.borrow();
assert!(textarea.search_pattern().is_none());
assert!(textarea.atomic_ranges().is_empty());
}
#[test]
fn leading_zero_width_backward_movement_beyond_u16_uses_atomic_leap() {
let combining = "\u{0301}";
let target_column = usize::from(u16::MAX) + 1;
let text = combining.repeat(target_column + 10);
let mut editor = PromptEditor::new(&text, (target_column + 8) * combining.len());
let calls = editor
.move_to_byte_inner(target_column * combining.len(), false)
.unwrap();
assert!(
calls <= 4,
"backward atomic movement used {calls} native calls"
);
assert_eq!(editor.cursor(), target_column * combining.len());
assert_eq!(editor.text(), text);
assert!(editor.textarea.borrow().search_pattern().is_none());
assert!(editor.textarea.borrow().atomic_ranges().is_empty());
}
#[test]
fn one_mib_zero_width_arbitrary_movement_and_selection_are_bounded() {
let combining = "\u{0301}";
let scalars = MAX_PROMPT_BYTES / combining.len();
let text = combining.repeat(scalars);
let target = (scalars - 8) * combining.len();
let mut editor = PromptEditor::new(&text, 0);
assert_eq!(editor.move_to_byte_inner(target, false), Ok(1));
assert_eq!(editor.cursor(), target);
let fresh = PromptEditor::new(&text, 0);
let calls = move_textarea_cursor_to(
&mut fresh.textarea.borrow_mut(),
cursor_for_byte_offset(&text, target),
)
.unwrap();
assert!(calls <= 4, "1 MiB movement used {calls} native calls");
let mut selected = PromptEditor::new(&text, 0);
assert_eq!(
selected.start_selection_at(0),
PromptMovementOutcome::Unchanged
);
let selection_target = target / 2;
assert_eq!(
selected.update_selection_at(selection_target),
PromptMovementOutcome::Moved
);
assert!(selected.selection_active());
assert_eq!(selected.cursor(), selection_target);
{
let textarea = selected.textarea.borrow();
assert_eq!(textarea.cursor(), (0, selection_target / combining.len()));
assert_eq!(
textarea.selection_range(),
Some(((0, 0), (0, selection_target / combining.len())))
);
assert!(textarea.search_pattern().is_none());
assert!(textarea.atomic_ranges().is_empty());
}
assert_eq!(selected.selection_byte_range(), Some((0, selection_target)));
assert_eq!(selected.text(), text);
}
#[test]
fn one_mib_zero_width_right_and_shift_right_are_exact_near_end() {
let combining = "\u{0301}";
let text = format!(
"{}{}",
combining.repeat((MAX_PROMPT_BYTES - 4) / combining.len()),
'\u{e0100}'
);
assert_eq!(text.len(), MAX_PROMPT_BYTES);
let target_scalar = text.chars().count() - 2;
let target = text
.char_indices()
.nth(target_scalar)
.map_or(text.len(), |(offset, _)| offset);
let mut right = PromptEditor::new(&text, target);
assert_eq!(right.move_right(1, 80), PromptMovementOutcome::Moved);
assert_eq!(right.cursor(), target + combining.len());
assert_eq!(right.text(), text);
{
let textarea = right.textarea.borrow();
assert!(textarea.search_pattern().is_none());
assert!(textarea.atomic_ranges().is_empty());
}
let mut shift_right = PromptEditor::new(&text, target);
assert_eq!(
shift_right.extend_selection_right(1, 80),
PromptMovementOutcome::Moved
);
assert_eq!(shift_right.cursor(), target + combining.len());
assert_eq!(shift_right.selection_text().as_deref(), Some(combining));
assert_eq!(shift_right.text(), text);
let textarea = shift_right.textarea.borrow();
assert!(textarea.search_pattern().is_none());
assert!(textarea.atomic_ranges().is_empty());
}
#[test]
fn zero_width_right_and_shift_right_use_search_without_changing_text() {
let scalars = usize::from(u16::MAX) + 8;
let text = "\u{0301}".repeat(scalars);
let mut editor = PromptEditor::new(&text, 0);
editor.move_right(1, 80);
assert_eq!(editor.cursor(), "\u{0301}".len());
editor.extend_selection_right(1, 80);
assert_eq!(editor.cursor(), "\u{0301}".len() * 2);
assert_eq!(editor.selection_text().as_deref(), Some("\u{0301}"));
assert_eq!(editor.text(), text);
assert!(editor.textarea.borrow().search_pattern().is_none());
}
#[test]
fn high_atomic_movement_preserves_edit_history() {
let combining = "\u{0301}";
let text = combining.repeat(MAX_PROMPT_BYTES / combining.len() - 2);
let target = text.len() - combining.len() * 4;
let mut editor = PromptEditor::new(&text, 0);
assert_eq!(editor.insert_char('x', 1, 80), PromptEditResult::Changed);
assert_eq!(editor.move_to_byte_inner(target, false), Ok(1));
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), text);
}
#[test]
fn multiline_cursor_conversion_preserves_newline_boundaries() {
let editor = editor("one\ntwo\n");
assert_eq!(cursor_for_byte_offset("one\ntwo\n", 4), (1, 0));
assert_eq!(cursor_for_byte_offset("one\ntwo\n", 8), (2, 0));
assert_eq!(
byte_offset_for_cursor(editor.textarea.borrow().lines(), (2, 0)),
8
);
}
#[test]
fn explicit_editing_and_history_do_not_use_input_dispatch() {
let mut editor = editor("ab");
editor.move_to_byte(1, 3, 20);
assert_eq!(editor.insert_char('é', 3, 20), PromptEditResult::Changed);
assert_eq!(editor.text(), "aéb");
assert_eq!(editor.undo(3, 20), PromptEditResult::Changed);
assert_eq!(editor.text(), "ab");
assert_eq!(editor.redo(3, 20), PromptEditResult::Changed);
assert_eq!(editor.text(), "aéb");
}
#[test]
fn paste_is_normalized_and_total_size_is_rejected_atomically() {
let mut editor = PromptEditor::default();
assert_eq!(
editor.insert_paste(&"x".repeat(MAX_PROMPT_BYTES), 1, 80),
PromptEditResult::Changed
);
let before = editor.text();
assert_eq!(
editor.insert_paste("y", 1, 80),
PromptEditResult::RejectedTooLarge
);
assert_eq!(editor.text(), before);
editor.clear();
assert_eq!(
editor.insert_paste("a\r\nb\rc", 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.text(), "a\nb\nc");
}
#[test]
fn normalization_preserves_tabs_newlines_and_unicode_but_filters_controls() {
assert_eq!(
normalize_prompt_text("a\0\x01\x1b\x7f\tb\r\nc\rdé界"),
"a\tb\nc\ndé界"
);
}
#[test]
fn normalization_filters_all_prohibited_controls_in_one_pass() {
assert_eq!(
normalize_prompt_text("a\0\x07\x08\x0b\x0c\x1b\x7f\tb\nc\r\nd\reé界"),
"a\tb\nc\nd\neé界"
);
}
#[test]
fn replacement_clamps_to_utf8_boundaries_without_splitting_text() {
let mut editor = editor("é界");
assert_eq!(
editor.replace_byte_range(1, "é".len(), "x", 1, 80),
PromptEditOutcome::Changed
);
assert_eq!(editor.text(), "x界");
}
#[test]
fn exact_utf8_limit_and_replacement_accounting_are_byte_based() {
let mut editor = editor(&"é".repeat(MAX_PROMPT_BYTES / 2));
assert_eq!(editor.text().len(), MAX_PROMPT_BYTES);
assert_eq!(
editor.insert_paste("é", 1, 80),
PromptEditResult::RejectedTooLarge
);
editor.start_selection_at(0);
editor.update_selection_at("é".len());
assert_eq!(editor.insert_paste("x", 1, 80), PromptEditResult::Changed);
assert_eq!(editor.text().len(), MAX_PROMPT_BYTES - 1);
}
#[test]
fn oversized_replacement_can_not_delete_or_change_selection() {
let mut editor = editor(&"x".repeat(MAX_PROMPT_BYTES));
editor.start_selection_at(0);
editor.update_selection_at(1);
let before_cursor = editor.cursor();
assert_eq!(
editor.insert_paste("yy", 1, 80),
PromptEditResult::RejectedTooLarge
);
assert_eq!(editor.text().len(), MAX_PROMPT_BYTES);
assert_eq!(editor.cursor(), before_cursor);
assert_eq!(editor.selection_text().as_deref(), Some("x"));
}
#[test]
fn rejected_edit_does_not_consume_or_add_undo_history() {
let mut editor = editor(&"x".repeat(MAX_PROMPT_BYTES));
editor.move_to_byte(0, 1, 80);
editor.extend_selection_right(1, 80);
assert_eq!(editor.insert_char('y', 1, 80), PromptEditOutcome::Changed);
let before_rejection = editor.text();
assert_eq!(
editor.insert_paste("zz", 1, 80),
PromptEditOutcome::RejectedTooLarge
);
assert_eq!(editor.text(), before_rejection);
assert_eq!(editor.undo(1, 80), PromptEditOutcome::Changed);
assert_eq!(editor.text().as_bytes()[0], b'x');
}
#[test]
fn prohibited_typed_controls_are_unchanged() {
let mut editor = editor("ab");
editor.move_to_byte(1, 1, 80);
assert_eq!(editor.insert_char('\0', 1, 80), PromptEditResult::Unchanged);
assert_eq!(editor.text(), "ab");
assert_eq!(editor.cursor(), 1);
}
#[test]
fn selection_and_autocomplete_range_replacement_are_plain_text_operations() {
let mut editor = editor("hello world");
editor.start_selection_at(0);
editor.update_selection_at(5);
assert_eq!(editor.selection_text().as_deref(), Some("hello"));
assert_eq!(
editor.replace_byte_range(0, 5, "hi", 2, 20),
PromptEditResult::Changed
);
assert_eq!(editor.text(), "hi world");
assert_eq!(editor.cursor(), 2);
assert!(!editor.selection_active());
}
#[test]
fn shifted_cursor_operations_extend_and_normal_cursor_operations_collapse_selection() {
let mut editor = editor("one two");
editor.move_to_byte(3, 1, 80);
editor.extend_selection_right(1, 80);
editor.extend_selection_right(1, 80);
assert_eq!(editor.selection_text().as_deref(), Some(" t"));
editor.move_left(1, 80);
assert_eq!(editor.cursor(), 3);
assert!(!editor.selection_active());
editor.move_word_right(1, 80);
assert_eq!(editor.cursor(), 4);
}
#[test]
fn movement_and_selection_support_columns_beyond_u16_max() {
let prefix = usize::from(u16::MAX) + 17;
let text = format!("{}\nlast", "x".repeat(prefix));
let mut editor = PromptEditor::new(&text, 0);
editor.move_to_byte(prefix, 1, 80);
assert_eq!(editor.cursor(), prefix);
editor.move_to_byte(prefix + 1, 1, 80);
assert_eq!(editor.cursor(), prefix + 1);
editor.start_selection_at(prefix - 2);
editor.update_selection_at(prefix + 1);
assert_eq!(editor.selection_text().as_deref(), Some("xx\n"));
}
#[test]
fn movement_and_select_all_support_rows_beyond_u16_max() {
let line_count = usize::from(u16::MAX) + 2;
let text = (0..line_count).map(|_| "x").collect::<Vec<_>>().join("\n");
let mut editor = PromptEditor::new(&text, 0);
editor.move_to_byte(text.len(), 1, 80);
assert_eq!(editor.cursor(), text.len());
editor.select_all();
assert_eq!(editor.selection_text().as_deref(), Some(text.as_str()));
}
#[test]
fn long_range_replacement_keeps_native_two_step_undo_and_redo_history() {
let start = usize::from(u16::MAX) + 3;
let text = "x".repeat(start + 8);
let mut editor = PromptEditor::new(&text, text.len());
let before = editor.text();
let after_deletion = format!("{}{}", &before[..start], &before[start + 4..]);
let after_replacement = format!("{}{}{}", &before[..start], "yz", &before[start + 4..]);
assert_eq!(
editor.replace_byte_range(start, start + 4, "yz", 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.text(), after_replacement);
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), after_deletion);
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), before);
assert_eq!(editor.undo(1, 80), PromptEditResult::Unchanged);
assert_eq!(editor.redo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), after_deletion);
assert_eq!(editor.redo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), after_replacement);
assert_eq!(editor.redo(1, 80), PromptEditResult::Unchanged);
}
#[test]
fn native_history_is_bounded_after_more_than_fifty_ordinary_edits() {
let mut editor = PromptEditor::default();
for _ in 0..60 {
assert_eq!(editor.insert_char('x', 1, 80), PromptEditResult::Changed);
}
assert_eq!(editor.textarea.borrow().max_histories(), 50);
let mut undo_steps = 0;
while editor.undo(1, 80) == PromptEditResult::Changed {
undo_steps += 1;
}
assert_eq!(undo_steps, 50);
assert_eq!(editor.text(), "x".repeat(10));
let mut redo_steps = 0;
while editor.redo(1, 80) == PromptEditResult::Changed {
redo_steps += 1;
}
assert_eq!(redo_steps, 50);
assert_eq!(editor.text(), "x".repeat(60));
}
#[test]
fn content_revision_ignores_noop_cursor_selection_and_visibility_changes() {
let mut editor = editor("abc");
let revision = editor.content_revision();
editor.move_to_byte(1, 1, 80);
editor.start_selection_at(0);
editor.update_selection_at(2);
editor.set_cursor_visible(false);
editor.toggle_cursor();
assert_eq!(editor.content_revision(), revision);
assert_eq!(editor.set_text("abc", 0), PromptEditResult::Changed);
assert_eq!(editor.content_revision(), revision);
assert_eq!(editor.set_text("abc", 0), PromptEditResult::Unchanged);
assert_eq!(editor.content_revision(), revision);
}
#[test]
fn content_revision_changes_for_accepted_set_clear_and_edit_operations() {
let mut editor = editor("abc");
let initial = editor.content_revision();
assert_eq!(editor.set_text("abcd", 4), PromptEditResult::Changed);
assert_eq!(editor.content_revision(), initial + 1);
assert_eq!(editor.set_text("abcd", 0), PromptEditResult::Changed);
assert_eq!(editor.content_revision(), initial + 1);
assert_eq!(editor.clear(), PromptEditResult::Changed);
assert_eq!(editor.content_revision(), initial + 2);
assert_eq!(editor.clear(), PromptEditResult::Unchanged);
assert_eq!(editor.content_revision(), initial + 2);
assert_eq!(editor.insert_char('x', 1, 80), PromptEditResult::Changed);
assert_eq!(editor.content_revision(), initial + 3);
}
#[test]
fn content_revision_ignores_rejected_and_noop_history_operations() {
let mut editor = editor(&"x".repeat(MAX_PROMPT_BYTES));
let initial = editor.content_revision();
assert_eq!(
editor.insert_char('y', 1, 80),
PromptEditResult::RejectedTooLarge
);
assert_eq!(editor.content_revision(), initial);
assert_eq!(
editor.set_text(&"y".repeat(MAX_PROMPT_BYTES + 1), 0),
PromptEditResult::RejectedTooLarge
);
assert_eq!(editor.content_revision(), initial);
editor.move_to_byte(0, 1, 80);
assert_eq!(editor.undo(1, 80), PromptEditResult::Unchanged);
assert_eq!(editor.redo(1, 80), PromptEditResult::Unchanged);
assert_eq!(editor.content_revision(), initial);
}
#[test]
fn content_revision_changes_for_edit_undo_and_redo() {
let mut editor = PromptEditor::default();
let initial = editor.content_revision();
assert_eq!(editor.insert_char('x', 1, 80), PromptEditResult::Changed);
assert_eq!(editor.content_revision(), initial + 1);
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.content_revision(), initial + 2);
assert_eq!(editor.redo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.content_revision(), initial + 3);
}
#[test]
fn content_revision_tracks_take_restore_and_not_commit() {
let mut editor = editor("draft");
let initial = editor.content_revision();
assert_eq!(editor.take_for_submit(), "draft");
assert_eq!(editor.content_revision(), initial + 1);
assert!(editor.is_empty());
assert_eq!(
editor.restore_rejected_submit("ignored fallback".to_string()),
PromptEditResult::Changed
);
assert_eq!(editor.content_revision(), initial + 2);
assert_eq!(editor.text(), "draft");
editor.commit_submission();
assert_eq!(editor.content_revision(), initial + 2);
}
#[test]
fn empty_take_and_restore_do_not_advance_content_revision() {
let mut editor = PromptEditor::default();
let initial = editor.content_revision();
assert_eq!(editor.take_for_submit(), "");
assert_eq!(editor.content_revision(), initial);
assert_eq!(
editor.restore_rejected_submit("ignored".to_string()),
PromptEditResult::Changed
);
assert_eq!(editor.content_revision(), initial);
}
#[test]
fn prompt_empty_and_bang_queries_do_not_materialize_text() {
let mut editor = PromptEditor::default();
assert!(editor.is_empty());
assert!(!editor.starts_with_bang());
assert_eq!(
editor.insert_paste("!git status", 1, 80),
PromptEditResult::Changed
);
assert!(!editor.is_empty());
assert!(editor.starts_with_bang());
}
#[test]
fn near_current_long_column_uses_a_bounded_native_route() {
let line_len = usize::from(u16::MAX) + 100;
let text = "x".repeat(line_len);
let mut editor = PromptEditor::new(&text, line_len - 3);
let calls = editor.move_to_byte_inner(line_len - 1, false).unwrap();
assert!(
calls <= 3,
"near-current movement used {calls} native calls"
);
assert_eq!(editor.cursor(), line_len - 1);
}
#[test]
fn near_end_of_one_mib_line_uses_a_bounded_native_route() {
let text = "x".repeat(MAX_PROMPT_BYTES);
let mut editor = PromptEditor::new(&text, 0);
let target = text.len() - 7;
let calls = editor.move_to_byte_inner(target, false).unwrap();
assert!(calls <= 12, "near-end movement used {calls} native calls");
assert_eq!(editor.cursor(), target);
}
#[test]
fn near_bottom_past_u16_rows_uses_a_bounded_native_route() {
let line_count = usize::from(u16::MAX) + 64;
let text = (0..line_count).map(|_| "x").collect::<Vec<_>>().join("\n");
let mut editor = PromptEditor::new(&text, 0);
let target_row = line_count - 3;
let target = target_row * 2;
let calls = editor.move_to_byte_inner(target, false).unwrap();
assert!(
calls <= 16,
"near-bottom movement used {calls} native calls"
);
assert_eq!(editor.cursor(), target);
}
#[test]
fn reverse_selection_collapsing_to_anchor_keeps_native_selection() {
let combining = "\u{0301}";
let anchor = usize::from(u16::MAX) + 8;
let text = combining.repeat(anchor + 4);
let mut editor = PromptEditor::new(&text, anchor * combining.len());
assert_eq!(
editor.start_selection_at(anchor * combining.len()),
PromptMovementOutcome::Unchanged
);
assert_eq!(editor.update_selection_at(0), PromptMovementOutcome::Moved);
assert_eq!(
editor.update_selection_at(anchor * combining.len()),
PromptMovementOutcome::Moved
);
assert!(editor.textarea.borrow().is_selecting());
{
let textarea = editor.textarea.borrow();
assert_eq!(textarea.selection_range(), Some(((0, anchor), (0, anchor))));
}
assert_eq!(
editor.extend_selection_right(1, 80),
PromptMovementOutcome::Moved
);
assert_eq!(
editor.selection_byte_range(),
Some((anchor * combining.len(), (anchor + 1) * combining.len()))
);
}
#[test]
fn multiline_high_column_movement_is_bounded_and_exact() {
let combining = "\u{0301}";
let column = usize::from(u16::MAX) + 8;
let text = format!("x\n{}{}", combining, combining.repeat(column + 4));
let target = 2 + column * combining.len();
let mut editor = PromptEditor::new(&text, 0);
assert_eq!(editor.move_to_byte_inner(target, false), Ok(1));
assert_eq!(editor.cursor(), target);
assert_eq!(editor.start_selection_at(0), PromptMovementOutcome::Moved);
assert_eq!(
editor.update_selection_at(target),
PromptMovementOutcome::Moved
);
assert_eq!(editor.selection_byte_range(), Some((0, target)));
}
#[test]
fn one_mib_selection_crossing_anchor_forward_is_bounded_and_exact() {
let combining = "\u{0301}";
let text = combining.repeat(MAX_PROMPT_BYTES / combining.len());
let anchor = usize::from(u16::MAX) + 20_000;
let current = anchor - 10_000;
let target = anchor + 20_000;
let mut textarea = TextArea::new(vec![text.clone()]);
move_textarea_cursor_to(&mut textarea, (0, anchor)).unwrap();
textarea.start_selection();
move_textarea_cursor_to(&mut textarea, (0, current)).unwrap();
let calls = move_textarea_cursor_to(&mut textarea, (0, target)).unwrap();
assert!(
calls <= 8,
"forward anchor crossing used {calls} native calls"
);
assert_eq!(textarea.cursor(), (0, target));
assert_eq!(textarea.selection_range(), Some(((0, anchor), (0, target))));
assert!(textarea.is_selecting());
assert_eq!(textarea.lines(), &[text]);
assert!(textarea.search_pattern().is_none());
assert!(textarea.atomic_ranges().is_empty());
}
#[test]
fn one_mib_selection_crossing_anchor_backward_is_bounded_and_exact() {
let combining = "\u{0301}";
let text = combining.repeat(MAX_PROMPT_BYTES / combining.len());
let anchor = usize::from(u16::MAX) + 20_000;
let current = anchor + 20_000;
let target = anchor - 10_000;
let mut textarea = TextArea::new(vec![text.clone()]);
move_textarea_cursor_to(&mut textarea, (0, anchor)).unwrap();
textarea.start_selection();
move_textarea_cursor_to(&mut textarea, (0, current)).unwrap();
let calls = move_textarea_cursor_to(&mut textarea, (0, target)).unwrap();
assert!(
calls <= 8,
"backward anchor crossing used {calls} native calls"
);
assert_eq!(textarea.cursor(), (0, target));
assert_eq!(textarea.selection_range(), Some(((0, target), (0, anchor))));
assert!(textarea.is_selecting());
assert_eq!(textarea.lines(), &[text]);
assert!(textarea.search_pattern().is_none());
assert!(textarea.atomic_ranges().is_empty());
}
proptest::proptest! {
#[test]
fn curated_unicode_edits_preserve_text_and_valid_cursor(
parts in proptest::collection::vec(
proptest::sample::select(vec!["a", "é", "界", "e\u{301}", "🙂", "\n"]),
0..32
),
movement_count in 0..32usize
) {
let text = parts.concat();
let mut editor = PromptEditor::new(&text, 0);
for step in 0..movement_count {
match step % 4 {
0 => {
editor.move_right(1, 80);
}
1 => {
editor.move_down(1, 80);
}
2 => {
editor.move_left(1, 80);
}
_ => {
editor.move_up(1, 80);
}
}
}
let pre_edit_cursor = editor.cursor();
assert!(text.is_char_boundary(pre_edit_cursor));
let expected = format!("{}X{}", &text[..pre_edit_cursor], &text[pre_edit_cursor..]);
editor.insert_char('X', 1, 80);
assert_eq!(editor.text(), expected);
assert!(editor.text().is_char_boundary(editor.cursor()));
editor.backspace(1, 80);
assert_eq!(editor.text(), text);
assert!(text.is_char_boundary(editor.cursor()));
}
}
#[test]
fn large_paste_uses_a_safe_label_but_expands_for_prompt_text() {
let payload = (1..=320)
.map(|line| format!("line-{line}"))
.collect::<Vec<_>>()
.join("\n");
let mut editor = PromptEditor::default();
assert_eq!(
editor.insert_paste(&payload, 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.visible_text(), "[Pasted #1 · 320 lines]");
assert_eq!(editor.text(), payload);
assert_eq!(editor.cursor(), payload.len());
assert!(!format!("{editor:?}").contains("line-1"));
}
#[test]
fn paste_collapse_uses_line_and_byte_boundaries() {
let mut editor = PromptEditor::default();
assert_eq!(
editor.insert_paste("a\nb", 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.visible_text(), "a\nb");
editor.clear();
let short = "x".repeat(COLLAPSED_PASTE_BYTE_THRESHOLD);
assert_eq!(
editor.insert_paste(&short, 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.visible_text(), short);
editor.clear();
let long = "x".repeat(COLLAPSED_PASTE_BYTE_THRESHOLD + 1);
assert_eq!(editor.insert_paste(&long, 1, 80), PromptEditResult::Changed);
assert_eq!(editor.visible_text(), "[Pasted #1 · 1 lines]");
assert_eq!(editor.text(), long);
}
#[test]
fn slash_and_shell_pastes_remain_ordinary_text() {
for payload in ["/help\none\ntwo", "!echo hi\none\ntwo"] {
let mut editor = PromptEditor::default();
assert_eq!(
editor.insert_paste(payload, 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.visible_text(), payload);
assert_eq!(editor.text(), payload);
}
}
#[test]
fn collapsed_paste_is_one_indivisible_edit_for_movement_deletion_and_history() {
let payload = "one\ntwo\nthree";
let mut editor = PromptEditor::default();
editor.insert_paste(payload, 1, 80);
assert_eq!(editor.move_left(1, 80), PromptMovementOutcome::Moved);
assert_eq!(editor.cursor(), 0);
assert_eq!(editor.move_right(1, 80), PromptMovementOutcome::Moved);
assert_eq!(editor.cursor(), payload.len());
assert_eq!(editor.backspace(1, 80), PromptEditResult::Changed);
assert!(editor.text().is_empty());
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), payload);
assert_eq!(editor.redo(1, 80), PromptEditResult::Changed);
assert!(editor.text().is_empty());
}
#[test]
fn second_collapsed_paste_undo_restores_cursor_after_movement() {
let first = "one\ntwo\nthree";
let second = "four\nfive\nsix";
let mut editor = PromptEditor::default();
assert_eq!(editor.insert_paste(first, 1, 80), PromptEditResult::Changed);
assert_eq!(editor.move_left(1, 80), PromptMovementOutcome::Moved);
let cursor_before_second = editor.cursor();
assert_eq!(cursor_before_second, 0);
assert_eq!(
editor.insert_paste(second, 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), first);
assert_eq!(editor.cursor(), cursor_before_second);
}
#[test]
fn collapsed_paste_history_keeps_prior_native_edits_in_the_same_undo_chain() {
let payload = "one\ntwo\nthree";
let mut editor = PromptEditor::default();
editor.insert_char('a', 1, 80);
editor.insert_char('b', 1, 80);
editor.insert_paste(payload, 1, 80);
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), "ab");
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), "a");
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert!(editor.text().is_empty());
assert_eq!(editor.undo(1, 80), PromptEditResult::Unchanged);
assert_eq!(editor.redo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), "a");
assert_eq!(editor.redo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), "ab");
assert_eq!(editor.redo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), format!("ab{payload}"));
assert_eq!(editor.redo(1, 80), PromptEditResult::Unchanged);
}
#[test]
fn selection_and_submit_expand_collapsed_paste_payload() {
let payload = "one\ntwo\nthree";
let prefix = "before ";
let mut editor = PromptEditor::new(prefix, prefix.len());
editor.insert_paste(payload, 1, 80);
assert_eq!(
editor.start_selection_at(prefix.len() + 1),
PromptMovementOutcome::Moved
);
assert_eq!(
editor.update_selection_at(prefix.len() + payload.len() - 1),
PromptMovementOutcome::Moved
);
assert_eq!(editor.selection_text().as_deref(), Some(payload));
assert_eq!(
editor.selection_byte_range(),
Some((prefix.len(), prefix.len() + payload.len()))
);
assert_eq!(editor.take_for_submit(), format!("{prefix}{payload}"));
}
#[test]
fn expanded_prompt_limit_rejects_edits_without_truncating_a_collapsed_payload() {
let payload = "one\ntwo\nthree";
let prefix = "x".repeat(MAX_PROMPT_BYTES - payload.len());
let mut editor = PromptEditor::new(&prefix, prefix.len());
assert_eq!(
editor.insert_paste(payload, 1, 80),
PromptEditResult::Changed
);
let before = editor.text();
let before_cursor = editor.cursor();
assert_eq!(
editor.insert_paste("y", 1, 80),
PromptEditResult::RejectedTooLarge
);
assert_eq!(editor.text(), before);
assert_eq!(editor.cursor(), before_cursor);
}
#[test]
fn three_line_collapsed_paste_at_the_limit_uses_checked_expanded_bytes() {
let payload = "a\nb\nc";
let prefix = "x".repeat(MAX_PROMPT_BYTES - payload.len());
let mut editor = PromptEditor::new(&prefix, prefix.len());
assert_eq!(
editor.insert_paste(payload, 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.checked_expanded_text_len(), Some(MAX_PROMPT_BYTES));
assert_eq!(editor.text().len(), MAX_PROMPT_BYTES);
assert!(editor.visible_text().ends_with("[Pasted #1 · 3 lines]"));
assert_eq!(
editor.insert_char('x', 1, 80),
PromptEditResult::RejectedTooLarge
);
}
#[test]
fn multiple_collapsed_pastes_keep_payloads_and_delete_as_atoms() {
let first = "one\ntwo\nthree";
let second = "four\nfive\nsix";
let mut editor = PromptEditor::default();
assert_eq!(editor.insert_paste(first, 1, 80), PromptEditResult::Changed);
assert_eq!(editor.insert_char(' ', 1, 80), PromptEditResult::Changed);
assert_eq!(
editor.insert_paste(second, 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.text(), format!("{first} {second}"));
assert_eq!(
editor.visible_text(),
"[Pasted #1 · 3 lines] [Pasted #2 · 3 lines]"
);
assert_eq!(editor.backspace(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), format!("{first} "));
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), format!("{first} {second}"));
editor.move_to_byte(first.len() + 1, 1, 80);
assert_eq!(editor.delete_forward(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), format!("{first} "));
}
#[test]
fn selection_crossing_a_collapsed_paste_deletes_the_whole_atom() {
let payload = "one\ntwo\nthree";
let mut editor = PromptEditor::new("pre", 3);
editor.insert_paste(payload, 1, 80);
editor.insert_char('s', 1, 80);
editor.start_selection_at(0);
editor.update_selection_at("pre".len() + 1);
let expected_selection = format!("pre{payload}");
assert_eq!(
editor.selection_text().as_deref(),
Some(expected_selection.as_str())
);
assert_eq!(editor.backspace(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), "s");
assert_eq!(editor.undo(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), format!("pre{payload}s"));
}
#[test]
fn rejected_submit_restores_collapsed_payload_and_visible_label() {
let payload = "one\ntwo\nthree";
let mut editor = PromptEditor::default();
editor.insert_paste(payload, 1, 80);
assert_eq!(editor.take_for_submit(), payload);
assert!(editor.is_empty());
assert_eq!(
editor.restore_rejected_submit("ignored fallback".to_string()),
PromptEditResult::Changed
);
assert_eq!(editor.text(), payload);
assert_eq!(editor.visible_text(), "[Pasted #1 · 3 lines]");
assert_eq!(editor.cursor(), payload.len());
}
#[test]
fn collapsed_atom_selection_maps_mouse_endpoints_by_drag_direction() {
let payload = "one\ntwo\nthree";
let inside_payload = payload.find("two").unwrap() + 1;
let mut reverse = PromptEditor::default();
reverse.insert_paste(payload, 1, 80);
reverse.move_to_byte(payload.len(), 1, 80);
reverse.start_selection_at(payload.len());
assert_eq!(
reverse.update_selection_at(inside_payload),
PromptMovementOutcome::Moved
);
assert_eq!(reverse.selection_text().as_deref(), Some(payload));
let mut forward = PromptEditor::default();
forward.insert_paste(payload, 1, 80);
forward.move_to_byte(0, 1, 80);
forward.start_selection_at(0);
assert_eq!(
forward.update_selection_at(inside_payload),
PromptMovementOutcome::Moved
);
assert_eq!(forward.selection_text().as_deref(), Some(payload));
}
#[test]
fn typed_paste_label_lookalike_remains_normal_text() {
let label = "[Pasted #1 · 3 lines]";
let mut editor = PromptEditor::default();
for ch in label.chars() {
assert_eq!(editor.insert_char(ch, 1, 80), PromptEditResult::Changed);
}
assert_eq!(editor.visible_text(), label);
assert_eq!(editor.backspace(1, 80), PromptEditResult::Changed);
assert_eq!(editor.text(), &label[..label.len() - 1]);
}
#[test]
fn deleting_atom_before_identical_typed_label_tracks_backward_range() {
let payload = "one\ntwo\nthree";
let label = "[Pasted #1 · 3 lines]";
let mut editor = PromptEditor::default();
assert_eq!(
editor.insert_paste(payload, 1, 80),
PromptEditResult::Changed
);
for ch in label.chars() {
assert_eq!(editor.insert_char(ch, 1, 80), PromptEditResult::Changed);
}
assert_eq!(editor.visible_text(), format!("{label}{label}"));
assert_eq!(
editor.move_to_byte(payload.len(), 1, 80),
PromptMovementOutcome::Moved
);
assert_eq!(editor.backspace(1, 80), PromptEditResult::Changed);
assert_eq!(editor.visible_text(), label);
assert_eq!(editor.text(), label);
assert_eq!(editor.take_for_submit(), label);
}
#[test]
fn deleting_atom_after_identical_typed_label_tracks_forward_range() {
let payload = "one\ntwo\nthree";
let label = "[Pasted #1 · 3 lines]";
let mut editor = PromptEditor::default();
for ch in label.chars() {
assert_eq!(editor.insert_char(ch, 1, 80), PromptEditResult::Changed);
}
assert_eq!(
editor.insert_paste(payload, 1, 80),
PromptEditResult::Changed
);
assert_eq!(editor.visible_text(), format!("{label}{label}"));
assert_eq!(
editor.move_to_byte(label.len(), 1, 80),
PromptMovementOutcome::Moved
);
assert_eq!(editor.delete_forward(1, 80), PromptEditResult::Changed);
assert_eq!(editor.visible_text(), label);
assert_eq!(editor.text(), label);
assert_eq!(editor.take_for_submit(), label);
}