use super::super::node::{CalloutKind, Fold, Inline};
use super::*;
fn first_block(md: &str) -> Block {
parse(md)
.blocks
.into_iter()
.next()
.expect("at least one block")
}
#[test]
fn parses_basic_callout_with_inline_title() {
match first_block("> [!note] Heads up\n> Body line 1.\n") {
Block::Callout {
kind,
fold,
title,
children,
} => {
assert_eq!(kind, CalloutKind::Note);
assert!(fold.is_none(), "non-foldable callout");
assert_eq!(title.as_deref(), Some("Heads up"));
assert!(!children.is_empty(), "body should remain");
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn parses_titleless_callout() {
match first_block("> [!warning]\n> Watch out.\n") {
Block::Callout {
kind,
fold,
title,
children,
} => {
assert_eq!(kind, CalloutKind::Warning);
assert!(fold.is_none());
assert!(title.is_none(), "no inline title");
assert!(!children.is_empty());
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_tldr_canonicalizes_to_abstract() {
match first_block("> [!tldr] Short summary\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Abstract),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_hint_canonicalizes_to_tip() {
match first_block("> [!hint] Pro tip\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_important_canonicalizes_to_tip() {
match first_block("> [!important] Read this\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_check_done_canonicalizes_to_success() {
for alias in &["check", "done"] {
let md = format!("> [!{alias}] Yes\n> body\n");
match first_block(&md) {
Block::Callout { kind, .. } => assert_eq!(
kind,
CalloutKind::Success,
"alias `{alias}` should canonicalize to Success"
),
other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
}
}
}
#[test]
fn callout_alias_help_faq_canonicalizes_to_question() {
for alias in &["help", "faq"] {
let md = format!("> [!{alias}] question\n> body\n");
match first_block(&md) {
Block::Callout { kind, .. } => assert_eq!(
kind,
CalloutKind::Question,
"alias `{alias}` should canonicalize to Question"
),
other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
}
}
}
#[test]
fn callout_alias_caution_attention_canonicalizes_to_warning() {
for alias in &["caution", "attention"] {
let md = format!("> [!{alias}] careful\n> body\n");
match first_block(&md) {
Block::Callout { kind, .. } => assert_eq!(
kind,
CalloutKind::Warning,
"alias `{alias}` should canonicalize to Warning"
),
other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
}
}
}
#[test]
fn callout_alias_fail_missing_canonicalizes_to_failure() {
for alias in &["fail", "missing"] {
let md = format!("> [!{alias}] oops\n> body\n");
match first_block(&md) {
Block::Callout { kind, .. } => assert_eq!(
kind,
CalloutKind::Failure,
"alias `{alias}` should canonicalize to Failure"
),
other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
}
}
}
#[test]
fn callout_alias_error_canonicalizes_to_danger() {
match first_block("> [!error] bad\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Danger),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_cite_canonicalizes_to_quote() {
match first_block("> [!cite] source\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Quote),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_foldable_open_suffix() {
match first_block("> [!note]+ Open by default\n> body\n") {
Block::Callout {
kind, fold, title, ..
} => {
assert_eq!(kind, CalloutKind::Note);
assert_eq!(fold, Some(Fold::Open));
assert_eq!(title.as_deref(), Some("Open by default"));
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_foldable_closed_suffix() {
match first_block("> [!note]- Closed by default\n> body\n") {
Block::Callout {
kind, fold, title, ..
} => {
assert_eq!(kind, CalloutKind::Note);
assert_eq!(fold, Some(Fold::Closed));
assert_eq!(title.as_deref(), Some("Closed by default"));
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_foldable_without_title() {
match first_block("> [!tip]+\n> body\n") {
Block::Callout {
kind, fold, title, ..
} => {
assert_eq!(kind, CalloutKind::Tip);
assert_eq!(fold, Some(Fold::Open));
assert!(title.is_none());
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_unknown_kind_falls_back_to_note() {
match first_block("> [!unknownkind] body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
other => panic!("expected Callout (fallback to Note), got {other:?}"),
}
}
#[test]
fn callout_multi_paragraph_body_preserves_blocks() {
let md = "> [!info] Multi\n> First paragraph.\n>\n> Second paragraph.\n";
match first_block(md) {
Block::Callout {
kind,
title,
children,
..
} => {
assert_eq!(kind, CalloutKind::Info);
assert_eq!(title.as_deref(), Some("Multi"));
let para_count = children
.iter()
.filter(|b| matches!(b, Block::Paragraph(_)))
.count();
assert!(
para_count >= 2,
"expected at least 2 paragraphs, got {children:?}"
);
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_nested_inside_callout() {
let md = "> [!warning] Outer\n> Outer content.\n>\n> > [!tip] Inner\n> > Inner content.\n";
match first_block(md) {
Block::Callout {
kind: outer_kind,
children,
..
} => {
assert_eq!(outer_kind, CalloutKind::Warning);
let inner = children.iter().find_map(|b| match b {
Block::Callout { kind, title, .. } => Some((*kind, title.clone())),
_ => None,
});
let (inner_kind, inner_title) =
inner.expect("inner Block::Callout missing from outer's children");
assert_eq!(inner_kind, CalloutKind::Tip);
assert_eq!(inner_title.as_deref(), Some("Inner"));
}
other => panic!("expected outer Callout, got {other:?}"),
}
}
#[test]
fn plain_blockquote_without_marker_stays_blockquote() {
match first_block("> Just a quote.\n> More of the quote.\n") {
Block::BlockQuote(_) => {} other => panic!("expected BlockQuote, got {other:?}"),
}
}
#[test]
fn blockquote_with_text_starting_like_callout_but_unknown_kind_still_promotes() {
match first_block("> [!xyz] not a real kind\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
other => panic!("expected Callout fallback, got {other:?}"),
}
}
#[test]
fn callout_case_insensitive_kind() {
match first_block("> [!WARNING] Loud\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Warning),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_pending_alias_canonicalizes_to_todo() {
match first_block("> [!pending] Trailer video\n> Add when ready.\n") {
Block::Callout { kind, title, .. } => {
assert_eq!(kind, CalloutKind::Todo);
assert_eq!(title.as_deref(), Some("Trailer video"));
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn empty_input_yields_empty_document() {
let d = parse("");
assert!(d.blocks.is_empty());
}
#[test]
fn parses_h1_heading() {
match first_block("# Hello\n") {
Block::Heading {
level,
children,
id,
} => {
assert_eq!(level, 1);
assert_eq!(id.as_deref(), Some("hello"));
assert!(matches!(&children[0], Inline::Text(t) if t == "Hello"));
}
other => panic!("expected Heading, got {other:?}"),
}
}
#[test]
fn parses_h6_heading() {
match first_block("###### tiny\n") {
Block::Heading { level, .. } => assert_eq!(level, 6),
other => panic!("expected Heading, got {other:?}"),
}
}
#[test]
fn parses_paragraph_with_text() {
match first_block("hello world\n") {
Block::Paragraph(children) => {
let s: String = children
.iter()
.filter_map(|i| match i {
Inline::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(s, "hello world");
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_link_with_unresolved_url() {
match first_block("[Docs](docs/)\n") {
Block::Paragraph(children) => match &children[0] {
Inline::Link {
url,
title,
children,
is_wikilink,
} => {
assert!(url.is_unresolved());
match url {
Url::Unresolved(s) => assert_eq!(s, "docs/"),
_ => unreachable!(),
}
assert!(title.is_none());
assert!(!is_wikilink, "standard markdown link is not a wikilink");
assert!(matches!(&children[0], Inline::Text(t) if t == "Docs"));
}
other => panic!("expected Link, got {other:?}"),
},
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_link_with_moss_resolved_prefix_unchanged() {
match first_block("[t](moss-resolved:foo.md)\n") {
Block::Paragraph(children) => match &children[0] {
Inline::Link {
url: Url::Unresolved(s),
..
} => assert_eq!(s, "moss-resolved:foo.md"),
other => panic!("expected unresolved Link, got {other:?}"),
},
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parser_link_inherits_wikilink_from_pulldown_cmark() {
match first_block("[[wikilink-target]]\n") {
Block::Paragraph(children) => {
let link = children
.iter()
.find(|i| matches!(i, Inline::Link { .. }))
.expect("expected an Inline::Link from [[…]]");
match link {
Inline::Link { is_wikilink, .. } => {
assert!(
*is_wikilink,
"[[…]] must set is_wikilink: true on the typed AST"
);
}
_ => unreachable!(),
}
}
other => panic!("expected Paragraph, got {other:?}"),
}
match first_block("[text](href)\n") {
Block::Paragraph(children) => match &children[0] {
Inline::Link { is_wikilink, .. } => {
assert!(!is_wikilink, "[](…) must set is_wikilink: false");
}
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn parses_link_with_title() {
match first_block(r#"[t](u "the title")"#) {
Block::Paragraph(children) => match &children[0] {
Inline::Link { title, .. } => assert_eq!(title.as_deref(), Some("the title")),
other => panic!("expected Link, got {other:?}"),
},
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_image_with_alt() {
match first_block("\n") {
Block::Figure { image, caption, .. } => {
match image {
Inline::Image {
src, alt, title, ..
} => {
assert!(src.is_unresolved());
assert_eq!(alt, "cat photo");
assert!(title.is_none());
}
other => panic!("expected Image inside Figure, got {other:?}"),
}
let cap = caption.expect("caption from alt text");
assert_eq!(cap.len(), 1);
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn parses_image_inside_paragraph_with_text() {
match first_block("see  here\n") {
Block::Paragraph(children) => {
let img = children
.iter()
.find(|i| matches!(i, Inline::Image { .. }))
.expect("expected Inline::Image among siblings");
match img {
Inline::Image { src, alt, .. } => {
assert!(src.is_unresolved());
assert_eq!(alt, "cat photo");
}
_ => unreachable!(),
}
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_emphasis_and_strong() {
let para = parse("*em* and **strong**\n")
.blocks
.into_iter()
.next()
.unwrap();
match para {
Block::Paragraph(children) => {
let has_em = children.iter().any(|i| matches!(i, Inline::Emphasis(_)));
let has_strong = children.iter().any(|i| matches!(i, Inline::Strong(_)));
assert!(has_em, "missing Emphasis: {children:?}");
assert!(has_strong, "missing Strong: {children:?}");
}
_ => panic!("expected Paragraph"),
}
}
#[test]
fn parses_inline_code() {
match first_block("`some code`\n") {
Block::Paragraph(children) => {
assert!(matches!(&children[0], Inline::Code(c) if c == "some code"));
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_unordered_list() {
match first_block("- one\n- two\n") {
Block::List { ordered, items, .. } => {
assert!(!ordered);
assert_eq!(items.len(), 2);
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parser_handles_tight_list_items_with_inline_content() {
match first_block("- **bold** text\n- another item\n") {
Block::List { ordered, items, .. } => {
assert!(!ordered);
assert_eq!(items.len(), 2, "expected two items, got {items:?}");
let first_item = &items[0];
assert_eq!(
first_item.len(),
1,
"tight item should synthesize a single Paragraph, got {first_item:?}"
);
match &first_item[0] {
Block::Paragraph(inlines) => {
let has_strong = inlines.iter().any(|i| matches!(i, Inline::Strong(_)));
let has_text = inlines
.iter()
.any(|i| matches!(i, Inline::Text(t) if t.contains("text")));
assert!(
has_strong,
"expected Inline::Strong inside item, got {inlines:?}"
);
assert!(has_text, "expected ' text' Inline::Text, got {inlines:?}");
}
other => panic!("expected Paragraph inside tight item, got {other:?}"),
}
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn tight_list_items_with_links_preserved() {
match first_block("- [link](url)\n- \n") {
Block::List { items, .. } => {
assert_eq!(items.len(), 2);
let first = &items[0];
assert_eq!(
first.len(),
1,
"expected one Block::Paragraph, got {first:?}"
);
match &first[0] {
Block::Paragraph(inlines) => {
assert!(
inlines.iter().any(|i| matches!(i, Inline::Link { .. })),
"expected Inline::Link, got {inlines:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
let second = &items[1];
match &second[0] {
Block::Paragraph(inlines) => {
assert!(
inlines.iter().any(|i| matches!(i, Inline::Image { .. })),
"expected Inline::Image, got {inlines:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn loose_list_items_with_paragraphs_still_work() {
let md = "- first item\n\n- second item\n";
match first_block(md) {
Block::List { items, .. } => {
assert_eq!(items.len(), 2);
for item in &items {
assert_eq!(item.len(), 1, "expected one block per item");
assert!(
matches!(&item[0], Block::Paragraph(_)),
"expected Paragraph, got {:?}",
item[0]
);
}
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn tight_list_items_with_nested_list_preserve_structure() {
let md = "- first\n - nested\n";
match first_block(md) {
Block::List { items, .. } => {
assert_eq!(items.len(), 1);
let outer = &items[0];
assert!(
outer.iter().any(|b| matches!(b, Block::Paragraph(_))),
"expected outer item to carry a Paragraph for 'first', got {outer:?}"
);
assert!(
outer.iter().any(|b| matches!(b, Block::List { .. })),
"expected outer item to carry a nested List, got {outer:?}"
);
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parses_ordered_list() {
match first_block("1. first\n2. second\n") {
Block::List { ordered, items, .. } => {
assert!(ordered);
assert_eq!(items.len(), 2);
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parses_fenced_code_block_with_lang() {
match first_block("```rust\nfn main() {}\n```\n") {
Block::CodeBlock { lang, value } => {
assert_eq!(lang.as_deref(), Some("rust"));
assert!(value.contains("fn main"));
}
other => panic!("expected CodeBlock, got {other:?}"),
}
}
#[test]
fn parses_fenced_code_block_without_lang() {
match first_block("```\nbare\n```\n") {
Block::CodeBlock { lang, value } => {
assert!(lang.is_none());
assert!(value.contains("bare"));
}
other => panic!("expected CodeBlock, got {other:?}"),
}
}
#[test]
fn code_block_is_not_parsed_as_shortcode() {
let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
match first_block(md) {
Block::CodeBlock { value, .. } => assert!(value.contains(":::buttons")),
other => panic!("expected CodeBlock, got {other:?}"),
}
}
#[test]
fn parses_blockquote() {
match first_block("> quoted\n") {
Block::BlockQuote(children) => {
assert!(!children.is_empty());
}
other => panic!("expected BlockQuote, got {other:?}"),
}
}
#[test]
fn parses_thematic_break() {
match first_block("---\n") {
Block::ThematicBreak => {}
_other => {
let d = parse("para\n\n---\n\nmore\n");
let has_break = d.blocks.iter().any(|b| matches!(b, Block::ThematicBreak));
assert!(
has_break,
"expected at least one ThematicBreak: {:?}",
d.blocks
);
}
}
}
#[test]
fn parses_table() {
let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n";
match first_block(md) {
Block::Table { header, rows, .. } => {
assert_eq!(header.len(), 2);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].len(), 2);
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn html_block_passes_through_as_other() {
match first_block("<div class=\"raw\">hi</div>\n\n") {
Block::Other(html) => assert!(html.contains("<div")),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn parses_multiple_blocks() {
let d = parse("# T\n\npara\n\n- li\n");
assert_eq!(d.blocks.len(), 3);
assert!(matches!(d.blocks[0], Block::Heading { .. }));
assert!(matches!(d.blocks[1], Block::Paragraph(_)));
assert!(matches!(d.blocks[2], Block::List { .. }));
}
#[test]
fn frontmatter_only_input_is_handled() {
let _ = parse("---\nfoo: bar\n---\n");
}
#[test]
fn link_inside_heading_is_preserved() {
match first_block("# [t](u)\n") {
Block::Heading { children, .. } => {
assert!(matches!(&children[0], Inline::Link { .. }));
}
other => panic!("expected Heading, got {other:?}"),
}
}
fn heading_id(md: &str) -> Option<String> {
let blocks = parse(md).blocks;
for block in &blocks {
if let Block::Heading { id, .. } = block {
return id.clone();
}
}
None
}
#[test]
fn heading_id_simple_phrase() {
assert_eq!(heading_id("## Mission\n"), Some("mission".to_string()));
}
#[test]
fn heading_id_spaces_become_hyphens() {
assert_eq!(
heading_id("# Getting Started\n"),
Some("getting-started".to_string())
);
}
#[test]
fn heading_id_with_emphasis_uses_text_content() {
assert_eq!(
heading_id("# Hello *world*\n"),
Some("hello-world".to_string())
);
}
#[test]
fn heading_id_with_strong_uses_text_content() {
assert_eq!(
heading_id("# Bold **stuff**\n"),
Some("bold-stuff".to_string())
);
}
#[test]
fn heading_id_with_inline_link_uses_link_text() {
assert_eq!(heading_id("# [Docs](url)\n"), Some("docs".to_string()));
}
#[test]
fn heading_id_with_inline_code_includes_code_payload() {
assert_eq!(
heading_id("# call `fn(x)`\n"),
Some("call-fn(x)".to_string())
);
}
#[test]
fn heading_id_with_inline_html_strips_html() {
let id = heading_id("# FAREWELL,<br>AND ERASE\n").expect("heading id");
assert!(!id.contains("br"), "got: {id}");
assert_eq!(id, "farewell,and-erase");
}
#[test]
fn heading_id_cjk_preserved() {
assert_eq!(heading_id("## 视频\n"), Some("视频".to_string()));
assert_eq!(heading_id("## 中文标题\n"), Some("中文标题".to_string()));
}
#[test]
fn heading_id_obsidian_strip_chars() {
assert_eq!(heading_id("# Note ^ref\n"), Some("note-ref".to_string()));
assert_eq!(heading_id("# A | B\n"), Some("a-b".to_string()));
}
#[test]
fn duplicate_headings_get_suffixed_ids() {
let md = "# Mission\n\n# Mission\n\n# Mission\n";
let doc = parse(md);
let ids: Vec<Option<String>> = doc
.blocks
.iter()
.filter_map(|b| match b {
Block::Heading { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
assert_eq!(
ids,
vec![
Some("mission".to_string()),
Some("mission-1".to_string()),
Some("mission-2".to_string()),
]
);
}
#[test]
fn duplicate_suffix_descends_into_blockquote() {
let md = "# Notes\n\n> # Notes\n";
let doc = parse(md);
let mut found_ids: Vec<String> = Vec::new();
collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
assert_eq!(found_ids, vec!["notes".to_string(), "notes-1".to_string()]);
}
#[test]
fn duplicate_suffix_descends_into_footnote_definition() {
let md = "## Notes\n\nX[^h].\n\n[^h]: body\n\n ## Notes\n";
let doc = parse(md);
let mut found_ids: Vec<String> = Vec::new();
collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
assert_eq!(found_ids, vec!["notes".to_string(), "notes-1".to_string()]);
}
#[test]
fn duplicate_suffix_descends_into_grid_cells() {
let doc = parse(":::grid\n### Notes\n:::\n\n## Notes\n");
let mut found_ids: Vec<String> = Vec::new();
collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
assert_eq!(found_ids, vec!["notes".to_string(), "notes-1".to_string()]);
}
#[test]
fn duplicate_suffix_dedups_two_cells_of_one_grid() {
let doc = parse(":::grid 2\n### Team\n+++\n### Team\n:::\n");
let mut found_ids: Vec<String> = Vec::new();
collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
assert_eq!(found_ids, vec!["team".to_string(), "team-1".to_string()]);
}
#[test]
fn duplicate_suffix_descends_into_hero_overlay() {
let doc = parse(":::hero {image=a.jpg}\n### Notes\n:::\n\n## Notes\n");
let mut found_ids: Vec<String> = Vec::new();
collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
assert_eq!(found_ids, vec!["notes".to_string(), "notes-1".to_string()]);
}
#[test]
fn duplicate_suffix_descends_into_link_card() {
let doc = parse(":::grid\n[### Notes\n\ntext](/a)\n:::\n\n## Notes\n");
let mut found_ids: Vec<String> = Vec::new();
collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
assert_eq!(found_ids, vec!["notes".to_string(), "notes-1".to_string()]);
}
fn collect_heading_ids_recursive(blocks: &[Block], out: &mut Vec<String>) {
use super::super::shortcode::Shortcode;
for b in blocks {
match b {
Block::Heading { id, .. } => {
if let Some(s) = id {
out.push(s.clone());
}
}
Block::BlockQuote(children)
| Block::Callout { children, .. }
| Block::LinkCard { children, .. }
| Block::FootnoteDefinition { children, .. } => {
collect_heading_ids_recursive(children, out);
}
Block::List { items, .. } => {
for item in items {
collect_heading_ids_recursive(item, out);
}
}
Block::Shortcode(Shortcode::Grid(args)) => {
for cell in &args.cells {
collect_heading_ids_recursive(cell, out);
}
}
Block::Shortcode(Shortcode::Hero(args)) => {
collect_heading_ids_recursive(&args.overlay, out);
}
_ => {}
}
}
}
#[test]
fn heading_id_empty_text_yields_empty_slug() {
let md = "# ###\n";
let id = heading_id(md);
assert_eq!(id, Some(String::new()));
}
#[test]
fn link_inside_emphasis_unwraps_correctly() {
match first_block("*[t](u)*\n") {
Block::Paragraph(children) => match &children[0] {
Inline::Emphasis(inner) => {
assert!(matches!(&inner[0], Inline::Link { .. }));
}
other => panic!("expected Emphasis, got {other:?}"),
},
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn image_only_paragraph_promotes_to_figure() {
match first_block("\n") {
Block::Figure { image, caption, .. } => {
match image {
Inline::Image { src, alt, .. } => {
assert!(src.is_unresolved());
assert_eq!(alt, "A logo");
}
other => panic!("expected Image inside Figure, got {other:?}"),
}
let cap = caption.expect("caption from alt text");
assert_eq!(cap.len(), 1);
assert!(matches!(&cap[0], Inline::Text(t) if t == "A logo"));
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn image_only_paragraph_with_empty_alt_stays_as_paragraph() {
match first_block("\n") {
Block::Paragraph(children) => {
assert_eq!(children.len(), 1);
match &children[0] {
Inline::Image { alt, .. } => assert_eq!(alt, ""),
other => panic!("expected Image inside Paragraph, got {other:?}"),
}
}
other => panic!("empty-alt image-only paragraph must stay as Paragraph, got {other:?}"),
}
}
#[test]
fn image_with_whitespace_text_still_promotes_to_figure() {
let md = " \n";
match first_block(md) {
Block::Figure { image, .. } => assert!(matches!(image, Inline::Image { .. })),
Block::Paragraph(_) => {}
other => panic!("expected Figure or Paragraph, got {other:?}"),
}
}
#[test]
fn image_with_caption_text_does_not_promote() {
match first_block(" plain caption text\n") {
Block::Paragraph(children) => {
assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
assert!(
children
.iter()
.any(|i| matches!(i, Inline::Text(t) if t.contains("plain"))),
"expected sibling Text to remain, got {children:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn image_with_emphasis_sibling_does_not_promote() {
match first_block(" *caption*\n") {
Block::Paragraph(children) => {
assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
assert!(
children.iter().any(|i| matches!(i, Inline::Emphasis(_))),
"expected Emphasis to remain, got {children:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn two_images_in_one_paragraph_do_not_promote() {
match first_block(" \n") {
Block::Paragraph(children) => {
let img_count = children
.iter()
.filter(|i| matches!(i, Inline::Image { .. }))
.count();
assert_eq!(img_count, 2);
}
other => panic!("expected Paragraph (two images), got {other:?}"),
}
}
#[test]
fn standard_image_percent_promotes_with_width() {
match first_block("\n") {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
let cap = caption.expect("caption from remaining alt");
assert!(matches!(cap.as_slice(), [Inline::Text(t)] if t == "alt"));
}
other => panic!("expected a Figure, got {other:?}"),
}
}
#[test]
fn standard_image_percent_empty_alt_still_promotes() {
match first_block("\n") {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
assert!(
caption.is_none() || matches!(caption.as_deref(), Some([])),
"empty-alt-with-width figure must not carry a caption: {caption:?}"
);
}
other => panic!("expected a Figure even with empty alt when width present, got {other:?}"),
}
}
#[test]
fn standard_image_no_width_unchanged() {
match first_block("\n") {
Block::Figure { width, .. } => assert_eq!(width, None),
other => panic!("expected a Figure, got {other:?}"),
}
}
#[test]
fn plain_paragraph_still_parses_as_paragraph() {
match first_block("just some prose\n") {
Block::Paragraph(_) => {}
other => panic!("expected Paragraph, got {other:?}"),
}
}
use super::super::shortcode::Shortcode;
#[test]
fn parses_subscribe_block_into_typed_shortcode() {
let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
:::
"#;
let doc = parse(md);
let mut found: Option<&Shortcode> = None;
for block in &doc.blocks {
if let Block::Shortcode(sc) = block {
found = Some(sc);
break;
}
}
let sc = found.expect("expected Block::Shortcode");
match sc {
Shortcode::Subscribe(args) => {
assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
assert_eq!(args.button.as_deref(), Some("Sign me up"));
}
other => panic!("expected Subscribe, got {other:?}"),
}
}
#[test]
fn subscribe_block_does_not_leave_sentinel_in_other_block() {
let md = ":::subscribe\n:::\n";
let doc = parse(md);
for block in &doc.blocks {
if let Block::Other(html) = block {
assert!(
!html.contains("MOSS_SHORTCODE"),
"unsubstituted sentinel remained in AST: {html:?}"
);
}
}
}
#[test]
fn subscribe_inside_paragraph_text_is_not_extracted() {
let md = "Read more about :::subscribe in the docs.\n";
let doc = parse(md);
for block in &doc.blocks {
assert!(
!matches!(block, Block::Shortcode(_)),
"`:::subscribe` inline-text was wrongly extracted as a shortcode"
);
}
}
#[test]
fn subscribe_block_alongside_other_content_preserves_order() {
let md = "# H\n\nfirst para\n\n:::subscribe\ndescription: d\n:::\n\nlast para\n";
let doc = parse(md);
let kinds: Vec<&'static str> = doc
.blocks
.iter()
.map(|b| match b {
Block::Heading { .. } => "h",
Block::Paragraph(_) => "p",
Block::Shortcode(_) => "sc",
_ => "x",
})
.collect();
assert_eq!(kinds, vec!["h", "p", "sc", "p"]);
}
#[test]
fn parse_default_config_keeps_block_meta_empty() {
let doc = parse("# H1\n\npara one\n\npara two\n");
assert_eq!(doc.blocks.len(), 3);
assert_eq!(doc.block_meta.len(), doc.blocks.len());
for meta in &doc.block_meta {
assert!(
meta.source_line.is_none(),
"default parse should not populate source_line: {meta:?}"
);
}
}
#[test]
fn parse_with_source_lines_assigns_1_based_line_numbers() {
let md = "# H1\n\npara on line 3\n\n## H2 on line 5\n\npara on line 7\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
hard_line_breaks: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 4);
assert_eq!(doc.block_meta.len(), 4);
assert_eq!(doc.block_meta[0].source_line, Some(1), "H1 on line 1");
assert_eq!(doc.block_meta[1].source_line, Some(3), "P on line 3");
assert_eq!(doc.block_meta[2].source_line, Some(5), "H2 on line 5");
assert_eq!(doc.block_meta[3].source_line, Some(7), "P on line 7");
}
#[test]
fn source_line_offset_is_applied_additively() {
let md = "# H1\n\npara on line 3\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 7,
math: false,
hard_line_breaks: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(
doc.block_meta[0].source_line,
Some(8),
"H1 body-line 1 + offset 7"
);
assert_eq!(
doc.block_meta[1].source_line,
Some(10),
"P body-line 3 + offset 7"
);
}
#[test]
fn source_lines_not_collapsed_across_multiline_shortcode() {
let md =
"# Title\n\n:::grid 3\n[\n\n](/x)\n+++\n[\n\n](/y)\n:::\n\n## After\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
hard_line_breaks: false,
};
let doc = parse_with_config(md, &config);
let last = doc
.block_meta
.last()
.expect("at least one block")
.source_line;
assert_eq!(
last,
Some(13),
"heading after a multi-line grid must keep its real line 13, not a collapsed line"
);
}
#[test]
fn parse_with_source_lines_lists_and_blockquotes() {
let md = "- item one\n- item two\n\n> quote on line 4\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
hard_line_breaks: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 2);
assert_eq!(doc.block_meta[0].source_line, Some(1), "ul on line 1");
assert_eq!(doc.block_meta[1].source_line, Some(4), "bq on line 4");
}
#[test]
fn parse_with_source_lines_populates_item_lines_on_list() {
let md = "- one\n- two\n- three\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
hard_line_breaks: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List {
items,
item_source_lines,
..
} => {
assert_eq!(items.len(), 3);
assert_eq!(
item_source_lines.len(),
3,
"item_source_lines must be parallel to items"
);
assert_eq!(item_source_lines[0], Some(1));
assert_eq!(item_source_lines[1], Some(2));
assert_eq!(item_source_lines[2], Some(3));
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parse_default_config_leaves_item_source_lines_empty() {
let doc = parse("- one\n- two\n");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List {
item_source_lines, ..
} => {
assert!(
item_source_lines.is_empty(),
"default config must NOT populate item_source_lines (publish builds): {item_source_lines:?}"
);
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parse_with_source_lines_populates_row_lines_on_table() {
let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n| e | f |\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
hard_line_breaks: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::Table {
rows,
header_source_line,
row_source_lines,
..
} => {
assert_eq!(rows.len(), 3);
assert_eq!(*header_source_line, Some(1), "header tr line");
assert_eq!(
row_source_lines.len(),
3,
"row_source_lines must be parallel to rows"
);
assert_eq!(row_source_lines[0], Some(3));
assert_eq!(row_source_lines[1], Some(4));
assert_eq!(row_source_lines[2], Some(5));
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn parse_default_config_leaves_row_source_lines_empty() {
let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n";
let doc = parse(md);
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::Table {
header_source_line,
row_source_lines,
..
} => {
assert!(header_source_line.is_none());
assert!(row_source_lines.is_empty());
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn parse_captures_gfm_column_alignment() {
let md = "| L | C | R |\n|:--|:-:|--:|\n| a | b | c |\n";
let doc = parse(md);
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::Table { alignments, .. } => {
assert_eq!(
alignments,
&vec![
ColumnAlignment::Left,
ColumnAlignment::Center,
ColumnAlignment::Right,
]
);
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn parse_unaligned_table_has_empty_alignments() {
let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n";
let doc = parse(md);
match &doc.blocks[0] {
Block::Table { alignments, .. } => {
assert!(alignments.is_empty(), "unaligned table → empty alignments");
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn parse_partial_alignment_keeps_none_for_unmarked_columns() {
let md = "| a | b | c |\n| --- | :-: | --- |\n| 1 | 2 | 3 |\n";
let doc = parse(md);
match &doc.blocks[0] {
Block::Table { alignments, .. } => {
assert_eq!(
alignments,
&vec![
ColumnAlignment::None,
ColumnAlignment::Center,
ColumnAlignment::None,
]
);
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn parse_ordered_list_start_3_captures_start_number() {
let doc = parse("3. foo\n4. bar\n");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List {
ordered,
start,
items,
..
} => {
assert!(ordered, "ordered list");
assert_eq!(*start, Some(3), "explicit start number captured");
assert_eq!(items.len(), 2);
}
other => panic!("expected ordered List, got {other:?}"),
}
}
#[test]
fn parse_ordered_list_default_start_collapses_to_none() {
let doc = parse("1. foo\n2. bar\n");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List { ordered, start, .. } => {
assert!(ordered);
assert!(
start.is_none(),
"implicit start=1 must collapse to None, got {start:?}"
);
}
other => panic!("expected ordered List, got {other:?}"),
}
}
#[test]
fn parse_unordered_list_has_no_start() {
let doc = parse("- foo\n- bar\n");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List { ordered, start, .. } => {
assert!(!ordered, "unordered list");
assert!(
start.is_none(),
"unordered list must have start=None, got {start:?}"
);
}
other => panic!("expected unordered List, got {other:?}"),
}
}
#[test]
fn parse_with_source_lines_handles_list_after_blank_line_offset() {
let md = "intro paragraph\n\n- item on line 3\n- item on line 4\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
hard_line_breaks: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 2);
match &doc.blocks[1] {
Block::List {
item_source_lines, ..
} => {
assert_eq!(item_source_lines.len(), 2);
assert_eq!(item_source_lines[0], Some(3));
assert_eq!(item_source_lines[1], Some(4));
}
other => panic!("expected List as second block, got {other:?}"),
}
}
#[test]
fn parse_implicit_figure_default_promotes_image_only_paragraph() {
let doc = parse("\n");
assert_eq!(doc.blocks.len(), 1);
assert!(
matches!(doc.blocks[0], Block::Figure { .. }),
"default config (implicit_figure=true) should promote: got {:?}",
doc.blocks[0]
);
}
#[test]
fn parse_implicit_figure_off_leaves_image_paragraph_unpromoted() {
let config = ParseConfig {
emit_source_lines: false,
implicit_figure: false,
source_line_offset: 0,
math: false,
hard_line_breaks: false,
};
let doc = parse_with_config("\n", &config);
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::Paragraph(inlines) => {
assert!(matches!(inlines[0], Inline::Image { .. }));
}
other => panic!("expected Paragraph with image, got {other:?}"),
}
}
#[test]
fn parse_implicit_figure_off_leaves_a_footnote_definition_image_unpromoted() {
fn holds_figure(blocks: &[Block]) -> bool {
blocks.iter().any(|block| match block {
Block::Figure { .. } => true,
Block::FootnoteDefinition { children, .. }
| Block::BlockQuote(children)
| Block::Callout { children, .. }
| Block::LinkCard { children, .. } => holds_figure(children),
Block::List { items, .. } => items.iter().any(|item| holds_figure(item)),
_ => false,
})
}
let config = ParseConfig {
implicit_figure: false,
..ParseConfig::default()
};
let doc = parse_with_config("See [^a]\n\n[^a]: \n", &config);
assert!(
!holds_figure(&doc.blocks),
"implicit_figure=false must reach inside a footnote definition, got {:?}",
doc.blocks
);
let doc = parse("See [^a]\n\n[^a]: \n");
assert!(
holds_figure(&doc.blocks),
"default config must still promote inside a footnote definition, got {:?}",
doc.blocks
);
}
#[test]
fn implicit_figure_caption_preserves_inline_markup() {
let block = first_block("\n");
match block {
Block::Figure { caption, image, .. } => {
let cap = caption.expect("caption must be present");
assert!(
cap.iter().any(|i| matches!(i, Inline::Emphasis(_))),
"caption must carry a typed Emphasis node, got {cap:?}"
);
assert!(
!cap.iter()
.any(|i| matches!(i, Inline::Text(t) if t.contains('*'))),
"caption must not contain raw `*` markers, got {cap:?}"
);
match image {
Inline::Image { alt, .. } => {
assert_eq!(
alt, "before em after",
"alt attribute must stay flat plain-text source"
);
}
other => panic!("expected Image, got {other:?}"),
}
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn implicit_figure_caption_carries_link_and_math_nodes() {
let config = ParseConfig {
emit_source_lines: false,
implicit_figure: true,
source_line_offset: 0,
math: true,
hard_line_breaks: false,
};
let doc = parse_with_config(" and $x^2$ end](img.png)\n", &config);
match doc.blocks.into_iter().next().expect("one block") {
Block::Figure { caption, image, .. } => {
let cap = caption.expect("caption must be present");
assert!(
cap.iter().any(|i| matches!(i, Inline::Link { .. })),
"caption must carry a typed Link node, got {cap:?}"
);
assert!(
cap.iter().any(|i| matches!(
i,
Inline::Other(html) if super::super::math_text::math_node_parts(html).is_some()
)),
"caption must carry a typed math node, got {cap:?}"
);
match image {
Inline::Image { alt, .. } => {
assert_eq!(
alt, "a link and $x^2$ end",
"alt must stay flat: link label inlined, math as source"
);
}
other => panic!("expected Image, got {other:?}"),
}
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn implicit_figure_caption_only_math_is_a_math_node() {
let config = ParseConfig {
emit_source_lines: false,
implicit_figure: true,
source_line_offset: 0,
math: true,
hard_line_breaks: false,
};
let doc = parse_with_config("\n", &config);
match doc.blocks.into_iter().next().expect("one block") {
Block::Figure { caption, image, .. } => {
let cap = caption.expect("caption must be present");
assert!(
cap.iter().any(|i| matches!(
i,
Inline::Other(html) if super::super::math_text::math_node_parts(html).is_some()
)),
"math-only caption must carry a math node, got {cap:?}"
);
match image {
Inline::Image { alt, .. } => {
assert_eq!(alt, "$E=mc^2$", "alt must be the math source verbatim");
}
other => panic!("expected Image, got {other:?}"),
}
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn implicit_figure_empty_alt_still_yields_no_caption() {
let doc = parse("\n");
assert!(
!matches!(doc.blocks.first(), Some(Block::Figure { .. })),
"empty-alt image must not promote to a figure: {:?}",
doc.blocks
);
}
#[test]
fn line_lookup_offset_zero_is_line_one() {
let lookup = LineLookup::build("hello\nworld\n", 0);
assert_eq!(lookup.line_at(0), 1);
}
#[test]
fn line_lookup_after_first_newline_is_line_two() {
let lookup = LineLookup::build("hello\nworld\n", 0);
assert_eq!(lookup.line_at(6), 2);
}
#[test]
fn line_lookup_handles_multiline_block_starts() {
let lookup = LineLookup::build("line1\nline2\nline3\n", 0);
assert_eq!(lookup.line_at(0), 1, "byte 0 → line 1");
assert_eq!(lookup.line_at(6), 2, "byte 6 → line 2");
assert_eq!(lookup.line_at(12), 3, "byte 12 → line 3");
}
#[test]
fn line_lookup_empty_source() {
let lookup = LineLookup::build("", 0);
assert_eq!(lookup.line_at(0), 1, "empty source still has line 1");
}
#[test]
fn wikilink_image_percent_no_graph_promotes_with_width() {
let block = first_block("![[pic.jpg|55%]]\n");
match block {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
assert!(caption.is_none(), "percent must not become a caption");
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn wikilink_image_percent_with_caption_no_graph() {
let block = first_block("![[pic.jpg|My cap|55%]]\n");
match block {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
let cap = caption.as_ref().expect("caption must be present");
assert!(
matches!(cap.as_slice(), [Inline::Text(t)] if t == "My cap"),
"caption should be the non-width segment, got {cap:?}"
);
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn wikilink_video_percent_stays_paragraph() {
let block = first_block("![[clip.mov|77%]]\n");
match block {
Block::Paragraph(inlines) => assert!(
matches!(
inlines.as_slice(),
[Inline::Image {
is_wikilink: true,
..
}]
),
"paragraph must hold the lone wikilink image, got {inlines:?}"
),
other => panic!("video embed must stay Paragraph for dispatch, got {other:?}"),
}
}
#[test]
fn wikilink_video_box_sizing_stays_paragraph() {
let block = first_block("![[clip.mov|640x360]]\n");
assert!(
matches!(block, Block::Paragraph(_)),
"expected Paragraph, got {block:?}"
);
}
#[test]
fn wikilink_pdf_alias_stays_paragraph() {
let block = first_block("![[report.pdf|80%]]\n");
assert!(
matches!(block, Block::Paragraph(_)),
"expected Paragraph, got {block:?}"
);
}
#[test]
fn wikilink_extensionless_stays_paragraph() {
let block = first_block("![[draft|55%]]\n");
assert!(
matches!(block, Block::Paragraph(_)),
"expected Paragraph, got {block:?}"
);
}
#[test]
fn wikilink_uppercase_image_ext_still_promotes() {
let block = first_block("![[photo.JPG|55%]]\n");
assert!(
matches!(block, Block::Figure { .. }),
"expected Figure, got {block:?}"
);
}
#[test]
fn image_alt_soft_break_becomes_a_space() {
for hard_line_breaks in [false, true] {
let config = ParseConfig {
hard_line_breaks,
..ParseConfig::default()
};
let doc = parse_with_config("\n", &config);
match doc.blocks.first().expect("one block") {
Block::Figure { image, caption, .. } => {
match image {
Inline::Image { alt, .. } => assert_eq!(
alt, "Cover art by Jane",
"soft-wrapped alt lines ran together (hard_line_breaks={hard_line_breaks})"
),
other => panic!("expected Image, got {other:?}"),
}
let cap = caption.as_ref().expect("caption from alt text");
assert!(
matches!(cap.as_slice(), [Inline::Text(t)] if t == "Cover art by Jane"),
"figcaption disagrees with alt: {cap:?}"
);
}
other => panic!("expected Figure, got {other:?}"),
}
}
}
#[test]
fn image_alt_hard_break_becomes_a_space_while_caption_keeps_the_break() {
let doc = parse("\n");
match doc.blocks.first().expect("one block") {
Block::Figure { image, caption, .. } => {
match image {
Inline::Image { alt, .. } => assert_eq!(alt, "one two"),
other => panic!("expected Image, got {other:?}"),
}
let cap = caption.as_ref().expect("caption");
assert!(
matches!(cap.as_slice(), [Inline::Text(a), Inline::LineBreak, Inline::Text(b)] if a == "one" && b == "two"),
"explicit break should survive in the caption: {cap:?}"
);
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn image_alt_break_does_not_double_the_space() {
let doc = parse("\n");
match doc.blocks.first().expect("one block") {
Block::Figure { image, .. } => match image {
Inline::Image { alt, .. } => assert_eq!(alt, "Cover art by Jane"),
other => panic!("expected Image, got {other:?}"),
},
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn nested_image_alt_folds_trailing_text_instead_of_leaking_as_body_prose() {
match first_block("before  c](outer.png) after\n") {
Block::Paragraph(children) => {
let images: Vec<&Inline> = children
.iter()
.filter(|i| matches!(i, Inline::Image { .. }))
.collect();
assert_eq!(images.len(), 1, "expected exactly one Image inline: {children:?}");
match images[0] {
Inline::Image { alt, .. } => assert_eq!(
alt, "a b c",
"the outer alt lost the inner image's trailing text: {children:?}"
),
_ => unreachable!(),
}
let stray_text: String = children
.iter()
.filter_map(|i| match i {
Inline::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(
stray_text, "before after",
"the inner image's trailing text leaked into sibling paragraph prose: {children:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn nested_image_alone_in_a_paragraph_promotes_to_a_figure_with_a_real_nested_image_in_its_caption() {
match first_block(" c](outer.png)\n") {
Block::Figure { caption, image, .. } => {
let cap = caption.expect("caption must be present");
match cap.iter().find(|i| matches!(i, Inline::Image { .. })) {
Some(Inline::Image { alt: inner_alt, .. }) => {
assert_eq!(inner_alt, "b", "nested image's own alt should survive in the caption");
}
other => panic!("caption must carry a real nested Image node, got {cap:?} ({other:?})"),
}
match image {
Inline::Image { alt, .. } => assert_eq!(
alt, "a b c",
"outer alt must stay flat text even though the caption has a real nested Image"
),
other => panic!("expected Image, got {other:?}"),
}
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn unknown_shortcode_warning_reaches_the_caller() {
let doc = crate::ast::parse(":::gallry\nbody\n:::\n");
assert!(
doc.warnings.iter().any(|w| w.contains("gallry")),
"an unrecognized shortcode name must surface a warning on the Document, got {:?}",
doc.warnings
);
}
#[test]
fn unknown_shortcode_warning_survives_unknown_nesting() {
let doc = crate::ast::parse("::::outr\n:::gallry\nbody\n:::\n::::\n");
assert!(
doc.warnings.iter().filter(|w| w.contains("gallry")).count() == 1,
"a misspelling nested inside another unknown shortcode must warn once, got {:?}",
doc.warnings
);
}
#[test]
fn unknown_shortcode_inside_a_valid_shortcode_warns() {
let doc = crate::ast::parse("::::grid\n:::gallry\nbody\n:::\n::::\n");
assert!(
doc.warnings.iter().any(|w| w.contains("gallry")),
"a misspelling nested inside a valid shortcode's body must warn, got {:?}",
doc.warnings
);
}
#[test]
fn unknown_shortcode_inside_a_hero_overlay_warns() {
let doc = crate::ast::parse("::::hero\n:::gallry\nbody\n:::\n::::\n");
assert!(
doc.warnings.iter().any(|w| w.contains("gallry")),
"a misspelling nested inside a hero overlay must warn, got {:?}",
doc.warnings
);
}
#[test]
fn valid_shortcodes_produce_no_warnings() {
let doc = crate::ast::parse("::::grid\n:::hero\nbody\n:::\n::::\n");
assert!(
doc.warnings.is_empty(),
"registered shortcode names must not warn, got {:?}",
doc.warnings
);
}