Files
figcraft/crates/engine/src/canvas/textedit/tests.rs
T

572 lines
28 KiB
Rust

use super::*;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration};
use serde_json::json;
fn setup(text: &str) -> (Session, NodeId) {
let mut s = Session::new();
let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap();
let id = crate::params::id_of(&r["id"]).unwrap();
(s, id)
}
fn data(s: &Session, id: NodeId) -> TextData {
s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone()
}
fn sel(s: &Session) -> (usize, usize) {
let te = s.ds().unwrap().text_edit.clone().unwrap();
(te.anchor, te.caret)
}
#[test]
fn whole_editing_session_is_one_undo_step() {
let (mut s, id) = setup("Hello");
let undo_before = s.ds().unwrap().history.undo.len();
begin(&mut s, id, Some(5), false).unwrap();
insert(&mut s, " big").unwrap();
insert(&mut s, " world").unwrap();
key(&mut s, "Backspace", false, false, false).unwrap();
// Styling a range mid-session joins the same step.
set_caret(&mut s, 0, false).unwrap();
set_caret(&mut s, 5, true).unwrap();
s.execute("text.bold", &json!({})).unwrap();
s.execute("text.alignCenter", &json!({})).unwrap();
end(&mut s).unwrap();
let t = data(&s, id);
assert_eq!(t.characters, "Hello big worl");
assert_eq!(t.style_at(0).font_style, "Bold");
assert_eq!(t.style_at(6).font_style, "Regular");
assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1);
s.undo().unwrap();
let t = data(&s, id);
assert_eq!(t.characters, "Hello");
assert!(t.runs.is_empty());
assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left);
}
#[test]
fn typing_replaces_selection_and_paste_is_plain() {
let (mut s, id) = setup("Hello world");
begin(&mut s, id, None, false).unwrap();
assert_eq!(sel(&s), (0, 11), "begin without caret selects all");
select_word(&mut s, 7).unwrap();
assert_eq!(sel(&s), (6, 11));
insert(&mut s, "there").unwrap();
assert_eq!(data(&s, id).characters, "Hello there");
assert_eq!(sel(&s), (11, 11));
// Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped.
insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap();
assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td");
insert(&mut s, "\u{1}").unwrap();
assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td");
// Select the line (paragraph) then type over it.
select_line(&mut s, 0).unwrap();
assert_eq!(sel(&s), (0, 11));
insert(&mut s, "Hi").unwrap();
assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td");
assert_eq!(selected_text(&s).as_deref(), Some(""));
end(&mut s).unwrap();
}
#[test]
fn word_selection_classes() {
let (mut s, id) = setup("foo, bar_baz!\nnext");
begin(&mut s, id, Some(0), false).unwrap();
select_word(&mut s, 1).unwrap();
assert_eq!(sel(&s), (0, 3));
// Right after a word's last char selects the word.
select_word(&mut s, 13).unwrap();
assert_eq!(sel(&s), (6, 13));
// A run of spaces.
select_word(&mut s, 4).unwrap();
assert_eq!(sel(&s), (4, 6));
select_line(&mut s, 16).unwrap();
assert_eq!(sel(&s), (15, 19));
select_line(&mut s, 999).unwrap();
assert_eq!(sel(&s), (15, 19));
select_word(&mut s, 999).unwrap();
assert_eq!(sel(&s), (15, 19));
set_caret(&mut s, 999, false).unwrap();
assert_eq!(sel(&s), (19, 19), "carets clamp to the text");
}
#[test]
fn keyboard_navigation_and_extension() {
let (mut s, id) = setup("one two\nthree");
begin(&mut s, id, Some(0), false).unwrap();
key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word
assert_eq!(sel(&s), (3, 3));
key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends
assert_eq!(sel(&s), (3, 7));
key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start
assert_eq!(sel(&s), (3, 3));
key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end
assert_eq!(sel(&s), (7, 7));
key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end
assert_eq!(sel(&s), (7, 13));
key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start
assert_eq!(sel(&s), (0, 0));
key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward
assert_eq!(data(&s, id).characters, " two\nthree");
key(&mut s, "SelectAll", false, false, false).unwrap();
assert_eq!(sel(&s), (0, 10));
end(&mut s).unwrap();
}
#[test]
fn backspace_removes_only_the_final_thai_mark() {
let (mut s, id) = setup("น้ำ");
begin(&mut s, id, Some(3), false).unwrap();
key(&mut s, "Backspace", false, false, false).unwrap();
assert_eq!(data(&s, id).characters, "น้");
}
fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent {
let mut e = PointerEvent::at(kind, x, y, 1.0);
e.shift = shift;
e
}
/// Asserts double-clicking a text layer selects all text, a second double-click selects the
/// word without changing the editing session, and drag and Shift+click extend the
/// selection.
#[test]
fn pointer_double_click_drag_and_shift_click() {
let (mut s, id) = setup("Hello world");
let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return };
if l.glyphs.is_empty() {
return; // no fonts
}
let y = l.lines[0].top + l.lines[0].height / 2.0;
let x_of = |i: usize| l.caret(i).0;
// Double-click inside "world" edits with all the text selected; another one there
// selects the word.
let mid = (x_of(7) + x_of(8)) / 2.0;
s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap();
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
assert_eq!(sel(&s), (0, 11));
let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session;
s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap();
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
assert_eq!(sel(&s), (6, 11));
assert_eq!(
s.ds().unwrap().text_edit.as_ref().unwrap().editing_session,
epoch,
"a double-click inside the edit is an ordinary edit: same IME replacement epoch"
);
// Drag-select from the start of "Hello" to the middle.
s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap();
assert_eq!(sel(&s), (0, 0));
s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap();
s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap();
assert_eq!(sel(&s), (0, 3));
// Shift+click extends.
s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap();
s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap();
assert_eq!(sel(&s), (0, 9));
insert(&mut s, "J").unwrap();
assert_eq!(data(&s, id).characters, "Jld");
}
#[test]
fn text_commands_apply_to_edited_range() {
let (mut s, id) = setup("Hello world");
begin(&mut s, id, Some(0), false).unwrap();
set_caret(&mut s, 6, true).unwrap(); // "Hello "
for (cmd, p) in [
("text.underline", json!({})),
("text.italic", json!({})),
("text.sizeUp", json!({})),
("text.trackingUp", json!({})),
("text.lineHeightUp", json!({})),
("text.caseSmallCaps", json!({})),
("text.link", json!({"url": "https://example.com"})),
] {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
let t = data(&s, id);
let (a, b) = (t.style_at(0), t.style_at(8));
assert_eq!(a.decoration, TextDecoration::Underline);
assert!(a.italic() && !b.italic());
assert_eq!(a.font_size, 25.0);
assert_eq!(b.font_size, 24.0);
assert_eq!(a.case, TextCase::SmallCaps);
assert_eq!(a.link.as_deref(), Some("https://example.com"));
assert!(b.link.is_none());
// text.style with an explicit range (what the panel sends).
s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap();
let t = data(&s, id);
assert_eq!(t.style_at(8).font_size, 40.0);
assert_eq!(t.style_at(8).case, TextCase::Upper);
// Paragraph commands hit the edited node.
s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap();
assert_eq!(data(&s, id).list, ListStyle::Numbered);
// Every Text-menu command runs while editing.
for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) {
let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) };
if crate::find_command(item.id).is_some() {
s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id));
}
}
end(&mut s).unwrap();
}
#[test]
fn paragraph_and_feature_commands_on_nodes() {
let (mut s, id) = setup("Hello");
let ids = json!([id]);
s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap();
assert_eq!(data(&s, id).features.get("liga"), Some(&false));
s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap();
let f = data(&s, id).features;
assert_eq!(f.get("liga"), None);
assert_eq!(f.get("ss01"), Some(&true));
assert_eq!(f.get("tnum"), Some(&true));
s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap();
assert_eq!(data(&s, id).features.len(), 1);
assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err());
assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err());
assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err());
s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap();
assert_eq!(data(&s, id).features.len(), 1);
s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap();
s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap();
s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap();
s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap();
s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap();
s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap();
let t = data(&s, id);
assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2));
assert_eq!(t.auto_resize, TextAutoResize::Truncate);
assert_eq!(t.style.decoration, TextDecoration::Strikethrough);
assert_eq!(t.style.case, TextCase::SmallCaps);
assert_eq!(t.list, ListStyle::Bulleted);
assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err());
assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err());
s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap();
assert_eq!(data(&s, id).paragraph_indent, 4.0);
}
fn preedit_state(s: &Session) -> Option<Preedit> {
s.ds().unwrap().text_edit.clone().unwrap().preedit
}
#[test]
fn ime_composition_is_shown_but_not_in_the_document_until_committed() {
let (mut s, id) = setup("Hello world");
let undo_before = s.ds().unwrap().history.undo.len();
begin(&mut s, id, Some(5), false).unwrap();
let doc_before = s.ds().unwrap().doc.clone();
s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap();
assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 }));
assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone");
let (shown, range, cursor) = preedit_doc(&s).unwrap();
assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world");
assert_eq!((range, cursor), ((5, 7), 6));
let info = s.execute("text.edit.selection", &json!({})).unwrap();
assert_eq!(info["preedit"]["text"], "にほ");
// Updating the composition replaces it; an empty one cancels it.
preedit(&mut s, "にほん", None).unwrap();
assert_eq!(preedit_doc(&s).unwrap().2, 8);
preedit(&mut s, "", None).unwrap();
assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none());
assert_eq!(data(&s, id).characters, "Hello world");
// Commit puts the final text at the caret.
preedit(&mut s, "にほん", None).unwrap();
s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap();
assert!(preedit_state(&s).is_none());
assert_eq!(data(&s, id).characters, "Hello日本 world");
assert_eq!(sel(&s), (7, 7));
// Composing over a selection replaces it; moving the caret abandons the composition.
set_caret(&mut s, 7, false).unwrap();
set_caret(&mut s, 13, true).unwrap();
preedit(&mut s, "x", None).unwrap();
assert_eq!(data(&s, id).characters, "Hello日本");
set_caret(&mut s, 0, false).unwrap();
assert!(preedit_state(&s).is_none());
assert_eq!(data(&s, id).characters, "Hello日本");
// Ending the edit drops an uncommitted composition; the session stays one undo step.
preedit(&mut s, "zz", None).unwrap();
end(&mut s).unwrap();
assert_eq!(data(&s, id).characters, "Hello日本");
assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1);
// Not editing: an error, not a crash.
assert!(preedit(&mut s, "a", None).is_err());
}
#[test]
fn variable_font_axes_as_commands_and_props() {
let (mut s, id) = setup("Hello world");
let ids = json!([id]);
s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap();
let st = data(&s, id).style;
assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0)));
assert_eq!(st.weight(), 550);
// null removes one axis; replace clears the rest.
s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap();
assert_eq!(data(&s, id).style.variations.len(), 1);
s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap();
assert_eq!(data(&s, id).style.variations.keys().collect::<Vec<_>>(), ["opsz"]);
// Undo restores the previous values.
s.undo().unwrap();
assert_eq!(data(&s, id).style.variations.keys().collect::<Vec<_>>(), ["wght"]);
// Junk is rejected.
assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err());
assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err());
assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err());
// node.set props (whole text or a range) replace the custom values.
s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap();
let t = data(&s, id);
assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0));
assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0));
s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap();
assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty());
// Picking a named style (Bold, weight steps) drops the custom weight but keeps width.
s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap();
s.execute("text.bold", &json!({"ids": ids})).unwrap();
let st = data(&s, id).style;
assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies");
assert!(!st.variations.contains_key("wght"));
assert_eq!(st.variations.get("wdth"), Some(&90.0));
// While editing, the command styles the selected range only.
begin(&mut s, id, Some(0), false).unwrap();
set_caret(&mut s, 5, true).unwrap();
s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap();
end(&mut s).unwrap();
let t = data(&s, id);
assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0));
assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0));
// The axes query answers without fonts too.
let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap();
assert!(r.is_null() || r["axes"].is_array());
}
/// No text command panics on junk parameters while a text is being edited.
#[test]
fn text_commands_survive_junk_while_editing() {
let junk = [
json!({}),
json!(null),
json!({"index": -5, "features": {"": 1}}),
json!({"index": 1e300, "style": 3, "case": null}),
json!([1]),
json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}),
json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}),
];
// `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow.
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") {
for j in &junk {
let (mut s, id) = setup("Hi there\nyou");
begin(&mut s, id, Some(2), false).unwrap();
set_caret(&mut s, 5, true).unwrap();
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
/// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed
/// selection reversed.
#[test]
fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() {
let (mut s, id) = setup("café😀e\u{301}x");
begin(&mut s, id, Some(8), false).unwrap();
set_caret(&mut s, 8, false).unwrap();
set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it
let ime = ime_surrounding(&s).unwrap();
assert_eq!(ime.text, "café😀e\u{301}x");
assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes");
assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed");
assert_eq!(ime.owner.node, id);
assert_eq!(ime.owner.document, s.ds().unwrap().uid);
}
/// Asserts an active composition is excluded from surrounding text and an empty text layer
/// reports an available, empty payload.
#[test]
fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() {
let (mut s, id) = setup("Hi");
begin(&mut s, id, Some(2), false).unwrap();
preedit(&mut s, "にほ", Some(1)).unwrap();
let ime = ime_surrounding(&s).unwrap();
assert_eq!(ime.text, "Hi", "the composition is not committed text");
assert_eq!((ime.cursor, ime.anchor), (2, 2));
end(&mut s).unwrap();
let (mut s, id) = setup("");
begin(&mut s, id, Some(0), false).unwrap();
let ime = ime_surrounding(&s).unwrap();
assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable");
}
/// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized
/// selection is describable, and one byte more is `SelectionTooWide`.
#[test]
fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() {
let long = "a".repeat(5000);
let (mut s, id) = setup(&long);
begin(&mut s, id, Some(5000), false).unwrap();
let ime = ime_surrounding(&s).unwrap();
assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES);
assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES));
// Exactly the budget is still describable; one more byte is not.
set_caret(&mut s, 1000, false).unwrap();
set_caret(&mut s, 5000, true).unwrap();
let ime = ime_surrounding(&s).unwrap();
assert_eq!(ime.text.len(), 4000);
assert_eq!((ime.cursor, ime.anchor), (4000, 0));
set_caret(&mut s, 999, false).unwrap();
set_caret(&mut s, 5000, true).unwrap();
assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide));
}
/// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new
/// edit session changes the generation.
#[test]
fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() {
let (mut s, id) = setup("Hello");
begin(&mut s, id, Some(0), false).unwrap();
let first = ime_surrounding(&s).unwrap().owner;
insert(&mut s, "x").unwrap();
set_caret(&mut s, 2, false).unwrap();
assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner");
end(&mut s).unwrap();
begin(&mut s, id, Some(0), false).unwrap();
let replaced = ime_surrounding(&s).unwrap().owner;
assert_eq!(replaced.node, id);
assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch");
}
/// Asserts byte deletion around a reversed selection removes whole characters on both
/// sides, keeps the selected text and direction, and a final no-op leaves the text alone.
#[test]
fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() {
let (mut s, id) = setup("ab你好cd");
begin(&mut s, id, Some(4), false).unwrap();
set_caret(&mut s, 4, false).unwrap();
set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好
assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap());
// "b" (whole character before the selection) and "c" (whole character after it) go; the
// selected "你好" stays for the paired commit, and the reversed direction is preserved.
assert_eq!(data(&s, id).characters, "a你好d");
assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed
assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap());
assert_eq!(data(&s, id).characters, "你好");
assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed
assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection");
assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text");
}
/// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an
/// active composition and the revision untouched.
#[test]
fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() {
let (mut s, id) = setup("ab你好cd");
begin(&mut s, id, Some(2), false).unwrap();
set_caret(&mut s, 4, true).unwrap(); // "你好"
assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap());
assert_eq!(data(&s, id).characters, "a你好d");
assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix");
set_caret(&mut s, 0, false).unwrap();
// A composition in progress survives a delete that finds nothing to remove.
preedit(&mut s, "x", None).unwrap();
let revision = s.ds().unwrap().revision;
assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap());
assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x"));
assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision");
assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap());
assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x"));
assert_eq!(s.ds().unwrap().revision, revision);
}
/// Asserts a text layer in another document has a different IME owner.
#[test]
fn ime_owner_identity_changes_with_the_document() {
let (mut s, id) = setup("Hi");
begin(&mut s, id, Some(2), false).unwrap();
let first = ime_surrounding(&s).unwrap().owner;
end(&mut s).unwrap();
s.execute("file.new", &json!({"name": "Second"})).unwrap();
let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap();
let other = crate::params::id_of(&r["id"]).unwrap();
begin(&mut s, other, Some(5), false).unwrap();
let second = ime_surrounding(&s).unwrap().owner;
assert_ne!(second.document, first.document, "another document is another IME owner");
assert_eq!(second.node, other);
}
/// Asserts a space committed after a line-start `-` becomes a bulleted list and reports
/// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`.
#[test]
fn ime_commit_reports_list_autoformat_as_an_application_transformation() {
let (mut s, id) = setup("-");
begin(&mut s, id, Some(1), false).unwrap();
assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed);
assert_eq!(data(&s, id).characters, "");
assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted);
assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain);
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
}
/// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`,
/// and bare newline/CR commits are ignored while a live composition remains.
#[test]
fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() {
let (mut s, id) = setup("Hi");
begin(&mut s, id, Some(2), false).unwrap();
// CRLF is inserted as LF, but the editor changed the commit: application-origin.
assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed);
assert_eq!(data(&s, id).characters, "Hia\nb");
// Control characters are dropped; the commit itself is a no-op.
assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing);
assert_eq!(data(&s, id).characters, "Hia\nb");
// Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one.
// (It also leaves a live composition alone, unlike the non-ignored commit below.)
preedit(&mut s, "x", None).unwrap();
assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing);
assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing);
assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into()));
preedit(&mut s, "", None).unwrap();
assert_eq!(data(&s, id).characters, "Hia\nb");
insert(&mut s, "\n").unwrap();
assert_eq!(data(&s, id).characters, "Hia\nb\n");
}
/// Asserts an empty or control-only IME commit ends a live composition (bumping the revision
/// once), inserts nothing, and is a harmless no-op when no composition is active.
#[test]
fn an_empty_ime_commit_ends_the_composition_without_inserting_text() {
let (mut s, id) = setup("Hello");
begin(&mut s, id, Some(5), false).unwrap();
preedit(&mut s, "にほ", None).unwrap();
assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into()));
let revision = s.ds().unwrap().revision;
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision");
assert!(preedit_state(&s).is_none(), "an empty commit ends the composition");
assert!(preedit_doc(&s).is_none());
assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing");
// A control-only commit normalises to empty and ends the composition too.
preedit(&mut s, "x", None).unwrap();
let revision = s.ds().unwrap().revision;
assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing);
assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision");
assert!(preedit_state(&s).is_none());
assert_eq!(data(&s, id).characters, "Hello");
// With no composition (and not even while editing) an empty commit stays a harmless no-op.
let revision = s.ds().unwrap().revision;
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
assert_eq!(s.ds().unwrap().revision, revision);
end(&mut s).unwrap();
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
}