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 atoms(s: &str) -> Document {
Document::new(s.chars().map(|c| atom(&c.to_string())).collect())
}
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 == "brackets"));
}
#[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_eq!(ed.document(), atoms("x"));
assert_eq!(ed.cursor(), CaretPath::root(1));
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_eq!(ed.document(), atoms("x"));
assert_eq!(ed.cursor(), CaretPath::root(0));
}
#[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 == "brackets").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:?}");
}
fn swap_menus() -> Vec<Editor> {
let x = || vec![atom("x")];
let structures = [
NodeDoc::Delim { open: '(', close: ')', body: x() },
NodeDoc::BigOp { op: Symbol { latex: "\\sum".into(), class: MathClass::Op }, lower: x(), upper: x() },
NodeDoc::Accent { mark: Mark::Hat, base: x() },
NodeDoc::UnderOver { base: x(), over: Some(x()), under: Some(x()), over_deco: Deco::Brace, under_deco: Deco::None },
NodeDoc::Styled { variant: Variant::Bold, content: x() },
NodeDoc::Matrix { env: MatrixEnv::Pmatrix, rows: vec![vec![x(), x(), x()], vec![x(), x(), x()]] },
NodeDoc::LabeledArrow { arrow: Arrow::Right, over: x(), under: x() },
];
structures
.into_iter()
.map(|node| {
let mut ed = editor([Command::InsertDocument(Document::new(vec![node]))]);
ed.place_at(Edge::End);
let _ = ed.exec(Command::DeleteBackward);
assert!(ed.menu().is_some(), "{}", tex(&ed));
ed
})
.collect()
}
#[test]
fn menu_labels_are_plain_names_and_filter_by_them() {
for mut ed in swap_menus() {
let items = ed.menu().unwrap().items;
for item in &items {
assert!(item.label.chars().all(|c| c.is_ascii_alphabetic() || c == ' ' || c == ':'), "{:?}", item.label);
}
let query = items[1].label.split(' ').next_back().unwrap().to_string();
run(&mut ed, [text(&query)]);
let filtered = ed.menu().unwrap().items;
assert!(filtered.len() > 1 && filtered[1..].iter().all(|i| i.label.contains(&query)), "{query} {filtered:?}");
assert_eq!(filtered.iter().filter(|i| i.label.contains(&query)).count(), items.iter().filter(|i| i.label.contains(&query)).count());
}
}
#[test]
fn matrix_rows_filter_by_their_bracket_names() {
let mut ed = swap_menus().remove(5);
run(&mut ed, [text("brackets")]);
let labels: Vec<String> = ed.menu().unwrap().items.into_iter().map(|i| i.label).collect();
assert_eq!(labels, ["Delete", "matrix in brackets"]);
}
#[test]
fn menu_previews_follow_the_menu_order() {
for mut ed in swap_menus() {
for query in ["", "a"] {
if !query.is_empty() {
let _ = ed.exec(text(query));
}
let (items, previews) = (ed.menu().unwrap().items, ed.menu_previews());
assert_eq!(items.len(), previews.len());
for (item, preview) in items.iter().zip(&previews) {
assert_eq!(item.kind == MenuItemKind::Delete, preview.is_none(), "{item:?}");
}
}
}
assert!(Editor::new().menu_previews().is_empty());
}
#[test]
fn menu_previews_are_the_swapped_structure_with_empty_slots() {
for ed in swap_menus() {
let (items, previews) = (ed.menu().unwrap().items, ed.menu_previews());
assert_eq!(previews[0], None);
let anchor = ed.document().root()[0].clone();
for (i, (item, preview)) in items.iter().zip(&previews).enumerate().skip(1) {
let preview = preview.as_ref().expect("a swap row has a preview");
assert!(preview.validate().is_ok());
let mut swapped = editor([Command::InsertDocument(Document::new(vec![anchor.clone()]))]);
swapped.place_at(Edge::End);
let _ = swapped.exec(Command::DeleteBackward);
assert!(swapped.exec(Command::MenuSelect(i)).changed, "{}", item.label);
let expected = crate::menu::hollow(swapped.document().root()[0].clone());
assert_eq!(preview.root(), [expected], "{}", item.label);
let mut atoms = Vec::new();
preview.visit(|n| atoms.extend(matches!(n, NodeDoc::Atom(s) if s.latex == "x").then_some(())));
assert!(atoms.is_empty(), "{} keeps the anchor's content", item.label);
}
}
}
#[test]
fn styled_previews_show_their_font_on_a_sample_letter() {
let ed = swap_menus().remove(4);
let at = ed.menu().unwrap().items.iter().position(|i| i.label == "blackboard").unwrap();
assert_eq!(ed.menu_previews()[at].as_ref().unwrap().to_tex(), "\\mathbb{A}");
}
#[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_keeps_the_filled_slot() {
let mut ed = editor([Command::InsertSqrt, Command::Move(Dir::Up), text("3"), Command::Move(Dir::Down)]);
assert!(ed.exec(Command::DeleteBackward).changed);
assert_eq!((ed.document(), ed.cursor()), (atoms("3"), CaretPath::root(1)));
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)]);
assert!(ed.exec(Command::DeleteBackward).changed);
assert_eq!((ed.document(), ed.cursor()), (atoms("a"), CaretPath::root(0)));
}
#[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}");
}
fn seq_of(s: &str) -> Vec<NodeDoc> {
s.chars().map(|c| atom(&c.to_string())).collect()
}
fn around(node: NodeDoc) -> Editor {
Editor::from_document(&Document::new(vec![atom("a"), node, atom("z")])).unwrap()
}
fn press(ed: &mut Editor, slot: Slot, back: bool) -> Outcome {
let seq = ed.tree().resolve(&at(vec![step(1, slot)], 0)).unwrap().seq;
let index = if back { 0 } else { ed.tree().len(seq) };
ed.set_cursor(&at(vec![step(1, slot)], index)).unwrap();
assert_eq!(ed.raw_cursor().seq, seq, "the caret rests in {slot:?}");
ed.exec(if back { Command::DeleteBackward } else { Command::DeleteForward })
}
fn sum() -> Symbol {
Symbol { latex: "\\sum".into(), class: MathClass::Op }
}
fn grid(env: MatrixEnv, cells: [&str; 4]) -> NodeDoc {
NodeDoc::Matrix { env, rows: vec![vec![seq_of(cells[0]), seq_of(cells[1])], vec![seq_of(cells[2]), seq_of(cells[3])]] }
}
#[test]
fn deleting_at_a_slot_edge_dissolves_every_kind_in_reading_order() {
let frac = |style| NodeDoc::Frac { num: seq_of("p"), den: seq_of("q"), style };
let script = NodeDoc::Script { base: seq_of("x"), sub: Some(seq_of("i")), sup: Some(seq_of("n")) };
let (over, under) = (Some(seq_of("o")), Some(seq_of("u")));
let under_over = NodeDoc::UnderOver { base: seq_of("x"), over, under, over_deco: Deco::Brace, under_deco: Deco::None };
let cell = |row, col| Slot::Cell { row, col };
let cases = [
(frac(FracStyle::Bar), Slot::Numerator, true, "apqz", 1),
(frac(FracStyle::Bar), Slot::Denominator, true, "apqz", 2),
(frac(FracStyle::Bar), Slot::Numerator, false, "apqz", 2),
(frac(FracStyle::Bar), Slot::Denominator, false, "apqz", 3),
(frac(FracStyle::Binom), Slot::Denominator, true, "apqz", 2),
(NodeDoc::Sqrt { index: seq_of("3"), radicand: seq_of("xy") }, Slot::Index, true, "a3xyz", 1),
(NodeDoc::Sqrt { index: seq_of("3"), radicand: seq_of("xy") }, Slot::Radicand, true, "a3xyz", 2),
(NodeDoc::Sqrt { index: vec![], radicand: seq_of("xy") }, Slot::Radicand, false, "axyz", 3),
(script.clone(), Slot::Sub, true, "axinz", 2),
(script.clone(), Slot::Sup, true, "axinz", 3),
(script.clone(), Slot::Base, false, "axinz", 2),
(script, Slot::Sup, false, "axinz", 4),
(NodeDoc::BigOp { op: sum(), lower: seq_of("i"), upper: seq_of("n") }, Slot::Lower, true, "ainz", 1),
(NodeDoc::BigOp { op: sum(), lower: seq_of("i"), upper: seq_of("n") }, Slot::Upper, true, "ainz", 2),
(NodeDoc::BigOp { op: sum(), lower: seq_of("i"), upper: seq_of("n") }, Slot::Lower, false, "ainz", 2),
(NodeDoc::Delim { open: '(', close: ')', body: seq_of("xy") }, Slot::Body, true, "axyz", 1),
(NodeDoc::Delim { open: '(', close: ')', body: seq_of("xy") }, Slot::Body, false, "axyz", 3),
(NodeDoc::Delim { open: '(', close: ')', body: vec![] }, Slot::Body, true, "az", 1),
(NodeDoc::Accent { mark: Mark::Hat, base: seq_of("x") }, Slot::Base, true, "axz", 1),
(NodeDoc::Styled { variant: Variant::Bold, content: seq_of("x") }, Slot::Body, false, "axz", 2),
(under_over.clone(), Slot::Over, true, "axouz", 2),
(under_over.clone(), Slot::Under, true, "axouz", 3),
(under_over, Slot::Base, false, "axouz", 2),
(grid(MatrixEnv::Pmatrix, ["p", "q", "r", "s"]), cell(0, 0), true, "apqrsz", 1),
(grid(MatrixEnv::Pmatrix, ["p", "q", "r", "s"]), cell(1, 1), false, "apqrsz", 5),
(grid(MatrixEnv::Cases, ["p", "", "", "s"]), cell(0, 0), true, "apsz", 1),
(NodeDoc::Frac { num: vec![], den: seq_of("q"), style: FracStyle::Bar }, Slot::Numerator, true, "aqz", 1),
(NodeDoc::Frac { num: seq_of("p"), den: vec![], style: FracStyle::Bar }, Slot::Denominator, true, "apz", 2),
(NodeDoc::Frac { num: vec![], den: vec![], style: FracStyle::Bar }, Slot::Denominator, false, "az", 1),
(NodeDoc::Ratio { left: seq_of("p"), right: seq_of("q") }, Slot::Right, true, "apqz", 2),
(NodeDoc::Ratio { left: seq_of("p"), right: seq_of("q") }, Slot::Left, false, "apqz", 2),
(NodeDoc::Ratio { left: vec![], right: vec![] }, Slot::Left, true, "az", 1),
(NodeDoc::EvalBar { body: seq_of("f"), lower: seq_of("i"), upper: seq_of("n") }, Slot::Body, true, "afinz", 1),
(NodeDoc::EvalBar { body: seq_of("f"), lower: seq_of("i"), upper: seq_of("n") }, Slot::Lower, true, "afinz", 2),
(NodeDoc::EvalBar { body: seq_of("f"), lower: seq_of("i"), upper: seq_of("n") }, Slot::Upper, false, "afinz", 4),
(NodeDoc::EvalBar { body: seq_of("f"), lower: vec![], upper: vec![] }, Slot::Lower, true, "afz", 2),
(NodeDoc::LabeledArrow { arrow: Arrow::Right, over: seq_of("f"), under: seq_of("g") }, Slot::Over, true, "afgz", 1),
(NodeDoc::LabeledArrow { arrow: Arrow::Left, over: seq_of("f"), under: seq_of("g") }, Slot::Under, false, "afgz", 3),
];
for (node, slot, back, expected, caret) in cases {
let mut ed = around(node.clone());
let revision = ed.revision();
let o = press(&mut ed, slot, back);
let ctx = format!("{node:?} {slot:?} back={back}");
assert!(o.changed && o.revision == revision + 1, "{ctx}");
assert_eq!(ed.document(), atoms(expected), "{ctx}");
assert_eq!(ed.cursor(), CaretPath::root(caret), "{ctx}");
}
}
#[test]
fn dissolving_a_nested_structure_keeps_its_owner() {
let inner = NodeDoc::Delim { open: '(', close: ')', body: seq_of("x") };
let frac = NodeDoc::Frac { num: vec![atom("p"), inner], den: seq_of("q"), style: FracStyle::Bar };
let mut ed = around(frac);
ed.set_cursor(&at(vec![step(1, Slot::Numerator), step(1, Slot::Body)], 0)).unwrap();
assert!(ed.exec(Command::DeleteBackward).changed);
let expected = NodeDoc::Frac { num: seq_of("px"), den: seq_of("q"), style: FracStyle::Bar };
assert_eq!(ed.document().root()[1], expected);
assert_eq!(ed.cursor(), at(vec![step(1, Slot::Numerator)], 1));
let _ = ed.exec(Command::MoveLineStart);
assert!(ed.exec(Command::DeleteBackward).changed);
assert_eq!((ed.document(), ed.cursor()), (atoms("apxqz"), CaretPath::root(1)));
}
#[test]
fn empty_optional_slots_and_placeholders_keep_their_structure() {
let x_sub_sup = NodeDoc::Script { base: seq_of("x"), sub: Some(seq_of("i")), sup: Some(vec![]) };
let mut ed = around(x_sub_sup);
assert!(press(&mut ed, Slot::Sup, true).changed);
assert_eq!(ed.document().root()[1], NodeDoc::Script { base: seq_of("x"), sub: Some(seq_of("i")), sup: None });
assert_eq!(ed.cursor(), at(vec![step(1, Slot::Base)], 1));
let mut ed = around(NodeDoc::Script { base: seq_of("x"), sub: None, sup: Some(vec![]) });
assert!(press(&mut ed, Slot::Sup, false).changed);
assert_eq!((ed.document(), ed.cursor()), (atoms("axz"), CaretPath::root(2)));
let (over, under) = (Some(vec![]), Some(seq_of("u")));
let mut ed = around(NodeDoc::UnderOver { base: seq_of("x"), over, under, over_deco: Deco::Brace, under_deco: Deco::None });
assert!(press(&mut ed, Slot::Over, true).changed);
assert!(matches!(&ed.document().root()[1], NodeDoc::UnderOver { over: None, under: Some(_), .. }));
let mut ed = around(NodeDoc::Sqrt { index: vec![], radicand: seq_of("x") });
assert!(!press(&mut ed, Slot::Index, true).changed);
assert_eq!(ed.cursor(), CaretPath::root(1));
assert!(!press(&mut ed, Slot::Index, false).changed);
assert_eq!(ed.cursor(), at(vec![step(1, Slot::Radicand)], 0));
let mut ed = around(NodeDoc::BigOp { op: sum(), lower: seq_of("i"), upper: vec![] });
assert!(!press(&mut ed, Slot::Upper, true).changed);
assert_eq!(ed.cursor(), at(vec![step(1, Slot::Lower)], 1));
assert!(!press(&mut ed, Slot::Upper, false).changed);
assert_eq!(ed.cursor(), at(vec![step(1, Slot::Lower)], 0));
let mut ed = around(NodeDoc::EvalBar { body: seq_of("f"), lower: vec![], upper: seq_of("n") });
assert!(!press(&mut ed, Slot::Lower, true).changed);
assert_eq!(ed.cursor(), at(vec![step(1, Slot::Upper)], 1));
let mut ed = around(NodeDoc::LabeledArrow { arrow: Arrow::Right, over: seq_of("f"), under: vec![] });
assert!(!press(&mut ed, Slot::Under, true).changed);
assert_eq!(ed.cursor(), at(vec![step(1, Slot::Over)], 1));
}
#[test]
fn backspace_in_an_empty_script_base_pulls_in_its_left_neighbor() {
let script = NodeDoc::Script { base: vec![], sub: None, sup: Some(seq_of("2")) };
let mut ed = around(script.clone());
assert!(press(&mut ed, Slot::Base, true).changed);
let pulled = NodeDoc::Script { base: seq_of("a"), sub: None, sup: Some(seq_of("2")) };
assert_eq!(ed.document().root(), [pulled, atom("z")]);
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Base)], 1));
let mut ed = around(script.clone());
assert!(press(&mut ed, Slot::Base, false).changed);
assert_eq!((ed.document(), ed.cursor()), (atoms("a2z"), CaretPath::root(1)));
let mut ed = Editor::from_document(&Document::new(vec![script])).unwrap();
ed.set_cursor(&at(vec![step(0, Slot::Base)], 0)).unwrap();
assert!(ed.exec(Command::DeleteBackward).changed);
assert_eq!((ed.document(), ed.cursor()), (atoms("2"), CaretPath::root(0)));
}
#[test]
fn inner_matrix_cells_move_between_cells_and_only_the_grid_edges_dissolve() {
let mut ed = around(grid(MatrixEnv::Bmatrix, ["p", "q", "r", "s"]));
let cell = |row, col| Slot::Cell { row, col };
assert!(!press(&mut ed, cell(1, 0), true).changed);
assert_eq!(ed.cursor(), at(vec![step(1, cell(0, 1))], 1));
assert!(!press(&mut ed, cell(0, 1), false).changed);
assert_eq!(ed.cursor(), at(vec![step(1, cell(1, 0))], 0));
let mut ed = around(grid(MatrixEnv::Matrix, ["", "", "", ""]));
assert!(press(&mut ed, cell(1, 0), true).changed);
assert_eq!((ed.document(), ed.cursor()), (atoms("az"), CaretPath::root(1)));
}
#[test]
fn text_runs_and_host_boxes_never_split() {
let mut ed = around(NodeDoc::Styled { variant: Variant::Text, content: seq_of("ab") });
assert!(!press(&mut ed, Slot::Body, true).changed);
assert_eq!(ed.cursor(), CaretPath::root(1));
assert!(!press(&mut ed, Slot::Body, false).changed);
assert_eq!(ed.cursor(), CaretPath::root(2));
let mut ed = around(NodeDoc::Styled { variant: Variant::Text, content: vec![] });
assert!(press(&mut ed, Slot::Body, true).changed);
assert_eq!(ed.document(), atoms("az"));
let text = NodeDoc::Styled { variant: Variant::Text, content: seq_of("ab") };
let body = vec![NodeDoc::HostBox { token: 7 }, text.clone()];
let mut ed = around(NodeDoc::Accent { mark: Mark::Vec, base: body });
assert!(press(&mut ed, Slot::Base, true).changed);
assert_eq!(ed.document().root(), [atom("a"), NodeDoc::HostBox { token: 7 }, text, atom("z")]);
}
#[test]
fn the_menu_delete_row_dissolves_and_a_selection_still_removes() {
for mut ed in swap_menus() {
let content = ed.document();
let o = ed.exec(Command::MenuSelect(0));
assert!(o.changed && ed.menu().is_none());
let mut leaves = Vec::new();
content.visit(|n| leaves.extend(matches!(n, NodeDoc::Atom(_)).then_some(n.clone())));
assert_eq!(ed.document().root(), leaves, "{content:?}");
assert_eq!(ed.cursor(), CaretPath::root(leaves.len()), "{content:?}");
}
let mut ed = around(NodeDoc::Delim { open: '[', close: ']', body: seq_of("xy") });
ed.set_cursor(&CaretPath::root(1)).unwrap();
let _ = ed.exec(Command::DeleteForward);
assert!(ed.exec(Command::Confirm).changed);
assert_eq!((ed.document(), ed.cursor()), (atoms("axyz"), CaretPath::root(1)));
let mut ed = around(NodeDoc::Delim { open: '[', close: ']', body: seq_of("xy") });
ed.set_selection(&CaretPath::root(1), &CaretPath::root(2)).unwrap();
assert!(ed.exec(Command::DeleteBackward).changed);
assert_eq!(ed.document(), atoms("az"));
}
#[test]
fn a_dissolve_undoes_through_snapshot_and_restore() {
let mut ed = around(NodeDoc::Frac { num: seq_of("p"), den: seq_of("q"), style: FracStyle::Bar });
ed.set_cursor(&at(vec![step(1, Slot::Denominator)], 0)).unwrap();
let before = ed.snapshot();
assert!(ed.exec(Command::DeleteBackward).changed);
let rev = ed.revision();
ed.restore(&before).unwrap();
assert_eq!(ed.revision(), rev + 1);
assert_eq!(ed.snapshot(), before);
}
#[test]
fn a_ratio_spaces_its_colon_as_a_relation_and_reads_left_to_right() {
let mut ed = editor([Command::InsertRatio, text("a"), Command::Move(Dir::Right), text("b")]);
assert_eq!(tex(&ed), "{a}\\mathrel{:}{b}");
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Right)], 1));
assert_eq!(editor([Command::InsertRatio]).source().tex, "{\\phantom{x}}\\mathrel{:}{\\phantom{x}}");
let _ = ed.exec(Command::MoveLineStart);
let _ = ed.exec(Command::Move(Dir::Left));
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Left)], 1));
assert_eq!(ed.exec(Command::Move(Dir::Up)).exit, Some(ExitDir::Up));
let mut ed = editor([text("xy"), Command::SelectAll, Command::InsertRatio, text("z")]);
assert_eq!(tex(&ed), "{xy}\\mathrel{:}{z}");
let doc = ed.document();
assert_eq!(Editor::from_document(&doc).unwrap().document(), doc);
ed.place_at(Edge::End);
let _ = ed.exec(Command::DeleteBackward);
assert!(ed.menu().is_none() && ed.selection().is_some());
}
#[test]
fn an_evaluation_bar_fills_body_then_limits() {
let mut ed = editor([Command::InsertEvalBar, text("F"), Command::Tab, text("a"), Command::Tab, text("b")]);
assert_eq!(tex(&ed), "\\left.F\\right|_a^b");
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Upper)], 1));
assert_eq!(editor([Command::InsertEvalBar, text("F")]).display_source().tex, "\\left.F\\right|");
let src = editor([Command::InsertEvalBar]).source().tex;
assert_eq!(src, "\\left.\\phantom{x}\\right|_{\\phantom{x}}^{\\phantom{x}}");
ed.set_cursor(&at(vec![step(0, Slot::Body)], 1)).unwrap();
let _ = ed.exec(Command::Move(Dir::Down));
assert_eq!(ed.cursor().steps, [step(0, Slot::Lower)]);
let _ = ed.exec(Command::Move(Dir::Up));
let _ = ed.exec(Command::Move(Dir::Up));
assert_eq!(ed.cursor().steps, [step(0, Slot::Upper)]);
ed.place_at(Edge::End);
let _ = ed.exec(Command::InsertScript(ScriptSlot::Sub));
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Lower)], 1));
let mut ed = editor([text("Fx"), Command::SelectAll, Command::InsertEvalBar]);
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Body)], 2));
ed.place_at(Edge::End);
let _ = ed.exec(Command::DeleteBackward);
assert!(ed.menu().is_none() && ed.selection().is_some());
}
#[test]
fn a_labeled_arrow_swaps_direction_and_drops_an_empty_label_below() {
let mut ed = editor([Command::InsertLabeledArrow(Arrow::Right), text("f")]);
assert_eq!(tex(&ed), "\\xrightarrow{f}");
assert_eq!(ed.source().tex, "\\xrightarrow[{\\phantom{x}}]{f}");
let _ = ed.exec(Command::Move(Dir::Down));
let _ = ed.exec(text("g"));
assert_eq!(tex(&ed), "\\xrightarrow[{g}]{f}");
ed.place_at(Edge::End);
let _ = ed.exec(Command::InsertScript(ScriptSlot::Sup));
assert_eq!(ed.cursor(), at(vec![step(0, Slot::Over)], 1));
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_eq!(labels, ["Delete", "left arrow"]);
assert!(ed.exec(Command::MenuSelect(1)).changed);
assert_eq!(tex(&ed), "\\xleftarrow[{g}]{f}");
}
#[test]
fn delimiters_swap_to_half_open_intervals_and_norms() {
let mut ed = delim_with_menu();
let labels: Vec<String> = ed.menu().unwrap().items.into_iter().map(|i| i.label).collect();
let pick = |label: &str| labels.iter().position(|l| l == label).unwrap();
let (right_open, norm) = (pick("half open on the right"), pick("double bars"));
assert!(ed.exec(Command::MenuSelect(right_open)).changed);
assert_eq!(tex(&ed), "\\left[x\\right)");
let mut ed = delim_with_menu();
assert!(ed.exec(Command::MenuSelect(norm)).changed);
assert_eq!(tex(&ed), "\\left\\|x\\right\\|");
assert_eq!(tex(&editor([Command::InsertAccent(Mark::Ring), text("A")])), "\\mathring{A}");
}
#[test]
fn wrapped_commands_take_the_class_of_their_wrapper() {
assert_eq!(Symbol::from_latex("\\operatorname{Aut}").class, MathClass::Op);
assert_eq!(Symbol::from_latex("\\mathbin{\\triangle}").class, MathClass::Bin);
assert_eq!(Symbol::from_latex("\\mathrel{:}").class, MathClass::Rel);
assert_eq!(Symbol::from_latex("\\limsup").class, MathClass::Op);
assert_eq!(Symbol::from_latex("\\colon").class, MathClass::Punct);
assert_eq!(Symbol::from_latex("\\lesssim").class, MathClass::Rel);
}
#[test]
fn letters_before_caret_stop_at_any_other_item() {
let mut ed = editor([text("2xs"), Command::InsertAtom(Symbol::from_latex("\\alpha")), text("pin")]);
assert_eq!(ed.letters_before_caret(), "pin");
run(&mut ed, [Command::Move(Dir::Left)]);
assert_eq!(ed.letters_before_caret(), "pi");
ed.set_cursor(&at(vec![], 3)).unwrap();
assert_eq!(ed.letters_before_caret(), "xs", "a digit ends the run");
ed.set_cursor(&at(vec![], 0)).unwrap();
assert_eq!(ed.letters_before_caret(), "");
let mut ed = editor([text("ab"), Command::InsertFraction(FracStyle::Bar), text("cd")]);
assert_eq!(ed.letters_before_caret(), "cd", "a slot holds its own run");
run(&mut ed, [Command::Extend(Dir::Left)]);
assert_eq!(ed.letters_before_caret(), "", "a selection has no run before it");
let ed = editor([text("a"), Command::InsertStyled(Variant::Blackboard), text("R")]);
assert_eq!(ed.letters_before_caret(), "R");
}