From ff7e9c0435ffbc109586bab0b2851e287842f088 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 19:27:57 -0400 Subject: [PATCH] Test iris's editor, which had no coverage and was just rewritten iris has no tests at all, and the rule here is not to erect a harness where there is none -- but the editor is the exception on both counts. It is the one part of the library that is pure logic over a string and a layout rather than something needing a GPU and a window, and it was just rewritten wholesale onto parley's selection model with no way to exercise it: input cannot be synthesised in the headless compositor the examples run under, because it has no seat devices. Fourteen tests over insert, backspace, delete, span clearing, select-all, motion, single- versus multi-line, and take. Two are there for specific things the rewrite could plausibly have broken: the IME preedit path, which resends its whole composition each keystroke so `replace` has to remove exactly what it added last time, and editing text with multi-byte characters, since parley addresses by byte offset where the old code counted (line, index). Co-Authored-By: Claude Opus 5 --- iris/src/widget/text/edit.rs | 155 +++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/iris/src/widget/text/edit.rs b/iris/src/widget/text/edit.rs index 90f6eea..4f209fe 100644 --- a/iris/src/widget/text/edit.rs +++ b/iris/src/widget/text/edit.rs @@ -521,3 +521,158 @@ impl> TextEditable for I { } } } + +#[cfg(test)] +mod tests { + use super::*; + use iris_core::{TextAttrs, TextBuffer}; + + /// The editor is the one part of iris that is pure logic over a string and + /// a layout, and it was rewritten wholesale when the text stack changed -- + /// so it is the one part worth testing directly. Everything else here + /// needs a GPU and a window. + fn edit(text: &str, mode: EditMode) -> (TextEdit, TextData) { + let view = TextView::new(TextBuffer::new(text), TextAttrs::default(), None); + (TextEdit::new(view, mode), TextData::default()) + } + + fn ctx<'a>(text: &'a mut TextEdit, data: &'a mut TextData) -> TextEditCtx<'a> { + TextEditCtx { text, data } + } + + fn content(text: &TextEdit) -> String { + text.buf.text().to_string() + } + + #[test] + fn insert_at_the_caret() { + let (mut t, mut d) = edit("ac", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(1); + ctx(&mut t, &mut d).insert("b"); + assert_eq!(content(&t), "abc"); + assert_eq!(t.selection.unwrap().focus().index(), 2); + } + + #[test] + fn backspace_removes_the_character_before_the_caret() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(2); + ctx(&mut t, &mut d).backspace(false); + assert_eq!(content(&t), "ac"); + } + + #[test] + fn backspace_at_the_start_does_nothing() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).backspace(false); + assert_eq!(content(&t), "abc"); + } + + #[test] + fn delete_removes_the_character_after_the_caret() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(1); + ctx(&mut t, &mut d).delete(false); + assert_eq!(content(&t), "ac"); + } + + #[test] + fn delete_at_the_end_does_nothing() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(3); + ctx(&mut t, &mut d).delete(false); + assert_eq!(content(&t), "abc"); + } + + #[test] + fn select_all_then_typing_replaces_everything() { + let (mut t, mut d) = edit("hello", EditMode::SingleLine); + ctx(&mut t, &mut d).select_all(); + assert_eq!(t.selected_text().as_deref(), Some("hello")); + ctx(&mut t, &mut d).insert("x"); + assert_eq!(content(&t), "x"); + } + + #[test] + fn clearing_a_span_leaves_the_caret_at_its_start() { + let (mut t, mut d) = edit("abcdef", EditMode::SingleLine); + ctx(&mut t, &mut d).select_all(); + assert!(ctx(&mut t, &mut d).clear_span()); + assert_eq!(content(&t), ""); + assert_eq!(t.selection.unwrap().focus().index(), 0); + } + + #[test] + fn a_single_line_field_refuses_newlines() { + let (mut t, mut d) = edit("", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).insert("a\nb"); + assert_eq!(content(&t), "ab"); + ctx(&mut t, &mut d).newline(); + assert_eq!(content(&t), "ab"); + } + + #[test] + fn a_multi_line_field_keeps_newlines() { + let (mut t, mut d) = edit("", EditMode::MultiLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).insert("a\nb"); + assert_eq!(content(&t), "a\nb"); + } + + #[test] + fn take_empties_the_field_and_hands_back_what_was_there() { + let (mut t, mut d) = edit("some text", EditMode::SingleLine); + assert_eq!(ctx(&mut t, &mut d).take(), "some text"); + assert_eq!(content(&t), ""); + } + + /// The IME's preedit path: each keystroke resends the whole composition, + /// so `replace` has to remove exactly what it added last time. + #[test] + fn ime_preedit_replaces_its_own_previous_text() { + let (mut t, mut d) = edit("", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).replace(0, "n"); + assert_eq!(content(&t), "n"); + ctx(&mut t, &mut d).replace(1, "ni"); + assert_eq!(content(&t), "ni"); + ctx(&mut t, &mut d).replace(2, "に"); + assert_eq!(content(&t), "に"); + } + + #[test] + fn motion_moves_the_caret_and_shift_extends_a_span() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).motion(Motion::Right, false); + assert_eq!(t.selection.unwrap().focus().index(), 1); + ctx(&mut t, &mut d).motion(Motion::Right, true); + assert_eq!(t.selected_text().as_deref(), Some("b")); + } + + /// Collapsing a span with an unshifted arrow goes to the near end rather + /// than stepping one character from the focus. + #[test] + fn an_unshifted_arrow_collapses_a_span_to_its_edge() { + let (mut t, mut d) = edit("abcdef", EditMode::SingleLine); + ctx(&mut t, &mut d).select_all(); + ctx(&mut t, &mut d).motion(Motion::Left, false); + assert_eq!(t.selection.unwrap().focus().index(), 0); + + ctx(&mut t, &mut d).select_all(); + ctx(&mut t, &mut d).motion(Motion::Right, false); + assert_eq!(t.selection.unwrap().focus().index(), 6); + } + + /// Byte offsets, not character counts: a caret placed after a multi-byte + /// character must not split it. + #[test] + fn multibyte_text_is_edited_by_byte_offset() { + let (mut t, mut d) = edit("aé", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(3); + ctx(&mut t, &mut d).backspace(false); + assert_eq!(content(&t), "a"); + } +}