diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs index f7be739b8..28e4c96bf 100644 --- a/crates/engine/src/cmd/equation.rs +++ b/crates/engine/src/cmd/equation.rs @@ -829,548 +829,4 @@ fn gallery(_: &mut Session, _: &Value) -> CmdResult { } #[cfg(test)] -mod tests { - use super::*; - use crate::Session; - use wordcraft_doc::Document; - - fn session() -> Session { - let mut s = Session::new(Document::new()); - s.run("document.setText", &json!({"text": ""})).unwrap(); - s - } - - fn linear(s: &Session) -> String { - s.math.as_ref().and_then(|m| s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off))).map(|o| o.plain_text().to_string()).unwrap_or_default() - } - - #[test] - fn type_an_equation_like_word() { - let mut s = session(); - s.run("insert.equation", &json!({})).unwrap(); - assert!(s.math.is_some(), "editing the new equation"); - // An empty line gives a display equation. - assert!(matches!(s.doc.para_at(&s.math.as_ref().unwrap().at).unwrap().object_at(0), Some(InlineObject::Equation { display: true, .. }))); - for t in ["x", "=", "(-b\\pm", " ", "\\sqrt", " ", "(b^2-4ac))/2a", " "] { - s.run("equation.type", &json!({"text": t})).unwrap(); - } - assert_eq!(linear(&s), "x=(-b±√(b^2-4ac))/2a"); - let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); - assert!(matches!(nodes.get(1), Some(MNode::Frac { .. })), "{nodes:#?}"); - // One undo step for the typing. - s.run("equation.exit", &json!({})).unwrap(); - s.run("edit.undo", &json!({})).unwrap(); - assert!(linear(&s).is_empty()); - } - - #[test] - fn structures_symbols_and_navigation() { - let mut s = session(); - s.run("equation.insertStructure", &json!({"id": "frac.stacked"})).unwrap(); - s.run("equation.type", &json!({"text": "1"})).unwrap(); - s.run("equation.move", &json!({"dir": "down"})).unwrap(); - s.run("equation.insertSymbol", &json!({"char": "π"})).unwrap(); - assert_eq!(linear(&s), "1/π"); - s.run("equation.move", &json!({"dir": "right"})).unwrap(); - s.run("equation.insertStructure", &json!({"id": "nary.sumLimits"})).unwrap(); - s.run("equation.type", &json!({"text": "k=1"})).unwrap(); - s.run("equation.move", &json!({"dir": "next"})).unwrap(); - s.run("equation.type", &json!({"text": "n"})).unwrap(); - s.run("equation.move", &json!({"dir": "next"})).unwrap(); - s.run("equation.type", &json!({"text": "k"})).unwrap(); - assert_eq!(linear(&s), "1/π∑_(k=1)^n▒k"); - let r = s.run("equation.get", &json!({})).unwrap(); - assert!(r["latex"].as_str().unwrap().contains("\\sum")); - // Moving right past the end leaves the equation. - for _ in 0..20 { - if s.math.is_none() { - break; - } - let _ = s.run("equation.move", &json!({"dir": "right"})); - } - assert!(s.math.is_none()); - } - - #[test] - fn numbering_conversion_and_display() { - let mut s = session(); - s.run("insert.equation", &json!({"linear": "E=mc^2"})).unwrap(); - s.run("equation.type", &json!({"text": "#(1)"})).unwrap(); - s.run("equation.enter", &json!({})).unwrap(); - assert_eq!(linear(&s), "E=mc^2#(1)"); - s.run("equation.number", &json!({"value": false})).unwrap(); - assert_eq!(linear(&s), "E=mc^2"); - s.run("equation.number", &json!({})).unwrap(); - assert_eq!(linear(&s), "E=mc^2#"); - s.run("equation.convert", &json!({"to": "linear"})).unwrap(); - s.run("equation.convert", &json!({"to": "professional"})).unwrap(); - assert_eq!(linear(&s), "E=mc^2#"); - s.run("equation.display", &json!({"value": false})).unwrap(); - let r = s.run("equation.get", &json!({})).unwrap(); - assert_eq!(r["display"], false); - // LaTeX input. - s.run("equation.set", &json!({"latex": "\\frac{a}{b}"})).unwrap(); - assert_eq!(linear(&s), "a/b"); - // Backspacing an equation empty, then once more, removes it. - let mut n = 0; - while s.math.is_some() && n < 50 { - s.run("equation.backspace", &json!({})).unwrap(); - n += 1; - } - assert!(s.math.is_none()); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().objects.len(), 0); - } - - #[test] - fn gallery_lists_everything() { - let mut s = session(); - let g = s.run("equation.gallery", &json!({})).unwrap(); - assert_eq!(g["structures"].as_array().unwrap().len(), 11); - assert!(g["symbols"].as_array().unwrap().len() >= 8); - for b in crate::math_gallery::BUILT_INS { - let mut s = session(); - s.run("insert.equation", &json!({"builtin": b.0, "edit": false})).unwrap(); - } - } - - /// A session on a DOCX whose only paragraph is `{math}`. - fn imported(math: &str) -> Session { - use std::io::Write; - let w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - let m = "http://schemas.openxmlformats.org/officeDocument/2006/math"; - let parts = [ - ( - "[Content_Types].xml", - r#""#.to_string(), - ), - ( - "_rels/.rels", - r#""#.to_string(), - ), - ("word/document.xml", format!(r#"{math}"#)), - ]; - let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, xml) in parts { - zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - zw.write_all(xml.as_bytes()).unwrap(); - } - let bytes = zw.finish().unwrap().into_inner(); - let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap()); - s.run("equation.edit", &json!({})).unwrap(); - s - } - - fn document_xml(s: &Session) -> String { - use std::io::Read; - let bytes = wordcraft_docx::write(&s.doc).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut xml = String::new(); - z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); - xml - } - - #[test] - fn removing_numbers_keeps_every_row() { - // #444: a numbered two-row array lost its second row when its numbers were removed. - for numbered in ["█(a#(1)@b#(2))", "█(a@b#(2))"] { - let mut s = session(); - s.run("insert.equation", &json!({"linear": numbered})).unwrap(); - s.run("equation.number", &json!({"value": false})).unwrap(); - assert_eq!(linear(&s), "█(a@b)", "{numbered}"); - let mut back = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap()); - assert_eq!(back.run("equation.get", &json!({})).unwrap()["linear"], "█(a@b)", "{numbered} after save and reopen"); - s.run("edit.undo", &json!({})).unwrap(); - assert_eq!(linear(&s), numbered, "Undo restores the numbers"); - } - // A single numbered row stands alone again. - let mut s = session(); - s.run("insert.equation", &json!({"linear": "a#(1)"})).unwrap(); - s.run("equation.number", &json!({"value": false})).unwrap(); - assert_eq!(linear(&s), "a"); - } - - #[test] - fn colour_survives_save_and_reopen() { - // #1467: a coloured equation came back black after saving and reopening. - let mut s = session(); - s.run("insert.equation", &json!({"linear": "(a+b)/c"})).unwrap(); - s.run("equation.exit", &json!({})).unwrap(); - s.run("select.all", &json!({})).unwrap(); - s.run("format.color", &json!({"color": "C00000"})).unwrap(); - assert!(document_xml(&s).contains(r#" 0); - // Automatic again: no colour written. - back.run("select.all", &json!({})).unwrap(); - back.run("format.color", &json!({"color": "auto"})).unwrap(); - assert!(!document_xml(&back).contains("C00000")); - } - - #[test] - fn imported_colour_keeps_source_omml_until_recoloured() { - // #1467: lifting a Word equation's colour must not drop the OMML the model can't hold. - let r = r#""#; - let ctrl = r#""#; - let mut s = - imported(&format!("{ctrl}{r}a{r}b")); - assert!(document_xml(&s).contains("00FF00"), "untouched: source OMML written back"); - s.run("select.all", &json!({})).unwrap(); - s.run("format.color", &json!({"color": "0000FF"})).unwrap(); - let xml = document_xml(&s); - assert!(xml.contains("0000FF") && !xml.contains("FF0000"), "recoloured: runs rewritten"); - } - - #[test] - fn navigation_keeps_imported_omml() { - // #446: a Left move without any change dropped the fraction's imported control colour. - let red = r#"ab"#; - let mut s = imported(red); - s.run("equation.move", &json!({"dir": "left"})).unwrap(); - assert!(document_xml(&s).contains(r#"w:val="FF0000""#), "the control colour survives navigation"); - // A genuine edit still writes the new content. - s.run("equation.type", &json!({"text": "x"})).unwrap(); - assert_eq!(linear(&s), "a/bx"); - assert!(document_xml(&s).contains(">bx<")); - } - - /// A Left move that builds up `a/b` makes the document dirty, advances layout and IME revisions, - /// reports a classified transformed change, and Undo restores the literal run; plain navigation - /// leaves no undo step. - #[test] - fn navigation_build_up_is_an_undoable_edit() { - // #447: building `a/b` up on a Left move left the document clean and Undo did nothing. - let mut s = imported("a/b"); - assert!(!s.dirty); - let layout_rev = s.rev(); - let ime_rev = revision(&mut s); - s.run("equation.move", &json!({"dir": "left"})).unwrap(); - // Every command replaces the last outcome; sample the edit before the read-only query. - let outcome = s.ime_edit_outcome(); - assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "\\frac{a}{b}"); - assert!(s.dirty, "build-up marks the document dirty"); - assert!(s.rev() > layout_rev, "build-up refreshes the layout revision"); - assert!(outcome.classified && outcome.content_changed && outcome.state_changed && outcome.transformed, "{outcome:?}"); - assert!(revision(&mut s) > ime_rev, "build-up advances the unavailable IME revision"); - s.run("edit.undo", &json!({})).unwrap(); - s.run("equation.edit", &json!({})).unwrap(); - assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "a/b", "Undo restores the plain run"); - // Plain navigation is no edit. - let mut s = imported("abc"); - s.run("equation.move", &json!({"dir": "left"})).unwrap(); - assert!(!s.dirty); - assert_eq!(s.undo_depth(), 0); - } - - #[test] - fn latex_text_keeps_its_spaces() { - // #448: `\text{for all x}` came out as `forallx`. - for (tex, want) in [ - ("\\text{for all x}", "\\text{for all x}"), - ("\\text{a b}", "\\text{a b}"), - ("\\text{ab}", "\\text{ab}"), - ("x + y", "x+y"), - ("x+y", "x+y"), - ] { - let mut s = session(); - s.run("insert.equation", &json!({"latex": tex})).unwrap(); - assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], want, "{tex}"); - } - } - - #[test] - fn typed_multi_letter_scripts_stay_whole() { - // #1609, Word for Mac 16.113.4: `V_dd` + Space leaves "dd" entirely in the subscript. - for typed in ["V_dd", "V_(dd)"] { - let mut s = session(); - s.run("insert.equation", &json!({})).unwrap(); - s.run("equation.type", &json!({"text": typed})).unwrap(); - s.run("equation.type", &json!({"text": " "})).unwrap(); - let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); - assert_eq!(nodes.len(), 1, "{typed}: {nodes:#?}"); - assert!( - matches!(&nodes[0], MNode::Script { sub, .. } if matches!(sub.as_slice(), [MNode::Run(r)] if r.text == "dd")), - "{typed}: {nodes:#?}" - ); - } - let mut s = session(); - s.run("insert.equation", &json!({})).unwrap(); - s.run("equation.type", &json!({"text": "x^ab "})).unwrap(); - let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); - assert!(matches!(&nodes[0], MNode::Script { sup, .. } if matches!(sup.as_slice(), [MNode::Run(r)] if r.text == "ab")), "{nodes:#?}"); - } - - #[test] - fn a_built_script_keeps_the_letter_typed_after_it() { - // #1609 review: `x_i y ` built x_{iy}: the letter after a script is its own item. - let mut s = session(); - s.run("insert.equation", &json!({})).unwrap(); - s.run("equation.type", &json!({"text": "x_i y "})).unwrap(); - let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); - assert_eq!(nodes.len(), 2, "{nodes:#?}"); - assert!(matches!(&nodes[0], MNode::Script { sub, .. } if matches!(sub.as_slice(), [MNode::Run(r)] if r.text == "i")), "{nodes:#?}"); - assert!(matches!(&nodes[1], MNode::Run(r) if r.text.trim() == "y"), "{nodes:#?}"); - let mut s = session(); - s.run("insert.equation", &json!({})).unwrap(); - s.run("equation.type", &json!({"text": "a_i b_j "})).unwrap(); - let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); - let subs: Vec<&str> = nodes - .iter() - .filter_map(|n| match n { - MNode::Script { sub, .. } => match sub.as_slice() { - [MNode::Run(r)] => Some(r.text.as_str()), - _ => None, - }, - _ => None, - }) - .collect(); - assert_eq!(subs, ["i", "j"], "{nodes:#?}"); - assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}"); - } - - /// Returns the session's current IME state revision, allocating a new one when the caret state changed. - fn revision(s: &mut Session) -> u64 { - s.ime_state_revision().expect("an IME state revision") - } - - /// Clears the stored node tree of the equation currently being edited, leaving its linear text - /// and lazy state in place. - fn clear_nodes(s: &mut Session) { - let at = s.math.as_ref().expect("editing").at.clone(); - if let Some(InlineObject::Equation { math, .. }) = s.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) { - math.nodes.clear(); - } - } - - /// Backspace at the start of an equation is classified with no content or state change, - /// allocates no revision and leaves the linear text intact. - #[test] - fn equation_backspace_at_start_is_a_classified_noop() { - let mut s = session(); - s.run("insert.equation", &json!({"linear": "x+y"})).unwrap(); - s.run("equation.move", &json!({"dir": "home"})).unwrap(); - let before = revision(&mut s); - s.run("equation.backspace", &json!({})).unwrap(); - let outcome = s.ime_edit_outcome(); - assert!(outcome.classified); - assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); - assert_eq!(revision(&mut s), before, "a no-op allocates no revision"); - assert_eq!(linear(&s), "x+y"); - } - - /// Materializing an imported equation's lazy nodes is a state change only: the outcome is - /// classified, content is unchanged, and the revision advances. - #[test] - fn lazy_normalization_is_state_not_content() { - let mut s = session(); - s.run("insert.equation", &json!({"linear": "x+1"})).unwrap(); - clear_nodes(&mut s); - // Editing parses the local tree but leaves the stored equation lazy. - s.run("equation.edit", &json!({})).unwrap(); - s.run("equation.move", &json!({"dir": "home"})).unwrap(); - let before = revision(&mut s); - s.run("equation.backspace", &json!({})).unwrap(); - let outcome = s.ime_edit_outcome(); - assert!(outcome.classified); - assert!(!outcome.content_changed, "the normalized tree was already logical content"); - assert!(outcome.state_changed, "materializing the cached nodes is a state change"); - assert!(revision(&mut s) > before, "state changes advance the revision"); - } - - /// Empty typing is a classified no-op in built-up and linear form, without a new revision. - #[test] - fn empty_equation_type_is_a_classified_noop_in_both_modes() { - let mut s = session(); - s.run("insert.equation", &json!({"linear": "x"})).unwrap(); - let before = revision(&mut s); - s.run("equation.type", &json!({"text": ""})).unwrap(); - assert!(s.ime_edit_outcome().classified); - assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed); - assert_eq!(revision(&mut s), before, "built-up empty type is a no-op"); - s.run("equation.convert", &json!({"to": "linear"})).unwrap(); - let before = revision(&mut s); - s.run("equation.type", &json!({"text": ""})).unwrap(); - assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed); - assert_eq!(revision(&mut s), before, "linear empty type is a no-op"); - } - - /// Typing `a/b ` or `\alpha ` reports both a content change and a transformed automatic rewrite. - #[test] - fn automatic_equation_rewrites_are_transformed() { - for text in ["a/b ", "\\alpha "] { - let mut s = session(); - s.run("insert.equation", &json!({})).unwrap(); - s.run("equation.type", &json!({"text": text})).unwrap(); - let outcome = s.ime_edit_outcome(); - assert!(outcome.content_changed, "{text}: {outcome:?}"); - assert!(outcome.transformed, "{text}: {outcome:?}"); - } - } - - /// Justification and display changes advance the revision and report a state, not content, - /// change. - #[test] - fn equation_style_changes_are_state_changes_without_content() { - let mut s = session(); - s.run("insert.equation", &json!({"linear": "x"})).unwrap(); - let before = revision(&mut s); - s.run("equation.justify", &json!({"jc": "left"})).unwrap(); - let outcome = s.ime_edit_outcome(); - assert!(outcome.classified && outcome.state_changed && !outcome.content_changed, "{outcome:?}"); - assert!(revision(&mut s) > before); - let before = revision(&mut s); - s.run("equation.display", &json!({"value": false})).unwrap(); - assert!(s.ime_edit_outcome().state_changed && !s.ime_edit_outcome().content_changed); - assert!(revision(&mut s) > before); - } - - /// Converting every equation reports one classified effect that merges their content and state - /// changes. - #[test] - fn convert_all_merges_every_equations_effect() { - let mut s = session(); - s.run("document.setText", &json!({"text": "\r"})).unwrap(); - s.run("insert.equation", &json!({"linear": "a/b"})).unwrap(); - s.run("equation.convert", &json!({"to": "linear"})).unwrap(); - s.run("equation.exit", &json!({})).unwrap(); - s.run("caret.docEnd", &json!({})).unwrap(); - s.run("insert.equation", &json!({"linear": "c/d"})).unwrap(); - s.run("equation.exit", &json!({})).unwrap(); - s.run("equation.convert", &json!({"to": "professional", "all": true})).unwrap(); - let outcome = s.ime_edit_outcome(); - assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); - } - - /// A bounded deletion around the caret removes the requested units in one undo step and reports - /// a content change. - #[test] - fn equation_delete_surrounding_is_one_transaction_and_one_undo_step() { - let mut s = session(); - s.run("insert.equation", &json!({"linear": "abcd"})).unwrap(); - let depth = s.undo_depth(); - s.run("equation.deleteSurrounding", &json!({"before": 2, "after": 0})).unwrap(); - assert_eq!(linear(&s), "ab"); - assert_eq!(s.undo_depth(), depth + 1, "one undo step, not one per unit"); - assert!(s.ime_edit_outcome().classified && s.ime_edit_outcome().content_changed); - } - - /// An impossible bounded deletion applies no prefix, adds no undo step, keeps the session and - /// caret, and leaves no classified outcome. - #[test] - fn equation_delete_surrounding_refuses_partial_progress() { - let mut s = session(); - s.run("insert.equation", &json!({"linear": "ab"})).unwrap(); - let session_token = s.math.as_ref().unwrap().session; - let at = s.math.as_ref().unwrap().at.clone(); - let depth = s.undo_depth(); - let err = s.run("equation.deleteSurrounding", &json!({"before": 3, "after": 0})).unwrap_err(); - assert!(err.to_string().contains("cannot delete"), "{err}"); - assert_eq!(linear(&s), "ab", "the whole request is refused, no prefix applied"); - assert_eq!(s.undo_depth(), depth, "no undo step for a refused transaction"); - let m = s.math.as_ref().expect("still editing"); - assert_eq!(m.session, session_token); - assert_eq!(m.at, at); - assert!(!s.ime_edit_outcome().classified, "a refusal has no outcome"); - assert_eq!( - s.last_command.as_ref().map(|(id, _)| id.as_str()), - Some("equation.deleteSurrounding"), - "only the pre-dispatch Repeat record remains" - ); - } - - /// Zero or oversized counts and deletions from an empty equation are refused without mutation; - /// exactly deleting all text leaves the empty equation object. - #[test] - fn equation_delete_surrounding_bounds_and_empty_equations() { - let mut s = session(); - s.run("insert.equation", &json!({})).unwrap(); - assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 0})).is_err(), "zero counts are refused"); - assert!(s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).is_err(), "an empty equation refuses"); - assert!(s.math.is_some(), "the empty equation object stays"); - s.run("equation.type", &json!({"text": "a"})).unwrap(); - let depth = s.undo_depth(); - assert!(s.run("equation.deleteSurrounding", &json!({"before": 65, "after": 0})).is_err()); - assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 65})).is_err()); - assert_eq!(linear(&s), "a"); - assert_eq!(s.undo_depth(), depth); - s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).unwrap(); - assert_eq!(linear(&s), "", "exactly deleting the last unit leaves the equation object"); - assert!(s.math.is_some()); - } - - /// A forward deletion that would only step into another argument is refused whole, leaving the - /// text and undo depth unchanged (the caret is not asserted here). - #[test] - fn equation_delete_surrounding_refuses_a_caret_only_step() { - let mut s = session(); - s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap(); - // At the end of the numerator a forward delete only steps into the denominator: the - // tree does not change, so a request that would report progress is refused whole. - s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 }; - let before = linear(&s); - let depth = s.undo_depth(); - let err = s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 1})).unwrap_err(); - assert!(err.to_string().contains("no progress"), "{err}"); - assert_eq!(linear(&s), before); - assert_eq!(s.undo_depth(), depth); - } - - /// A deletion that really erases on one side cannot buy a caret-only step on the other: - /// the mixed request is refused whole, so a following IME commit cannot land in the - /// argument the platform never asked about. - #[test] - fn equation_delete_surrounding_refuses_a_mixed_caret_only_step() { - let mut s = session(); - s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap(); - // At the end of the numerator. `before: 1` erases `a`, but `after: 1` would only step - // into the denominator (the report's mixed-direction reproducer). - s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 }; - let before = linear(&s); - let pos = s.math.as_ref().expect("editing").pos.clone(); - let depth = s.undo_depth(); - let err = s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 1})).unwrap_err(); - assert!(err.to_string().contains("no progress"), "{err}"); - assert_eq!(linear(&s), before, "the successful-looking prefix is not applied"); - assert_eq!(s.undo_depth(), depth, "no undo step for a refused mixed request"); - assert_eq!(s.math.as_ref().expect("editing").pos, pos, "the caret stays where the request found it"); - } - - /// Typing more than the cap inserts exactly `MAX_EQUATION_TYPE_CHARS` characters in both linear - /// and built-up form. - #[test] - fn equation_type_caps_linear_and_built_up_input() { - for linear_mode in [false, true] { - let mut s = session(); - s.run("insert.equation", &json!({})).unwrap(); - if linear_mode { - s.run("equation.convert", &json!({"to": "linear"})).unwrap(); - } - let text = "a".repeat(crate::MAX_EQUATION_TYPE_CHARS + 5); - s.run("equation.type", &json!({"text": text})).unwrap(); - assert_eq!(linear(&s).chars().count(), crate::MAX_EQUATION_TYPE_CHARS, "linear={linear_mode}"); - } - } - - /// Deleting one character forward removes it and moves the math caret to the start of the - /// remaining text. - #[test] - fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() { - let mut s = session(); - s.run("insert.equation", &json!({"linear": "abc"})).unwrap(); - s.run("equation.move", &json!({"dir": "home"})).unwrap(); - s.run("equation.deleteSurrounding", &json!({"after": 1, "before": 0})).unwrap(); - assert_eq!(linear(&s), "bc"); - assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 0)); - } -} +mod tests; diff --git a/crates/engine/src/cmd/equation/tests.rs b/crates/engine/src/cmd/equation/tests.rs new file mode 100644 index 000000000..6fc52c1d1 --- /dev/null +++ b/crates/engine/src/cmd/equation/tests.rs @@ -0,0 +1,532 @@ +use super::*; +use crate::Session; +use wordcraft_doc::Document; + +fn session() -> Session { + let mut s = Session::new(Document::new()); + s.run("document.setText", &json!({"text": ""})).unwrap(); + s +} + +fn linear(s: &Session) -> String { + s.math.as_ref().and_then(|m| s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off))).map(|o| o.plain_text().to_string()).unwrap_or_default() +} + +#[test] +fn type_an_equation_like_word() { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + assert!(s.math.is_some(), "editing the new equation"); + // An empty line gives a display equation. + assert!(matches!(s.doc.para_at(&s.math.as_ref().unwrap().at).unwrap().object_at(0), Some(InlineObject::Equation { display: true, .. }))); + for t in ["x", "=", "(-b\\pm", " ", "\\sqrt", " ", "(b^2-4ac))/2a", " "] { + s.run("equation.type", &json!({"text": t})).unwrap(); + } + assert_eq!(linear(&s), "x=(-b±√(b^2-4ac))/2a"); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + assert!(matches!(nodes.get(1), Some(MNode::Frac { .. })), "{nodes:#?}"); + // One undo step for the typing. + s.run("equation.exit", &json!({})).unwrap(); + s.run("edit.undo", &json!({})).unwrap(); + assert!(linear(&s).is_empty()); +} + +#[test] +fn structures_symbols_and_navigation() { + let mut s = session(); + s.run("equation.insertStructure", &json!({"id": "frac.stacked"})).unwrap(); + s.run("equation.type", &json!({"text": "1"})).unwrap(); + s.run("equation.move", &json!({"dir": "down"})).unwrap(); + s.run("equation.insertSymbol", &json!({"char": "π"})).unwrap(); + assert_eq!(linear(&s), "1/π"); + s.run("equation.move", &json!({"dir": "right"})).unwrap(); + s.run("equation.insertStructure", &json!({"id": "nary.sumLimits"})).unwrap(); + s.run("equation.type", &json!({"text": "k=1"})).unwrap(); + s.run("equation.move", &json!({"dir": "next"})).unwrap(); + s.run("equation.type", &json!({"text": "n"})).unwrap(); + s.run("equation.move", &json!({"dir": "next"})).unwrap(); + s.run("equation.type", &json!({"text": "k"})).unwrap(); + assert_eq!(linear(&s), "1/π∑_(k=1)^n▒k"); + let r = s.run("equation.get", &json!({})).unwrap(); + assert!(r["latex"].as_str().unwrap().contains("\\sum")); + // Moving right past the end leaves the equation. + for _ in 0..20 { + if s.math.is_none() { + break; + } + let _ = s.run("equation.move", &json!({"dir": "right"})); + } + assert!(s.math.is_none()); +} + +#[test] +fn numbering_conversion_and_display() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "E=mc^2"})).unwrap(); + s.run("equation.type", &json!({"text": "#(1)"})).unwrap(); + s.run("equation.enter", &json!({})).unwrap(); + assert_eq!(linear(&s), "E=mc^2#(1)"); + s.run("equation.number", &json!({"value": false})).unwrap(); + assert_eq!(linear(&s), "E=mc^2"); + s.run("equation.number", &json!({})).unwrap(); + assert_eq!(linear(&s), "E=mc^2#"); + s.run("equation.convert", &json!({"to": "linear"})).unwrap(); + s.run("equation.convert", &json!({"to": "professional"})).unwrap(); + assert_eq!(linear(&s), "E=mc^2#"); + s.run("equation.display", &json!({"value": false})).unwrap(); + let r = s.run("equation.get", &json!({})).unwrap(); + assert_eq!(r["display"], false); + // LaTeX input. + s.run("equation.set", &json!({"latex": "\\frac{a}{b}"})).unwrap(); + assert_eq!(linear(&s), "a/b"); + // Backspacing an equation empty, then once more, removes it. + let mut n = 0; + while s.math.is_some() && n < 50 { + s.run("equation.backspace", &json!({})).unwrap(); + n += 1; + } + assert!(s.math.is_none()); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().objects.len(), 0); +} + +#[test] +fn gallery_lists_everything() { + let mut s = session(); + let g = s.run("equation.gallery", &json!({})).unwrap(); + assert_eq!(g["structures"].as_array().unwrap().len(), 11); + assert!(g["symbols"].as_array().unwrap().len() >= 8); + for b in crate::math_gallery::BUILT_INS { + let mut s = session(); + s.run("insert.equation", &json!({"builtin": b.0, "edit": false})).unwrap(); + } +} + +/// A session on a DOCX whose only paragraph is `{math}`. +fn imported(math: &str) -> Session { + use std::io::Write; + let w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + let m = "http://schemas.openxmlformats.org/officeDocument/2006/math"; + let parts = [ + ( + "[Content_Types].xml", + r#""#.to_string(), + ), + ( + "_rels/.rels", + r#""#.to_string(), + ), + ("word/document.xml", format!(r#"{math}"#)), + ]; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, xml) in parts { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(xml.as_bytes()).unwrap(); + } + let bytes = zw.finish().unwrap().into_inner(); + let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap()); + s.run("equation.edit", &json!({})).unwrap(); + s +} + +fn document_xml(s: &Session) -> String { + use std::io::Read; + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + xml +} + +#[test] +fn removing_numbers_keeps_every_row() { + // #444: a numbered two-row array lost its second row when its numbers were removed. + for numbered in ["█(a#(1)@b#(2))", "█(a@b#(2))"] { + let mut s = session(); + s.run("insert.equation", &json!({"linear": numbered})).unwrap(); + s.run("equation.number", &json!({"value": false})).unwrap(); + assert_eq!(linear(&s), "█(a@b)", "{numbered}"); + let mut back = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap()); + assert_eq!(back.run("equation.get", &json!({})).unwrap()["linear"], "█(a@b)", "{numbered} after save and reopen"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(linear(&s), numbered, "Undo restores the numbers"); + } + // A single numbered row stands alone again. + let mut s = session(); + s.run("insert.equation", &json!({"linear": "a#(1)"})).unwrap(); + s.run("equation.number", &json!({"value": false})).unwrap(); + assert_eq!(linear(&s), "a"); +} + +#[test] +fn colour_survives_save_and_reopen() { + // #1467: a coloured equation came back black after saving and reopening. + let mut s = session(); + s.run("insert.equation", &json!({"linear": "(a+b)/c"})).unwrap(); + s.run("equation.exit", &json!({})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.color", &json!({"color": "C00000"})).unwrap(); + assert!(document_xml(&s).contains(r#" 0); + // Automatic again: no colour written. + back.run("select.all", &json!({})).unwrap(); + back.run("format.color", &json!({"color": "auto"})).unwrap(); + assert!(!document_xml(&back).contains("C00000")); +} + +#[test] +fn imported_colour_keeps_source_omml_until_recoloured() { + // #1467: lifting a Word equation's colour must not drop the OMML the model can't hold. + let r = r#""#; + let ctrl = r#""#; + let mut s = + imported(&format!("{ctrl}{r}a{r}b")); + assert!(document_xml(&s).contains("00FF00"), "untouched: source OMML written back"); + s.run("select.all", &json!({})).unwrap(); + s.run("format.color", &json!({"color": "0000FF"})).unwrap(); + let xml = document_xml(&s); + assert!(xml.contains("0000FF") && !xml.contains("FF0000"), "recoloured: runs rewritten"); +} + +#[test] +fn navigation_keeps_imported_omml() { + // #446: a Left move without any change dropped the fraction's imported control colour. + let red = r#"ab"#; + let mut s = imported(red); + s.run("equation.move", &json!({"dir": "left"})).unwrap(); + assert!(document_xml(&s).contains(r#"w:val="FF0000""#), "the control colour survives navigation"); + // A genuine edit still writes the new content. + s.run("equation.type", &json!({"text": "x"})).unwrap(); + assert_eq!(linear(&s), "a/bx"); + assert!(document_xml(&s).contains(">bx<")); +} + +/// A Left move that builds up `a/b` makes the document dirty, advances layout and IME revisions, +/// reports a classified transformed change, and Undo restores the literal run; plain navigation +/// leaves no undo step. +#[test] +fn navigation_build_up_is_an_undoable_edit() { + // #447: building `a/b` up on a Left move left the document clean and Undo did nothing. + let mut s = imported("a/b"); + assert!(!s.dirty); + let layout_rev = s.rev(); + let ime_rev = revision(&mut s); + s.run("equation.move", &json!({"dir": "left"})).unwrap(); + // Every command replaces the last outcome; sample the edit before the read-only query. + let outcome = s.ime_edit_outcome(); + assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "\\frac{a}{b}"); + assert!(s.dirty, "build-up marks the document dirty"); + assert!(s.rev() > layout_rev, "build-up refreshes the layout revision"); + assert!(outcome.classified && outcome.content_changed && outcome.state_changed && outcome.transformed, "{outcome:?}"); + assert!(revision(&mut s) > ime_rev, "build-up advances the unavailable IME revision"); + s.run("edit.undo", &json!({})).unwrap(); + s.run("equation.edit", &json!({})).unwrap(); + assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "a/b", "Undo restores the plain run"); + // Plain navigation is no edit. + let mut s = imported("abc"); + s.run("equation.move", &json!({"dir": "left"})).unwrap(); + assert!(!s.dirty); + assert_eq!(s.undo_depth(), 0); +} + +#[test] +fn latex_text_keeps_its_spaces() { + // #448: `\text{for all x}` came out as `forallx`. + for (tex, want) in + [("\\text{for all x}", "\\text{for all x}"), ("\\text{a b}", "\\text{a b}"), ("\\text{ab}", "\\text{ab}"), ("x + y", "x+y"), ("x+y", "x+y")] + { + let mut s = session(); + s.run("insert.equation", &json!({"latex": tex})).unwrap(); + assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], want, "{tex}"); + } +} + +#[test] +fn typed_multi_letter_scripts_stay_whole() { + // #1609, Word for Mac 16.113.4: `V_dd` + Space leaves "dd" entirely in the subscript. + for typed in ["V_dd", "V_(dd)"] { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": typed})).unwrap(); + s.run("equation.type", &json!({"text": " "})).unwrap(); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + assert_eq!(nodes.len(), 1, "{typed}: {nodes:#?}"); + assert!(matches!(&nodes[0], MNode::Script { sub, .. } if matches!(sub.as_slice(), [MNode::Run(r)] if r.text == "dd")), "{typed}: {nodes:#?}"); + } + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": "x^ab "})).unwrap(); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + assert!(matches!(&nodes[0], MNode::Script { sup, .. } if matches!(sup.as_slice(), [MNode::Run(r)] if r.text == "ab")), "{nodes:#?}"); +} + +#[test] +fn a_built_script_keeps_the_letter_typed_after_it() { + // #1609 review: `x_i y ` built x_{iy}: the letter after a script is its own item. + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": "x_i y "})).unwrap(); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + assert_eq!(nodes.len(), 2, "{nodes:#?}"); + assert!(matches!(&nodes[0], MNode::Script { sub, .. } if matches!(sub.as_slice(), [MNode::Run(r)] if r.text == "i")), "{nodes:#?}"); + assert!(matches!(&nodes[1], MNode::Run(r) if r.text.trim() == "y"), "{nodes:#?}"); + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": "a_i b_j "})).unwrap(); + let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap(); + let subs: Vec<&str> = nodes + .iter() + .filter_map(|n| match n { + MNode::Script { sub, .. } => match sub.as_slice() { + [MNode::Run(r)] => Some(r.text.as_str()), + _ => None, + }, + _ => None, + }) + .collect(); + assert_eq!(subs, ["i", "j"], "{nodes:#?}"); + assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}"); +} + +/// Returns the session's current IME state revision, allocating a new one when the caret state changed. +fn revision(s: &mut Session) -> u64 { + s.ime_state_revision().expect("an IME state revision") +} + +/// Clears the stored node tree of the equation currently being edited, leaving its linear text +/// and lazy state in place. +fn clear_nodes(s: &mut Session) { + let at = s.math.as_ref().expect("editing").at.clone(); + if let Some(InlineObject::Equation { math, .. }) = s.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) { + math.nodes.clear(); + } +} + +/// Backspace at the start of an equation is classified with no content or state change, +/// allocates no revision and leaves the linear text intact. +#[test] +fn equation_backspace_at_start_is_a_classified_noop() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "x+y"})).unwrap(); + s.run("equation.move", &json!({"dir": "home"})).unwrap(); + let before = revision(&mut s); + s.run("equation.backspace", &json!({})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified); + assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); + assert_eq!(revision(&mut s), before, "a no-op allocates no revision"); + assert_eq!(linear(&s), "x+y"); +} + +/// Materializing an imported equation's lazy nodes is a state change only: the outcome is +/// classified, content is unchanged, and the revision advances. +#[test] +fn lazy_normalization_is_state_not_content() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "x+1"})).unwrap(); + clear_nodes(&mut s); + // Editing parses the local tree but leaves the stored equation lazy. + s.run("equation.edit", &json!({})).unwrap(); + s.run("equation.move", &json!({"dir": "home"})).unwrap(); + let before = revision(&mut s); + s.run("equation.backspace", &json!({})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified); + assert!(!outcome.content_changed, "the normalized tree was already logical content"); + assert!(outcome.state_changed, "materializing the cached nodes is a state change"); + assert!(revision(&mut s) > before, "state changes advance the revision"); +} + +/// Empty typing is a classified no-op in built-up and linear form, without a new revision. +#[test] +fn empty_equation_type_is_a_classified_noop_in_both_modes() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "x"})).unwrap(); + let before = revision(&mut s); + s.run("equation.type", &json!({"text": ""})).unwrap(); + assert!(s.ime_edit_outcome().classified); + assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed); + assert_eq!(revision(&mut s), before, "built-up empty type is a no-op"); + s.run("equation.convert", &json!({"to": "linear"})).unwrap(); + let before = revision(&mut s); + s.run("equation.type", &json!({"text": ""})).unwrap(); + assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed); + assert_eq!(revision(&mut s), before, "linear empty type is a no-op"); +} + +/// Typing `a/b ` or `\alpha ` reports both a content change and a transformed automatic rewrite. +#[test] +fn automatic_equation_rewrites_are_transformed() { + for text in ["a/b ", "\\alpha "] { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": text})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.content_changed, "{text}: {outcome:?}"); + assert!(outcome.transformed, "{text}: {outcome:?}"); + } +} + +/// Justification and display changes advance the revision and report a state, not content, +/// change. +#[test] +fn equation_style_changes_are_state_changes_without_content() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "x"})).unwrap(); + let before = revision(&mut s); + s.run("equation.justify", &json!({"jc": "left"})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified && outcome.state_changed && !outcome.content_changed, "{outcome:?}"); + assert!(revision(&mut s) > before); + let before = revision(&mut s); + s.run("equation.display", &json!({"value": false})).unwrap(); + assert!(s.ime_edit_outcome().state_changed && !s.ime_edit_outcome().content_changed); + assert!(revision(&mut s) > before); +} + +/// Converting every equation reports one classified effect that merges their content and state +/// changes. +#[test] +fn convert_all_merges_every_equations_effect() { + let mut s = session(); + s.run("document.setText", &json!({"text": "\r"})).unwrap(); + s.run("insert.equation", &json!({"linear": "a/b"})).unwrap(); + s.run("equation.convert", &json!({"to": "linear"})).unwrap(); + s.run("equation.exit", &json!({})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("insert.equation", &json!({"linear": "c/d"})).unwrap(); + s.run("equation.exit", &json!({})).unwrap(); + s.run("equation.convert", &json!({"to": "professional", "all": true})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); +} + +/// A bounded deletion around the caret removes the requested units in one undo step and reports +/// a content change. +#[test] +fn equation_delete_surrounding_is_one_transaction_and_one_undo_step() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "abcd"})).unwrap(); + let depth = s.undo_depth(); + s.run("equation.deleteSurrounding", &json!({"before": 2, "after": 0})).unwrap(); + assert_eq!(linear(&s), "ab"); + assert_eq!(s.undo_depth(), depth + 1, "one undo step, not one per unit"); + assert!(s.ime_edit_outcome().classified && s.ime_edit_outcome().content_changed); +} + +/// An impossible bounded deletion applies no prefix, adds no undo step, keeps the session and +/// caret, and leaves no classified outcome. +#[test] +fn equation_delete_surrounding_refuses_partial_progress() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "ab"})).unwrap(); + let session_token = s.math.as_ref().unwrap().session; + let at = s.math.as_ref().unwrap().at.clone(); + let depth = s.undo_depth(); + let err = s.run("equation.deleteSurrounding", &json!({"before": 3, "after": 0})).unwrap_err(); + assert!(err.to_string().contains("cannot delete"), "{err}"); + assert_eq!(linear(&s), "ab", "the whole request is refused, no prefix applied"); + assert_eq!(s.undo_depth(), depth, "no undo step for a refused transaction"); + let m = s.math.as_ref().expect("still editing"); + assert_eq!(m.session, session_token); + assert_eq!(m.at, at); + assert!(!s.ime_edit_outcome().classified, "a refusal has no outcome"); + assert_eq!(s.last_command.as_ref().map(|(id, _)| id.as_str()), Some("equation.deleteSurrounding"), "only the pre-dispatch Repeat record remains"); +} + +/// Zero or oversized counts and deletions from an empty equation are refused without mutation; +/// exactly deleting all text leaves the empty equation object. +#[test] +fn equation_delete_surrounding_bounds_and_empty_equations() { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 0})).is_err(), "zero counts are refused"); + assert!(s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).is_err(), "an empty equation refuses"); + assert!(s.math.is_some(), "the empty equation object stays"); + s.run("equation.type", &json!({"text": "a"})).unwrap(); + let depth = s.undo_depth(); + assert!(s.run("equation.deleteSurrounding", &json!({"before": 65, "after": 0})).is_err()); + assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 65})).is_err()); + assert_eq!(linear(&s), "a"); + assert_eq!(s.undo_depth(), depth); + s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).unwrap(); + assert_eq!(linear(&s), "", "exactly deleting the last unit leaves the equation object"); + assert!(s.math.is_some()); +} + +/// A forward deletion that would only step into another argument is refused whole, leaving the +/// text and undo depth unchanged (the caret is not asserted here). +#[test] +fn equation_delete_surrounding_refuses_a_caret_only_step() { + let mut s = session(); + s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap(); + // At the end of the numerator a forward delete only steps into the denominator: the + // tree does not change, so a request that would report progress is refused whole. + s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 }; + let before = linear(&s); + let depth = s.undo_depth(); + let err = s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 1})).unwrap_err(); + assert!(err.to_string().contains("no progress"), "{err}"); + assert_eq!(linear(&s), before); + assert_eq!(s.undo_depth(), depth); +} + +/// A deletion that really erases on one side cannot buy a caret-only step on the other: +/// the mixed request is refused whole, so a following IME commit cannot land in the +/// argument the platform never asked about. +#[test] +fn equation_delete_surrounding_refuses_a_mixed_caret_only_step() { + let mut s = session(); + s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap(); + // At the end of the numerator. `before: 1` erases `a`, but `after: 1` would only step + // into the denominator (the report's mixed-direction reproducer). + s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 }; + let before = linear(&s); + let pos = s.math.as_ref().expect("editing").pos.clone(); + let depth = s.undo_depth(); + let err = s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 1})).unwrap_err(); + assert!(err.to_string().contains("no progress"), "{err}"); + assert_eq!(linear(&s), before, "the successful-looking prefix is not applied"); + assert_eq!(s.undo_depth(), depth, "no undo step for a refused mixed request"); + assert_eq!(s.math.as_ref().expect("editing").pos, pos, "the caret stays where the request found it"); +} + +/// Typing more than the cap inserts exactly `MAX_EQUATION_TYPE_CHARS` characters in both linear +/// and built-up form. +#[test] +fn equation_type_caps_linear_and_built_up_input() { + for linear_mode in [false, true] { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + if linear_mode { + s.run("equation.convert", &json!({"to": "linear"})).unwrap(); + } + let text = "a".repeat(crate::MAX_EQUATION_TYPE_CHARS + 5); + s.run("equation.type", &json!({"text": text})).unwrap(); + assert_eq!(linear(&s).chars().count(), crate::MAX_EQUATION_TYPE_CHARS, "linear={linear_mode}"); + } +} + +/// Deleting one character forward removes it and moves the math caret to the start of the +/// remaining text. +#[test] +fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "abc"})).unwrap(); + s.run("equation.move", &json!({"dir": "home"})).unwrap(); + s.run("equation.deleteSurrounding", &json!({"after": 1, "before": 0})).unwrap(); + assert_eq!(linear(&s), "bc"); + assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 0)); +}