use crate::model::Kind;
use crate::*;
fn sym(c: &str) -> Symbol {
Symbol { latex: c.into(), class: MathClass::Ord }
}
fn atom(c: &str) -> NodeDoc {
NodeDoc::Atom(sym(c))
}
fn text(s: &str) -> Command {
Command::InsertText(s.into())
}
fn editor(cmds: impl IntoIterator<Item = Command>) -> Editor {
let mut ed = Editor::new();
for c in cmds {
let _ = ed.exec(c);
}
ed
}
fn run(ed: &mut Editor, cmds: impl IntoIterator<Item = Command>) {
for c in cmds {
let _ = ed.exec(c);
}
}
fn tex(ed: &Editor) -> String {
ed.document().to_tex()
}
fn doc_tex(nodes: Vec<NodeDoc>) -> String {
Document::new(nodes).to_tex()
}
fn step(node: usize, slot: Slot) -> Step {
Step { node, slot }
}
fn at(steps: Vec<Step>, index: usize) -> CaretPath {
CaretPath { steps, index }
}
fn delim(open: char, close: char) -> Command {
Command::InsertDelimiters { open, close }
}
fn x_squared() -> Editor {
editor([text("x"), Command::InsertScript(ScriptSlot::Sup), text("2")])
}
#[test]
fn revision_moves_only_on_content_changes() {
let mut ed = Editor::new();
let o = ed.exec(text("a"));
assert!(o.changed && o.moved);
assert_eq!((o.revision, ed.revision()), (1, 1));
let o = ed.exec(Command::Move(Dir::Left));
assert!(!o.changed && o.moved);
assert_eq!(o.revision, 1);
let o = ed.exec(Command::Move(Dir::Left));
assert_eq!(o.exit, Some(ExitDir::Left));
assert!(!o.changed && !o.moved);
}
#[test]
fn no_op_commands_report_no_change() {
let mut ed = editor([text("ab"), Command::Move(Dir::Left), Command::Move(Dir::Left)]);
for cmd in [Command::DeleteBackward, Command::MatrixDeleteRow, Command::MatrixInsertCol(Side::After), Command::Collapse] {
let o = ed.exec(cmd.clone());
assert!(!o.changed, "{cmd:?}");
assert_eq!(o.revision, 1, "{cmd:?}");
}
}
#[test]
fn backspace_next_to_structure_selects_then_deletes() {
let mut ed = editor([Command::InsertFraction(FracStyle::Bar), text("a"), Command::Move(Dir::Down), text("b")]);
ed.place_at(Edge::End);
let o = ed.exec(Command::DeleteBackward);
assert!(!o.changed && o.moved);
assert_eq!(ed.selection(), Some(Selection { anchor: CaretPath::root(0), focus: CaretPath::root(1) }));
assert!(ed.exec(Command::DeleteBackward).changed);
assert!(ed.document().is_empty());
}
#[test]
fn delete_forward_next_to_structure_selects_then_deletes() {
let mut ed = editor([Command::InsertFraction(FracStyle::Bar)]);
ed.place_at(Edge::Start);
let _ = ed.exec(Command::DeleteForward);
assert!(ed.selection().is_some());
let _ = ed.exec(Command::DeleteForward);
assert!(ed.document().is_empty());
}
fn delim_with_menu() -> Editor {
let mut ed = editor([delim('(', ')'), text("x")]);
ed.place_at(Edge::End);
let o = ed.exec(Command::DeleteBackward);
assert!(!o.changed && !o.moved);
ed
}
#[test]
fn backspace_next_to_delim_opens_swap_menu() {
let ed = delim_with_menu();
let menu = ed.menu().expect("swap menu should be open");
assert_eq!(menu.items[0], MenuItem { label: "Delete".into(), kind: MenuItemKind::Delete });
assert!(menu.items[1..].iter().all(|i| i.kind == MenuItemKind::Swap));
assert!(ed.input_context().menu_open);
assert_eq!(ed.document().len(), 1);
}
#[test]
fn menu_filters_by_typed_query_but_keeps_delete_pinned() {
let mut ed = delim_with_menu();
run(&mut ed, [text("brace")]);
let menu = ed.menu().unwrap();
assert_eq!(menu.query, "brace");
assert_eq!(menu.items[0].kind, MenuItemKind::Delete);
assert!(menu.items[1..].iter().all(|i| i.label.contains("brace")));
assert!(!menu.items.iter().any(|i| i.label.contains('[')));
}
#[test]
fn menu_backspace_with_empty_query_confirms_delete() {
let mut ed = delim_with_menu();
run(&mut ed, [Command::Move(Dir::Down), Command::Move(Dir::Down)]);
assert_eq!(ed.menu().unwrap().selected, 2);
let o = ed.exec(Command::DeleteBackward);
assert!(o.changed);
assert!(ed.document().is_empty());
assert!(ed.menu().is_none());
}
#[test]
fn forward_delete_removes_a_swappable_structure() {
let mut ed = editor([delim('(', ')'), text("x")]);
ed.place_at(Edge::Start);
let _ = ed.exec(Command::DeleteForward);
assert!(ed.menu().is_some());
let o = ed.exec(Command::DeleteForward);
assert!(o.changed);
assert!(ed.document().is_empty());
}
#[test]
fn menu_select_swaps_the_bracket_in_place() {
let mut ed = delim_with_menu();
let idx = ed.menu().unwrap().items.iter().position(|i| i.label.starts_with("[ ]")).unwrap();
assert!(ed.exec(Command::MenuSelect(idx)).changed);
assert!(ed.menu().is_none());
assert_eq!(tex(&ed), "\\left[x\\right]");
}
#[test]
fn menu_escape_cancels_without_changes() {
let mut ed = delim_with_menu();
let o = ed.exec(Command::Collapse);
assert!(!o.changed);
assert!(ed.menu().is_none());
assert_eq!(tex(&ed), "\\left(x\\right)");
}
#[test]
fn under_over_labels_name_their_slot_when_both_exist() {
let spec = UnderOverSpec { over: true, under: true, over_deco: Deco::Brace, under_deco: Deco::None };
let mut ed = editor([Command::InsertUnderOver(spec), text("x")]);
ed.place_at(Edge::End);
let _ = ed.exec(Command::DeleteBackward);
let labels: Vec<String> = ed.menu().unwrap().items.into_iter().map(|i| i.label).collect();
assert!(labels.contains(&"over: plain".to_string()), "{labels:?}");
assert!(labels.contains(&"under: brace".to_string()), "{labels:?}");
assert!(!labels.contains(&"brace".to_string()), "{labels:?}");
}
#[test]
fn backspace_next_to_text_block_enters_it() {
let mut ed = editor([Command::InsertStyled(Variant::Text), text("x")]);
ed.place_at(Edge::End);
let _ = ed.exec(Command::DeleteBackward);
assert!(ed.selection().is_none());
assert!(ed.in_text_slot());
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Body)], 1));
}
#[test]
fn typing_while_structure_selected_replaces_it() {
let mut ed = editor([Command::InsertFraction(FracStyle::Bar)]);
ed.place_at(Edge::End);
let _ = ed.exec(Command::DeleteBackward);
let _ = ed.exec(text("z"));
assert_eq!(tex(&ed), "z");
}
#[test]
fn select_all_then_delete_clears() {
let mut ed = editor([text("abc"), Command::SelectAll, Command::DeleteBackward]);
assert!(ed.document().is_empty());
let o = ed.exec(Command::DeleteBackward);
assert!(o.close && !o.changed);
}
#[test]
fn drag_keeps_its_anchor_and_promotes_across_structures() {
let mut ed = editor([text("ab"), Command::InsertFraction(FracStyle::Bar), text("c")]);
ed.set_cursor(&CaretPath::root(1)).unwrap();
let _ = ed.exec(Command::ExtendTo(CaretPath::root(0)));
assert_eq!(ed.selection().unwrap().anchor, CaretPath::root(1));
let _ = ed.exec(Command::ExtendTo(at(vec![step(2, Slot::Numerator)], 1)));
let s = ed.selection().unwrap();
assert_eq!((s.anchor, s.focus), (CaretPath::root(1), CaretPath::root(3)));
}
#[test]
fn arrows_exit_at_the_formula_edges() {
let mut ed = editor([text("ab")]);
assert!(ed.at_end() && ed.at_slot_end());
assert_eq!(ed.exec(Command::Move(Dir::Right)).exit, Some(ExitDir::Right));
assert_eq!(ed.exec(Command::Move(Dir::Up)).exit, Some(ExitDir::Up));
assert_eq!(ed.exec(Command::Move(Dir::Down)).exit, Some(ExitDir::Down));
ed.place_at(Edge::Start);
assert!(ed.at_start());
assert_eq!(ed.exec(Command::Move(Dir::Left)).exit, Some(ExitDir::Left));
let _ = ed.exec(Command::SelectAll);
let o = ed.exec(Command::Move(Dir::Right));
assert_eq!(o.exit, None);
assert!(ed.selection().is_none());
}
#[test]
fn down_in_numerator_enters_denominator() {
let mut ed = editor([Command::InsertFraction(FracStyle::Bar)]);
let o = ed.exec(Command::Move(Dir::Down));
assert_eq!(o.exit, None);
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Denominator)], 0));
}
#[test]
fn vertical_motion_uses_the_nearest_movable_ancestor() {
let mut ed = editor([Command::InsertFraction(FracStyle::Bar), delim('(', ')'), text("x")]);
let o = ed.exec(Command::Move(Dir::Down));
assert!(o.moved);
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Denominator)], 0));
}
#[test]
fn place_at_clears_selection_and_menu() {
let mut ed = delim_with_menu();
ed.place_at(Edge::Start);
assert!(ed.menu().is_none());
assert_eq!(ed.cursor(), CaretPath::root(0));
let _ = ed.exec(Command::SelectAll);
ed.place_at(Edge::End);
assert!(ed.selection().is_none());
assert_eq!(ed.cursor(), CaretPath::root(1));
}
#[test]
fn host_box_policy_enter_stops_and_reports() {
let mut ed = editor([text("a"), Command::InsertHostBox(17), text("b")]);
ed.set_cursor(&CaretPath::root(1)).unwrap();
let o = ed.exec(Command::Move(Dir::Right));
assert_eq!((o.entered_host_box, ed.cursor()), (None, CaretPath::root(2)));
ed.set_host_box_policy(HostBoxPolicy::Enter);
let o = ed.exec(Command::Move(Dir::Left));
assert_eq!(o.entered_host_box, Some(HostBoxEntry { token: 17, side: Side::After }));
assert!(!o.moved);
ed.set_cursor(&CaretPath::root(1)).unwrap();
let o = ed.exec(Command::Move(Dir::Right));
assert_eq!(o.entered_host_box, Some(HostBoxEntry { token: 17, side: Side::Before }));
assert_eq!(ed.cursor(), CaretPath::root(1));
}
#[test]
fn host_box_deletes_whole_and_restore_brings_it_back() {
let mut ed = editor([Command::InsertHostBox(17)]);
let before = ed.snapshot();
assert!(ed.exec(Command::DeleteBackward).changed);
assert!(ed.document().is_empty());
let rev = ed.revision();
ed.restore(&before).unwrap();
assert_eq!(ed.revision(), rev + 1);
assert_eq!(ed.document().host_tokens().into_iter().collect::<Vec<_>>(), vec![17]);
assert_eq!(ed.cursor(), CaretPath::root(1));
}
#[test]
fn snapshots_round_trip_through_json() {
let mut ed = editor([Command::InsertFraction(FracStyle::Bar), text("ab")]);
let _ = ed.exec(Command::Extend(Dir::Left));
let s = ed.snapshot();
let json = serde_json::to_string(&s).unwrap();
let back: Snapshot = serde_json::from_str(&json).unwrap();
let mut other = Editor::new();
other.restore(&back).unwrap();
assert_eq!(other.snapshot(), s);
}
#[test]
fn restore_rejects_bad_paths_without_touching_state() {
let mut ed = editor([text("ab")]);
let mut s = ed.snapshot();
s.cursor = CaretPath::root(9);
assert_eq!(ed.restore(&s), Err(RestoreError::Path(PathError::GapOutOfRange { len: 2, index: 9 })));
assert_eq!((ed.revision(), ed.cursor()), (1, CaretPath::root(2)));
let err = ed.set_selection(&CaretPath::root(0), &at(vec![step(0, Slot::Numerator)], 0));
assert_eq!(err, Err(PathError::NoSlot { depth: 0 }));
}
#[test]
fn set_selection_requires_one_sequence() {
let mut ed = editor([text("a"), Command::InsertFraction(FracStyle::Bar)]);
let err = ed.set_selection(&CaretPath::root(0), &at(vec![step(1, Slot::Numerator)], 0));
assert_eq!(err, Err(PathError::SplitSelection));
ed.set_selection(&CaretPath::root(0), &CaretPath::root(2)).unwrap();
assert_eq!(ed.selection_tex().as_deref(), Some("a\\frac{}{}"));
}
#[test]
fn selection_document_is_slot_aware() {
let mut ed = editor([text("q"), Command::InsertFraction(FracStyle::Bar), text("ab")]);
let _ = ed.exec(Command::Extend(Dir::Left));
let doc = ed.selection_document().unwrap();
assert_eq!(doc, Document::new(vec![atom("b")]));
let _ = ed.exec(Command::Collapse);
assert_eq!(ed.selection_document(), None);
}
#[test]
fn documents_paste_and_carry_host_boxes() {
let mut ed = editor([text("a"), Command::InsertHostBox(17), text("b"), Command::SelectAll]);
let doc = ed.selection_document().unwrap();
assert!(matches!(doc.root()[1], NodeDoc::HostBox { token: 17 }));
assert_eq!(ed.selection_tex().as_deref(), Some("a\\hostbox{17}b"));
let _ = ed.exec(Command::Collapse);
assert!(ed.exec(Command::InsertDocument(doc)).changed);
assert_eq!(tex(&ed), "a\\hostbox{17}ba\\hostbox{17}b");
}
#[test]
fn script_from_a_script_base_targets_the_owning_script() {
let mut ed = x_squared();
ed.place_at(Edge::Start);
run(&mut ed, [Command::Move(Dir::Right), Command::InsertScript(ScriptSlot::Sup), text("3")]);
assert_eq!(tex(&ed), "x^{23}");
run(&mut ed, [Command::Move(Dir::Left), Command::Move(Dir::Left), Command::Move(Dir::Left)]);
run(&mut ed, [Command::InsertScript(ScriptSlot::Sub), text("i")]);
assert_eq!(tex(&ed), "x_i^{23}");
}
#[test]
fn backspace_in_empty_radicand_or_base_steps_into_the_filled_slot() {
let mut ed = editor([Command::InsertSqrt, Command::Move(Dir::Up), text("3"), Command::Move(Dir::Down)]);
let o = ed.exec(Command::DeleteBackward);
assert!(!o.changed);
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Index)], 1));
assert_eq!(tex(&ed), "\\sqrt[{3}]{}");
let spec = UnderOverSpec { over: true, under: false, over_deco: Deco::None, under_deco: Deco::None };
let mut ed = editor([Command::InsertUnderOver(spec), Command::Move(Dir::Up), text("a"), Command::Move(Dir::Down)]);
let o = ed.exec(Command::DeleteBackward);
assert!(!o.changed);
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Over)], 1));
}
#[test]
fn illegal_script_base_start_is_never_a_resting_position() {
let mut ed = x_squared();
ed.set_cursor(&at(vec![step(0, Slot::Base)], 1)).unwrap();
let _ = ed.exec(Command::MoveLineStart);
assert_eq!(ed.cursor(), CaretPath::root(0));
ed.set_cursor(&at(vec![step(0, Slot::Base)], 0)).unwrap();
assert_eq!(ed.cursor(), CaretPath::root(0));
ed.set_cursor(&at(vec![step(0, Slot::Base)], 1)).unwrap();
let _ = ed.exec(Command::Extend(Dir::Left));
assert_eq!(ed.selection(), Some(Selection { anchor: CaretPath::root(1), focus: CaretPath::root(0) }));
}
#[test]
fn typed_characters_are_escaped_or_mapped() {
let latex = |c| Symbol::from_char(c).map(|s| s.latex);
assert_eq!(latex('^').as_deref(), Some("\\text{\\textasciicircum}"));
assert_eq!(latex('\'').as_deref(), Some("\\prime"));
assert_eq!((latex('\n'), latex('\t'), latex('\u{7}')), (None, None, None));
assert_eq!(latex('𝑥').as_deref(), Some("𝑥"));
assert_eq!(latex('ℝ').as_deref(), Some("ℝ"));
assert_eq!(latex('é').as_deref(), Some("\\text{é}"));
assert_eq!(Symbol::from_char('\u{2212}').unwrap().class, MathClass::Bin);
let ed = editor([text("50%&$^'\n")]);
assert_eq!(tex(&ed), "50\\%\\&\\$\\text{\\textasciicircum}\\prime");
}
#[test]
fn big_operators_replace_and_scripts_wrap_the_selection() {
let op = Symbol { latex: "\\sum".into(), class: MathClass::Op };
let ed = editor([text("ab"), Command::SelectAll, Command::InsertBigOp(op)]);
assert_eq!(tex(&ed), "\\sum");
let mut ed = editor([text("ab"), Command::SelectAll, Command::InsertScript(ScriptSlot::Sup), text("2")]);
assert_eq!(tex(&ed), "{ab}^2");
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Sup)], 1));
let _ = ed.exec(Command::SelectAll);
assert!(ed.selection().is_some());
}
#[test]
fn inserts_into_a_script_base_keep_one_base_item() {
let base_end = at(vec![step(0, Slot::Base)], 1);
let op = Symbol { latex: "\\int".into(), class: MathClass::Op };
let paste = Document::new(vec![atom("a"), atom("b")]);
let cases = [
(Command::InsertMatrix { env: MatrixEnv::Matrix, rows: 1, cols: 1 }, "x{\\begin{matrix}\\end{matrix}}^2"),
(Command::InsertBigOp(op), "x{\\int}^2"),
(Command::InsertDocument(paste), "xab^2"),
];
for (cmd, expected) in cases {
let mut ed = x_squared();
ed.set_cursor(&base_end).unwrap();
assert!(ed.exec(cmd.clone()).changed, "{cmd:?}");
let root = ed.tree().root();
let script = *ed.tree().items(root).last().unwrap();
assert_eq!(ed.tree().len(ed.tree().child_seqs(script)[0]), 1, "{cmd:?}");
assert_eq!(tex(&ed), expected, "{cmd:?}");
}
}
#[test]
fn tab_and_selection_delete_scale_linearly() {
let mut nodes: Vec<NodeDoc> = (0..100_001).map(|_| atom("a")).collect();
nodes.push(NodeDoc::Frac { num: vec![], den: vec![], style: FracStyle::Bar });
let mut ed = Editor::from_document(&Document::new(nodes)).unwrap();
let _ = ed.exec(Command::Tab);
assert_eq!(ed.cursor(), at(vec![step(100_001, Slot::Numerator)], 0));
let _ = ed.exec(Command::Tab);
assert_eq!(ed.cursor(), at(vec![step(100_001, Slot::Denominator)], 0));
assert_eq!(ed.exec(Command::Tab).exit, Some(ExitDir::Right));
let _ = ed.exec(Command::SelectAll);
let _ = ed.exec(Command::DeleteBackward);
assert!(ed.document().is_empty());
}
#[test]
fn tab_fills_big_operator_limits_lower_first() {
let op = Symbol { latex: "\\sum".into(), class: MathClass::Op };
let mut ed = editor([Command::InsertBigOp(op), Command::Tab]);
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Lower)], 0));
let _ = ed.exec(Command::Tab);
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Upper)], 0));
}
#[test]
fn script_bases_are_braced_when_needed() {
let script = |base, sup| NodeDoc::Script { base, sub: None, sup: Some(sup) };
assert_eq!(doc_tex(vec![script(vec![], vec![atom("2")])]), "{}^2");
assert_eq!(doc_tex(vec![script(vec![script(vec![atom("x")], vec![atom("2")])], vec![atom("3")])]), "{x^2}^3");
assert_eq!(doc_tex(vec![script(vec![atom("a"), atom("b")], vec![atom("3")])]), "{ab}^3");
let ed = editor([Command::InsertScript(ScriptSlot::Sup)]);
assert_eq!(ed.source().tex, "{\\phantom{x}}^{\\phantom{x}}");
assert_eq!(ed.display_source().tex, "{}");
}
fn under_over(over: Option<&str>, under: Option<&str>, over_deco: Deco, under_deco: Deco) -> NodeDoc {
let label = |l: Option<&str>| l.map(|s| if s.is_empty() { vec![] } else { vec![atom(s)] });
NodeDoc::UnderOver { base: vec![atom("x")], over: label(over), under: label(under), over_deco, under_deco }
}
#[test]
fn under_over_decorations_export_their_commands() {
let cases = [
(under_over(Some("a"), None, Deco::None, Deco::None), "\\overset{a}{x}"),
(under_over(Some("a"), None, Deco::Arrow, Deco::None), "\\overset{a}{\\overrightarrow{x}}"),
(under_over(None, Some("b"), Deco::None, Deco::Line), "\\underset{b}{\\underline{x}}"),
(under_over(Some("a"), Some("b"), Deco::Brace, Deco::Brace), "\\overbrace{\\underbrace{x}_{b}}^{a}"),
(under_over(Some("a"), Some("b"), Deco::Line, Deco::Arrow), "\\overset{a}{\\overline{\\underset{b}{\\underrightarrow{x}}}}"),
(under_over(Some(""), None, Deco::Arrow, Deco::None), "\\overrightarrow{x}"),
(under_over(Some(""), None, Deco::Brace, Deco::None), "\\overbrace{x}"),
(under_over(Some(""), None, Deco::None, Deco::None), "x"),
];
for (node, expected) in cases {
assert_eq!(doc_tex(vec![node]), expected);
}
}
#[test]
fn script_on_an_overbrace_braces_the_base() {
let base = under_over(Some("a"), None, Deco::Brace, Deco::None);
let node = NodeDoc::Script { base: vec![base], sub: None, sup: Some(vec![atom("2")]) };
assert_eq!(doc_tex(vec![node]), "{\\overbrace{x}^{a}}^2");
}
#[test]
fn text_slots_use_text_mode() {
let mut ed = editor([Command::InsertStyled(Variant::Text), text("a b%~\\")]);
assert!(ed.input_context().in_text_slot);
assert_eq!(tex(&ed), "\\text{a\\ b\\%\\textasciitilde{}\\textbackslash{}}");
for cmd in [
Command::InsertFraction(FracStyle::Bar),
Command::InsertScript(ScriptSlot::Sup),
Command::InsertMatrix { env: MatrixEnv::Matrix, rows: 1, cols: 1 },
Command::InsertStyled(Variant::Bold),
Command::InsertDocument(Document::new(vec![NodeDoc::Sqrt { index: vec![], radicand: vec![] }])),
] {
assert!(!ed.exec(cmd.clone()).changed, "{cmd:?}");
}
let _ = ed.exec(Command::InsertAtom(sym("\\alpha")));
assert!(tex(&ed).ends_with("\\ensuremath{\\alpha}}"));
assert_eq!(tex(&editor([text("a b")])), "ab");
let mut ed = editor([Command::InsertStyled(Variant::Text), text("ab"), Command::Extend(Dir::Left)]);
assert_eq!(ed.selection_tex().as_deref(), Some("\\text{b}"));
let _ = ed.exec(Command::SelectAll);
assert_eq!(ed.selection_tex().as_deref(), Some("\\text{ab}"));
}
#[test]
fn one_separator_rule_for_every_export() {
let alpha = || atom("\\alpha");
assert_eq!(doc_tex(vec![alpha(), atom("x")]), "\\alpha x");
let script = NodeDoc::Script { base: vec![alpha()], sub: None, sup: Some(vec![]) };
assert_eq!(doc_tex(vec![script, atom("x")]), "\\alpha x");
let delims = NodeDoc::Delim { open: '⟨', close: '⟩', body: vec![atom("x")] };
assert_eq!(doc_tex(vec![delims, atom("y")]), "\\left\\langle x\\right\\rangle y");
let mut ed = editor([Command::InsertAtom(sym("\\alpha")), text("x"), Command::SelectAll]);
assert_eq!(ed.selection_tex().as_deref(), Some("\\alpha x"));
let _ = ed.exec(Command::Collapse);
let src = ed.source();
assert_eq!(src.caret_offset(&CaretPath::root(1)), Some(7));
assert_eq!(&src.tex[7..], "x");
}
#[test]
fn radical_fraction_and_attachment_export() {
let sqrt = NodeDoc::Sqrt { index: vec![atom("3")], radicand: vec![atom("x")] };
assert_eq!(doc_tex(vec![sqrt]), "\\sqrt[{3}]{x}");
let empty_sup = NodeDoc::Script { base: vec![atom("x")], sub: Some(vec![atom("i")]), sup: Some(vec![]) };
assert_eq!(doc_tex(vec![empty_sup]), "x_i");
let atop = NodeDoc::Frac { num: vec![atom("a")], den: vec![atom("b")], style: FracStyle::Atop };
assert_eq!(doc_tex(vec![atop]), "\\genfrac{}{}{0pt}{}{a}{b}");
let op = Symbol { latex: "\\sum".into(), class: MathClass::Op };
assert_eq!(doc_tex(vec![NodeDoc::BigOp { op, lower: vec![], upper: vec![] }, atom("x")]), "\\sum x");
}
#[test]
fn caret_offsets_index_into_the_source() {
let ed = editor([Command::InsertFraction(FracStyle::Bar), text("a"), Command::Move(Dir::Down), text("b")]);
let src = ed.source();
assert_eq!(src.tex, "\\frac{a}{b}");
assert_eq!(src.caret_offset(&at(vec![step(0, Slot::Numerator)], 1)), Some(7));
assert_eq!(src.caret_offset(&ed.cursor()), Some(10));
assert_eq!(src.caret_offset(&CaretPath::root(1)), Some(11));
assert_eq!(src.caret_offset(&CaretPath::root(2)), None);
let clean = editor([Command::InsertFraction(FracStyle::Bar)]).display_source();
assert_eq!(clean.tex, "\\frac{}{}");
assert_eq!(clean.caret_offset(&at(vec![step(0, Slot::Denominator)], 0)), Some(8));
}
#[test]
fn geometry_needs_a_fresh_source_and_real_geometry() {
let mut ed = editor([text("ab")]);
let src = ed.source();
let empty = mathtex_ir::Fragment::default();
assert_eq!(ed.hit_test(&src, &empty, Point { x: 1.0, y: 1.0 }), Ok(None));
let out = ed.render(&src, &empty).unwrap();
assert!(out.selection.is_empty() && out.menu.is_none());
let _ = ed.exec(text("c"));
assert_eq!(ed.render(&src, &empty).unwrap_err(), StaleSource { source: 1, editor: 2 });
let other = editor([text("a"), text("bc")]);
assert!(other.render(&ed.source(), &empty).is_err());
}
#[test]
fn replace_typed_swaps_a_matching_run() {
let pi = Symbol { latex: "\\pi".into(), class: MathClass::Ord };
let mut ed = editor([text("api")]);
let cmd = |typed: &str| Command::ReplaceTyped { typed: typed.into(), with: vec![Command::InsertAtom(pi.clone())] };
assert!(!ed.exec(cmd("xi")).changed);
assert!(!ed.exec(cmd("zapi")).changed);
assert!(ed.exec(cmd("pi")).changed);
assert_eq!(tex(&ed), "a\\pi");
let mut ed = editor([text("5%")]);
let o = ed.exec(Command::ReplaceTyped { typed: "%".into(), with: vec![text("x")] });
assert!(o.changed);
assert_eq!(tex(&ed), "5x");
}
#[test]
fn close_delimiter_leaves_matching_delimiters_at_their_end() {
let mut ed = editor([delim('(', ')'), text("x"), Command::InsertScript(ScriptSlot::Sup), text("2")]);
assert_eq!(ed.input_context().closing_delimiter, Some(')'));
let o = ed.exec(Command::CloseDelimiter(')'));
assert!(o.moved && !o.changed);
assert_eq!(ed.cursor(), CaretPath::root(1));
assert_eq!(ed.input_context().closing_delimiter, None);
let _ = ed.exec(Command::CloseDelimiter(')'));
assert_eq!(tex(&ed), "\\left(x^2\\right))");
let mut ed = editor([delim('[', ']'), text("ab"), Command::Move(Dir::Left)]);
assert_eq!(ed.input_context().closing_delimiter, None);
let _ = ed.exec(Command::CloseDelimiter(']'));
assert_eq!(tex(&ed), "\\left[a]b\\right]");
}
#[test]
fn nesting_stops_at_the_depth_cap() {
let mut ed = Editor::new();
for _ in 0..MAX_DEPTH {
assert!(ed.exec(delim('(', ')')).changed);
}
for cmd in [delim('(', ')'), Command::InsertFraction(FracStyle::Bar), Command::InsertScript(ScriptSlot::Sub)] {
assert!(!ed.exec(cmd.clone()).changed, "{cmd:?}");
}
assert!(ed.exec(text("x")).changed);
let _ = ed.exec(Command::InsertScript(ScriptSlot::Sub));
assert_eq!(ed.document().validate(), Ok(()));
let mut wrapped = Editor::from_document(&ed.document()).unwrap();
let _ = wrapped.exec(Command::SelectAll);
assert!(!wrapped.exec(Command::InsertSqrt).changed);
}
#[test]
fn a_repaired_ragged_matrix_loads_and_row_ops_stay_live() {
let ragged = NodeDoc::Matrix { env: MatrixEnv::Bmatrix, rows: vec![vec![vec![atom("a")], vec![]], vec![]] };
let mut doc = Document::new(vec![ragged]);
assert_eq!(Editor::from_document(&doc).err(), Some(DocumentError::RaggedMatrix));
assert_eq!(doc.repair(), [Repair::PaddedMatrix]);
let mut ed = Editor::from_document(&doc).unwrap();
assert_eq!(ed.document(), doc);
ed.set_cursor(&at(vec![step(0, Slot::Cell { row: 1, col: 1 })], 0)).unwrap();
assert_eq!(ed.matrix_shape(), Some((2, 2)));
for cmd in [Command::MatrixDeleteRow, Command::MatrixDeleteCol, Command::MatrixDeleteRow, Command::MatrixDeleteCol] {
let _ = ed.exec(cmd);
assert!(ed.tree().seqs.contains_key(ed.raw_cursor().seq));
}
assert_eq!(ed.matrix_shape(), Some((1, 1)));
let _ = ed.exec(Command::MatrixInsertCol(Side::Before));
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Cell { row: 0, col: 0 })], 0));
assert_eq!(tex(&ed), "\\begin{bmatrix} & a\\end{bmatrix}");
}
#[test]
fn documents_from_json_must_validate() {
let good = r#"{"version":1,"root":[{"type":"atom","data":{"latex":"x","class":"ord"}}]}"#;
let doc: Document = serde_json::from_str(good).unwrap();
assert_eq!(Editor::from_document(&doc).unwrap().document(), doc);
let bad = r#"{"version":1,"root":[{"type":"atom","data":{"latex":"}","class":"ord"}}]}"#;
assert!(serde_json::from_str::<Document>(bad).is_err());
}
#[test]
fn invalid_symbols_and_delimiters_are_refused() {
let mut ed = Editor::new();
assert!(!ed.exec(Command::InsertAtom(sym("%"))).changed);
assert!(!ed.exec(Command::InsertBigOp(sym("{"))).changed);
assert!(!ed.exec(delim('x', ')')).changed);
assert!(ed.document().is_empty());
}
#[test]
fn menu_anchor_follows_render() {
let ed = delim_with_menu();
let src = ed.source();
let out = ed.render(&src, &mathtex_ir::Fragment::default()).unwrap();
assert!(out.menu.is_some());
assert!(ed.menu_anchor().is_some());
assert_eq!(ed.raw_anchor(), None);
assert!(matches!(ed.tree().kind(ed.menu_anchor().unwrap()), Some(Kind::Delim { .. })));
}
#[test]
fn input_context_serial_counts_state_changing_calls_only() {
let mut ed = Editor::new();
let s0 = ed.input_context().serial;
let _ = ed.exec(text("ab"));
let _ = ed.exec(Command::Move(Dir::Right));
assert_eq!(ed.input_context().serial, s0 + 2);
let snap = ed.snapshot();
let _ = (ed.source(), ed.cursor(), ed.menu(), ed.at_start());
assert_eq!(ed.input_context().serial, s0 + 2);
ed.set_cursor(&CaretPath::root(1)).unwrap();
ed.restore(&snap).unwrap();
ed.place_at(Edge::Start);
ed.set_selection(&CaretPath::root(0), &CaretPath::root(2)).unwrap();
assert_eq!(ed.input_context().serial, s0 + 6);
assert!(ed.set_cursor(&CaretPath::root(9)).is_err());
assert_eq!(ed.input_context().serial, s0 + 6);
}
#[test]
fn typed_characters_and_named_commands_share_one_class_table() {
let pairs = [('~', "\\sim"), ('{', "\\{"), ('}', "\\}"), ('⟨', "\\langle"), ('∘', "\\circ"), ('→', "\\rightarrow")];
for (c, latex) in pairs {
assert_eq!(Symbol::from_char(c).unwrap().class, Symbol::from_latex(latex).class, "{c} and {latex}");
}
assert_eq!(Symbol::from_latex("\\sin").class, MathClass::Op);
assert_eq!(Symbol::from_latex("\\ldots").class, MathClass::Inner);
assert_eq!(Symbol::from_latex("\\land").class, MathClass::Bin);
assert_eq!(Symbol::from_latex("\\pi").class, MathClass::Ord);
}
#[test]
fn invalid_documents_and_tokens_are_refused_not_repaired() {
let mut ed = editor([text("a")]);
let bad = Document::new(vec![NodeDoc::Script { base: vec![], sub: None, sup: None }]);
assert!(!ed.exec(Command::InsertDocument(bad.clone())).changed);
assert!(!ed.exec(Command::InsertHostBox(MAX_HOST_TOKEN + 1)).changed);
assert!(ed.exec(Command::InsertHostBox(MAX_HOST_TOKEN)).changed);
assert_eq!(tex(&ed), "a\\hostbox{2147483647}");
let snapshot = Snapshot { document: bad, cursor: CaretPath::root(0), selection: None };
assert_eq!(ed.restore(&snapshot), Err(RestoreError::Document(DocumentError::EmptyScript)));
assert_eq!(tex(&ed), "a\\hostbox{2147483647}");
}