Files
wordcraft/crates/engine/src/cmd/equation/tests.rs
T

642 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 `<m:oMath>{math}</m:oMath>`.
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#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#.to_string(),
),
(
"_rels/.rels",
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#.to_string(),
),
("word/document.xml", format!(r#"<w:document xmlns:w="{w}" xmlns:m="{m}"><w:body><w:p><m:oMath>{math}</m:oMath></w:p><w:sectPr/></w:body></w:document>"#)),
];
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#"<w:color w:val="C00000"/></w:rPr><m:t"#), "the math runs carry the colour");
let mut back = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
back.run("equation.edit", &json!({})).unwrap();
let at = back.math.as_ref().unwrap().at.clone();
let p = back.doc.para_at(&at).unwrap();
let (_, props) = p.run_ranges().find(|(r, _)| r.contains(&at.off)).unwrap();
assert_eq!(props.color, Some(wordcraft_doc::props::TextColor::Rgb(wordcraft_doc::props::Rgb(0xC0, 0, 0))), "the equation's colour is back");
let Some(InlineObject::Equation { math, .. }) = p.object_at(at.off) else { panic!() };
let mut nodes = math.nodes.clone();
let mut runs = 0;
wordcraft_doc::math_edit::each_run_mut(&mut nodes, &mut |r| {
runs += 1;
assert_eq!(r.color, None, "the colour is the equation's, not stuck on each run");
});
assert!(runs > 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#"<w:rPr><w:color w:val="FF0000"/></w:rPr>"#;
let ctrl = r#"<m:ctrlPr><w:rPr><w:color w:val="00FF00"/></w:rPr></m:ctrlPr>"#;
let mut s =
imported(&format!("<m:f><m:fPr>{ctrl}</m:fPr><m:num><m:r>{r}<m:t>a</m:t></m:r></m:num><m:den><m:r>{r}<m:t>b</m:t></m:r></m:den></m:f>"));
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#"<m:f><m:fPr><m:ctrlPr><w:rPr><w:color w:val="FF0000"/></w:rPr></m:ctrlPr></m:fPr><m:num><m:r><m:t>a</m:t></m:r></m:num><m:den><m:r><m:t>b</m:t></m:r></m:den></m:f>"#;
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("<m:r><m:t>a/b</m:t></m:r>");
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("<m:r><m:t>abc</m:t></m:r>");
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));
}
/// The n-ary operator in the equation, with its limits (shown or not) and operand as text.
fn nary_of(s: &Session) -> Option<(char, Option<String>, Option<String>, String)> {
nodes_of(s, &s.math.as_ref()?.at)?.iter().find_map(|n| match n {
MNode::Nary { chr, sub_hide, sup_hide, sub, sup, e, .. } => {
Some((*chr, (!sub_hide).then(|| to_linear(sub)), (!sup_hide).then(|| to_linear(sup)), to_linear(e)))
}
_ => None,
})
}
#[test]
fn an_autocorrected_operator_waits_for_its_limits() {
// #4917: as in Word, `\int` + Space gives a ∫ that `_x^y` then Space decorates; the
// operand typed next goes inside the operator.
let typed = |keys: &[&str]| {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
for t in keys {
s.run("equation.type", &json!({"text": t})).unwrap();
}
s
};
let s = typed(&[r"\int", " "]);
assert_eq!(linear(&s), "∫", "the operator waits, not yet built");
assert!(nary_of(&s).is_none());
let s = typed(&[r"\int", " ", "_x^y", " ", "f"]);
assert_eq!(nary_of(&s), Some(('∫', Some("x".into()), Some("y".into()), "f".into())));
let s = typed(&[r"\sum", " ", "_(k=1)^n", " ", "k"]);
assert_eq!(nary_of(&s), Some(('∑', Some("k=1".into()), Some("n".into()), "k".into())));
// Only a lower limit.
let s = typed(&[r"\prod", " ", "_i", " ", "a"]);
assert_eq!(nary_of(&s), Some(('∏', Some("i".into()), None, "a".into())));
// Space alone builds it without limits, the caret in its operand.
let s = typed(&[r"\int", " ", " ", "x"]);
assert_eq!(nary_of(&s), Some(('∫', None, None, "x".into())));
// Typing the operand straight after it, as before.
let s = typed(&[r"\int", " ", "x", " "]);
assert_eq!(nary_of(&s), Some(('∫', None, None, "x".into())));
// Enter builds the operator and leaves the caret after it; Right builds it and, at the
// equation's end, leaves the equation, both as before.
let mut s = typed(&[r"\int", " ", "_0^1"]);
s.run("equation.enter", &json!({})).unwrap();
assert_eq!(nary_of(&s), Some(('∫', Some("0".into()), Some("1".into()), String::new())));
assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 1));
let mut s = typed(&[r"\int", " ", "_0^1"]);
let at = s.math.as_ref().unwrap().at.clone();
s.run("equation.move", &json!({"dir": "right"})).unwrap();
assert!(s.math.is_none(), "Right at the end leaves the equation");
assert!(matches!(nodes_of(&s, &at).as_deref(), Some([MNode::Nary { e, .. }]) if e.is_empty()));
// One undo step takes the whole typing away, leaving the empty equation.
let mut s = typed(&[r"\int", " ", "_0^1", " ", "t"]);
let at = s.math.as_ref().unwrap().at.clone();
s.run("equation.exit", &json!({})).unwrap();
s.run("edit.undo", &json!({})).unwrap();
let left = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).map(|o| o.plain_text().to_string());
assert_eq!(left.as_deref(), Some(""), "the equation is there, empty");
// An operator ending another one's operand (`∫∫`) takes what is typed next.
let s = typed(&[r"\int", " ", r"\int", " ", " ", "f"]);
let inner = nary_of(&s).and_then(|_| {
nodes_of(&s, &s.math.as_ref()?.at)?.iter().find_map(|n| match n {
MNode::Nary { e, .. } => match e.as_slice() {
[MNode::Nary { e, .. }] => Some(to_linear(e)),
_ => None,
},
_ => None,
})
});
assert_eq!(inner.as_deref(), Some("f"), "f is the inner operator's operand");
// Space after an operator that was already there (from the gallery, its operand left empty)
// doesn't send the caret back into it: what is typed next follows the operator.
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
s.run("equation.insertStructure", &json!({"id": "nary.sumLimits"})).unwrap();
s.run("equation.type", &json!({"text": "k"})).unwrap();
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), 1);
}
s.run("equation.type", &json!({"text": " x"})).unwrap();
assert_eq!(nary_of(&s).map(|n| n.3), Some(String::new()), "the operand stays empty");
assert_eq!(s.math.as_ref().unwrap().pos.path, Vec::new(), "the caret stays after the operator");
}
#[test]
fn a_math_name_is_corrected_by_any_character_that_cannot_continue_it() {
// Observed in Word for Windows (#4938): `\int_0^1` typed in one go gives ∫ as soon as `_` is
// typed, so Space then builds the integral with its limits; `\alpha` + `+` gives α+, and a
// letter or digit leaves the name as typed.
let typed = |keys: &[&str]| {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
for t in keys {
s.run("equation.type", &json!({"text": t})).unwrap();
}
s
};
let s = typed(&[r"\int_0^1", " ", "x"]);
assert_eq!(nary_of(&s), Some(('∫', Some("0".into()), Some("1".into()), "x".into())));
let s = typed(&[r"\alpha+\beta\gamma1"]);
assert_eq!(linear(&s), r"α+β\gamma1");
let s = typed(&[r"\alpha(x)"]);
assert_eq!(linear(&s), "α(x)");
// LaTeX input keeps its commands until they are built up.
let mut s = session();
s.run("equation.inputFormat", &json!({"format": "latex"})).unwrap();
s.run("insert.equation", &json!({})).unwrap();
s.run("equation.type", &json!({"text": r"\alpha+"})).unwrap();
assert_eq!(linear(&s), r"\alpha+");
}