use markdown::*;
use markdown::{parse::parse, serialize::serialize};
mod common;
fn text_of(doc: &Doc, ix: usize) -> &Text {
doc.blocks[ix]
.text_at(Part::Body)
.expect("block holds text")
}
#[test]
fn typing_at_the_end_of_a_mark_joins_it() {
let mut text = parse("**ab**").blocks[0]
.text_at(Part::Body)
.unwrap()
.clone();
text.insert(2, "c");
assert_eq!(text.text, "abc");
assert_eq!(text.marks[0].range, 0..3, "left-sticky: the tail is bold");
}
#[test]
fn typing_at_the_start_of_a_mark_stays_outside_it() {
let mut text = parse("**ab**").blocks[0]
.text_at(Part::Body)
.unwrap()
.clone();
text.insert(0, "x");
assert_eq!(text.text, "xab");
assert_eq!(text.marks[0].range, 1..3);
}
#[test]
fn removing_text_collapses_the_marks_over_it() {
let mut text = parse("a**bc**d").blocks[0]
.text_at(Part::Body)
.unwrap()
.clone();
text.remove(1..3);
assert_eq!(text.text, "ad");
assert!(
text.marks
.iter()
.all(|span| span.range.end <= text.text.len())
);
}
#[test]
fn toggling_twice_is_a_no_op() {
let mut text = Text::plain("hello");
let before = text.clone();
text.toggle(0..5, Mark::Bold);
assert!(text.covered_by(&(0..5), &Mark::Bold));
text.toggle(0..5, Mark::Bold);
assert_eq!(text, before);
}
#[test]
fn untoggling_the_middle_leaves_the_ends() {
let mut text = Text::plain("abcde");
text.toggle(0..5, Mark::Bold);
text.toggle(1..4, Mark::Bold);
let mut ranges: Vec<_> = text.marks.iter().map(|span| span.range.clone()).collect();
ranges.sort_by_key(|range| range.start);
assert_eq!(ranges, vec![0..1, 4..5]);
}
#[test]
fn abutting_marks_merge_so_they_serialize_as_one_run() {
let mut text = Text::plain("abcd");
text.toggle(0..2, Mark::Bold);
text.toggle(2..4, Mark::Bold);
assert_eq!(text.marks.len(), 1, "`**ab****cd**` is not one bold run");
assert_eq!(text.marks[0].range, 0..4);
}
#[test]
fn enter_in_a_list_makes_another_item() {
let mut doc = parse("- alpha");
let new = doc.split(0, 3);
assert_eq!(new, 1);
assert!(matches!(doc.blocks[1].kind, BlockKind::Bullet(_)));
assert_eq!(text_of(&doc, 0).text, "alp");
assert_eq!(text_of(&doc, 1).text, "ha");
}
#[test]
fn enter_after_a_heading_gives_body_text() {
let mut doc = parse("# Title");
doc.split(0, 5);
assert!(matches!(doc.blocks[1].kind, BlockKind::Paragraph(_)));
}
#[test]
fn enter_at_the_start_of_a_heading_moves_it_down_whole() {
let mut doc = parse("## abc");
assert_eq!(doc.split(0, 0), 1, "the caret stays with the heading");
assert!(matches!(doc.blocks[0].kind, BlockKind::Paragraph(_)));
assert!(text_of(&doc, 0).text.is_empty());
assert!(matches!(
doc.blocks[1].kind,
BlockKind::Heading { level: 2, .. }
));
assert_eq!(text_of(&doc, 1).text, "abc");
}
#[test]
fn enter_in_a_heading_gives_its_tail_to_body_text() {
let mut doc = parse("## abc");
doc.split(0, 1);
assert!(matches!(
doc.blocks[0].kind,
BlockKind::Heading { level: 2, .. }
));
assert_eq!(text_of(&doc, 0).text, "a");
assert!(matches!(doc.blocks[1].kind, BlockKind::Paragraph(_)));
assert_eq!(text_of(&doc, 1).text, "bc");
}
#[test]
fn backspace_walks_out_before_it_merges() {
let mut doc = parse("- a\n - b");
assert_eq!(doc.blocks[1].indent, 1);
let start = Cursor::new(1, Part::Body, 0);
doc.merge_back(start);
assert!(
matches!(doc.blocks[1].kind, BlockKind::Paragraph(_)),
"first press drops the marker"
);
assert_eq!(doc.blocks[1].indent, 1);
doc.merge_back(start);
assert_eq!(doc.blocks[1].indent, 0, "second press outdents");
let caret = doc.merge_back(start);
assert_eq!(
caret,
Some(Cursor::new(0, Part::Body, 1)),
"third press merges after \"a\""
);
assert_eq!(doc.blocks.len(), 1);
assert_eq!(text_of(&doc, 0).text, "ab");
}
#[test]
fn indenting_carries_the_children() {
let mut doc = parse("- a\n- b\n - c");
assert!(doc.indent(1));
assert_eq!(doc.blocks[1].indent, 1);
assert_eq!(doc.blocks[2].indent, 2, "the child came along");
}
#[test]
fn a_nested_run_starts_over() {
let mut doc = parse("1. a\n2. b\n3. c");
assert!(doc.indent(1));
assert_eq!(
serialize(&doc),
"1. a\n 1. b\n2. c",
"the sub-list starts at 1, and the outer list closes up behind it"
);
assert!(doc.indent(2));
assert_eq!(
serialize(&doc),
"1. a\n 1. b\n 2. c",
"the next item into the same sub-list carries it on"
);
}
#[test]
fn parsing_leaves_a_run_that_names_its_own_start() {
assert_eq!(serialize(&parse("5. a\n6. b")), "5. a\n6. b");
let mut doc = parse("- x\n5. a\n6. b");
assert!(doc.indent(1));
assert_eq!(serialize(&doc), "- x\n 1. a\n6. b");
}
#[test]
fn a_block_cannot_indent_more_than_one_past_the_one_above() {
let mut doc = parse("- a\n- b");
assert!(doc.indent(1));
assert!(
!doc.indent(1),
"two levels in one press would break the invariant"
);
assert!(!doc.indent(0), "the first block has nothing to nest under");
}
#[test]
fn shortcuts_cover_the_slash_menu_vocabulary() {
assert_eq!(shortcut("# x"), Some((Shortcut::Heading(1), 2)));
assert_eq!(shortcut("### x"), Some((Shortcut::Heading(3), 4)));
assert_eq!(shortcut("- [x] x"), Some((Shortcut::Task(true), 6)));
assert_eq!(shortcut("- [ ] x"), Some((Shortcut::Task(false), 6)));
assert_eq!(shortcut("- x"), Some((Shortcut::Bullet, 2)));
assert_eq!(shortcut("1. x"), Some((Shortcut::Ordered, 3)));
assert_eq!(shortcut("> x"), Some((Shortcut::Quote, 2)));
assert_eq!(shortcut("plain"), None);
assert_ne!(shortcut("- [ ] x").map(|hit| hit.0), Some(Shortcut::Bullet));
}
#[test]
fn no_edit_sequence_escapes_the_round_trip() {
const SEEDS: &[&str] = &[
"# Title\n\nBody **bold** text",
"- a\n- b\n - c",
"1. one\n2. two",
"- [ ] task\n- [x] done",
"> quoted\n\npara `code` tail",
"> [!TIP]\n> alert body\n\n> [!NOTE]",
"para with [link](u) and ",
];
let mut rng = common::Rng(0xed17);
for seed in SEEDS {
for case in 0..20_000 {
let mut doc = parse(seed);
for _ in 0..6 {
if doc.blocks.is_empty() {
break;
}
common::edit_randomly(&mut doc, &mut rng);
for block in &doc.blocks {
for part in block.parts() {
let Some(text) = block.text_at(part) else {
continue;
};
for span in &text.marks {
assert!(
span.range.end <= text.text.len()
&& text.text.is_char_boundary(span.range.start)
&& text.text.is_char_boundary(span.range.end),
"mark escaped its text: {span:?} in {:?}",
text.text
);
}
}
}
for ix in 0..doc.blocks.len() {
assert!(
doc.blocks[ix].indent <= doc.ceiling(ix),
"block {ix} is deeper than anything above it can hold"
);
}
}
doc.normalize();
let text = serialize(&doc);
assert_eq!(
parse(&text),
doc,
"seed {seed:?} case {case}: edits left a document that does not \
survive its own serializer\n--- serialized ---\n{text:?}\n"
);
}
}
}
#[test]
fn marks_over_a_selection_are_the_ones_it_carries_throughout() {
let doc = parse("**bold** and plain");
let all = Selection::new(
Cursor::new(0, Part::Body, 0),
Cursor::new(0, Part::Body, 18),
);
let inside = Selection::new(Cursor::new(0, Part::Body, 1), Cursor::new(0, Part::Body, 3));
assert_eq!(doc.marks(inside), vec![Mark::Bold], "all of it is bold");
assert!(
doc.marks(all).is_empty(),
"a selection reaching past the run carries nothing throughout"
);
}
#[test]
fn marks_at_a_caret_are_the_ones_the_next_character_would_join() {
let doc = parse("**bold** tail");
let at = |offset| Selection::at(Cursor::new(0, Part::Body, offset));
assert_eq!(doc.marks(at(4)), vec![Mark::Bold], "the end of the run");
assert!(doc.marks(at(0)).is_empty(), "and never the start of one");
assert!(doc.marks(at(8)).is_empty(), "nor past it");
}
#[test]
fn enter_in_a_quote_stays_in_the_quote() {
let note = Some(Quoted {
alert: Some(QuoteKind::Note),
});
let mut doc = parse("> [!NOTE]\n> - one");
let new = doc.split(0, 3);
assert_eq!(doc.blocks[new].quote, note);
assert!(matches!(doc.blocks[new].kind, BlockKind::Bullet(_)));
let mut doc = parse("> [!NOTE]\n> body");
doc.split(0, 0);
assert_eq!(doc.blocks[0].quote, note);
assert_eq!(text_of(&doc, 1).text, "body");
doc.normalize();
assert_eq!(serialize(&doc), "> [!NOTE]\n> body");
}
#[test]
fn peel_takes_the_marker_then_the_quote_then_the_indent() {
let mut doc = parse("- x\n\n > - a");
assert!(doc.peel(1));
assert!(matches!(doc.blocks[1].kind, BlockKind::Paragraph(_)));
assert!(doc.blocks[1].quote.is_some());
assert_eq!(doc.blocks[1].indent, 1);
assert!(doc.peel(1));
assert_eq!(doc.blocks[1].quote, None);
assert_eq!(doc.blocks[1].indent, 1);
assert!(doc.peel(1));
assert_eq!(doc.blocks[1].indent, 0);
assert!(!doc.peel(1));
}
#[test]
fn backspace_at_the_start_of_a_quoted_bullet_peels_before_it_merges() {
let mut doc = parse("a\n\n> - b");
let at = Cursor::new(1, Part::Body, 0);
assert_eq!(doc.merge_back(at), Some(at));
assert_eq!(serialize(&doc), "a\n\n> b");
assert_eq!(doc.merge_back(at), Some(at));
assert_eq!(serialize(&doc), "a\n\nb");
assert_eq!(doc.merge_back(at), Some(Cursor::new(0, Part::Body, 1)));
assert_eq!(serialize(&doc), "ab");
}
#[test]
fn toggling_a_quote() {
let mut doc = parse("> a\n>\n> b\n\nc");
doc.toggle_quote(1, Some(QuoteKind::Tip));
assert_eq!(serialize(&doc), "> [!TIP]\n> a\n>\n> b\n\nc");
doc.toggle_quote(1, Some(QuoteKind::Tip));
assert_eq!(serialize(&doc), "> [!TIP]\n> a\n\nb\n\nc");
doc.toggle_quote(2, Some(QuoteKind::Tip));
assert_eq!(serialize(&doc), "> [!TIP]\n> a\n\nb\n\n> [!TIP]\n> c");
}
#[test]
fn a_block_under_a_quoted_item_cannot_nest_outside_the_quote() {
let mut doc = parse("> - a\n>\n> b");
assert_eq!(doc.blocks[1].indent, 1);
doc.set_quote(1, None);
assert_eq!(doc.blocks[1].indent, 0, "nothing could write it under `a`");
assert_eq!(parse(&serialize(&doc)), doc);
}
#[test]
fn the_quote_shortcut_quotes_without_turning() {
let mut doc = parse("- item");
doc.apply(0, Shortcut::Quote);
assert_eq!(serialize(&doc), "> - item");
}