use super::*;
use crate::ir;
fn plain(t: &str) -> ir::Inline {
ir::Inline::plain(t)
}
fn make_doc_with_blocks(blocks: Vec<ir::Block>) -> ir::Document {
let mut doc = ir::Document::new();
doc.sections.push(ir::Section { blocks });
doc
}
#[test]
fn escape_yaml_backslash() {
assert_eq!(escape_yaml("a\\b"), "a\\\\b");
}
#[test]
fn escape_yaml_double_quote() {
assert_eq!(escape_yaml("say \"hi\""), "say \\\"hi\\\"");
}
#[test]
fn escape_yaml_no_special_chars() {
assert_eq!(escape_yaml("hello world"), "hello world");
}
#[test]
fn escape_yaml_newline() {
assert_eq!(escape_yaml("line1\nline2"), "line1\\nline2");
}
#[test]
fn escape_yaml_carriage_return() {
assert_eq!(escape_yaml("a\rb"), "a\\rb");
}
#[test]
fn escape_yaml_tab() {
assert_eq!(escape_yaml("a\tb"), "a\\tb");
}
#[test]
fn render_inlines_plain() {
assert_eq!(render_inlines(&[plain("hello")]), "hello");
}
#[test]
fn render_inlines_bold() {
let inlines = vec![ir::Inline {
text: "bold".into(),
bold: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "**bold**");
}
#[test]
fn render_inlines_italic() {
let inlines = vec![ir::Inline {
text: "em".into(),
italic: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "*em*");
}
#[test]
fn render_inlines_bold_italic() {
let inlines = vec![ir::Inline {
text: "bi".into(),
bold: true,
italic: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "***bi***");
}
#[test]
fn render_inlines_strikethrough() {
let inlines = vec![ir::Inline {
text: "del".into(),
strikethrough: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "~~del~~");
}
#[test]
fn render_inlines_underline() {
let inlines = vec![ir::Inline {
text: "ul".into(),
underline: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "<u>ul</u>");
}
#[test]
fn render_inlines_superscript() {
let inlines = vec![ir::Inline {
text: "sup".into(),
superscript: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "<sup>sup</sup>");
}
#[test]
fn render_inlines_subscript() {
let inlines = vec![ir::Inline {
text: "sub".into(),
subscript: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "<sub>sub</sub>");
}
#[test]
fn render_inlines_code() {
let inlines = vec![ir::Inline {
text: "code()".into(),
code: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "`code()`");
}
#[test]
fn render_inlines_link() {
let inlines = vec![ir::Inline {
text: "click".into(),
link: Some("https://example.com".into()),
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "[click](https://example.com)");
}
#[test]
fn render_inlines_footnote_ref() {
let inlines = vec![ir::Inline {
text: String::new(),
footnote_ref: Some("1".into()),
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "[^1]");
}
#[test]
fn write_markdown_heading_levels() {
for level in 1u8..=6 {
let doc = make_doc_with_blocks(vec![ir::Block::Heading {
level,
inlines: vec![plain("Title")],
}]);
let md = write_markdown(&doc, false);
let hashes = "#".repeat(level as usize);
assert!(
md.starts_with(&format!("{hashes} Title")),
"level {level}: got {md:?}"
);
}
}
#[test]
fn write_markdown_paragraph() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("Hello, world.")],
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("Hello, world."));
}
#[test]
fn write_markdown_code_block() {
let doc = make_doc_with_blocks(vec![ir::Block::CodeBlock {
language: Some("rust".into()),
code: "fn main() {}".into(),
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("```rust\n"), "got: {md}");
assert!(md.contains("fn main() {}"), "got: {md}");
assert!(md.contains("\n```"), "got: {md}");
}
#[test]
fn write_markdown_code_block_no_language() {
let doc = make_doc_with_blocks(vec![ir::Block::CodeBlock {
language: None,
code: "raw code".into(),
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("```\n"), "got: {md}");
assert!(md.contains("raw code"), "got: {md}");
}
#[test]
fn write_markdown_simple_gfm_table() {
let rows = vec![
ir::TableRow {
cells: vec![
ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("Name")],
}],
..Default::default()
},
ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("Age")],
}],
..Default::default()
},
],
is_header: true,
},
ir::TableRow {
cells: vec![
ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("Alice")],
}],
..Default::default()
},
ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("30")],
}],
..Default::default()
},
],
is_header: false,
},
];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 2 }]);
let md = write_markdown(&doc, false);
assert!(md.contains("| Name | Age |"), "got: {md}");
assert!(md.contains("| --- |"), "got: {md}");
assert!(md.contains("| Alice | 30 |"), "got: {md}");
}
#[test]
fn write_markdown_complex_table_html_fallback() {
let rows = vec![ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("wide")],
}],
colspan: 2,
rowspan: 1,
}],
is_header: true,
}];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 2 }]);
let md = write_markdown(&doc, false);
assert!(md.contains("<table>"), "got: {md}");
assert!(md.contains("colspan=\"2\""), "got: {md}");
}
#[test]
fn write_markdown_unordered_list() {
let doc = make_doc_with_blocks(vec![ir::Block::List {
ordered: false,
start: 1,
items: vec![
ir::ListItem {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("alpha")],
}],
children: Vec::new(),
},
ir::ListItem {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("beta")],
}],
children: Vec::new(),
},
],
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("- alpha"), "got: {md}");
assert!(md.contains("- beta"), "got: {md}");
}
#[test]
fn write_markdown_ordered_list() {
let doc = make_doc_with_blocks(vec![ir::Block::List {
ordered: true,
start: 1,
items: vec![
ir::ListItem {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("first")],
}],
children: Vec::new(),
},
ir::ListItem {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("second")],
}],
children: Vec::new(),
},
],
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("1. first"), "got: {md}");
assert!(md.contains("2. second"), "got: {md}");
}
#[test]
fn write_markdown_image() {
let doc = make_doc_with_blocks(vec![ir::Block::Image {
src: "img.png".into(),
alt: "a picture".into(),
}]);
let md = write_markdown(&doc, false);
assert!(md.contains(""), "got: {md}");
}
#[test]
fn write_markdown_horizontal_rule() {
let doc = make_doc_with_blocks(vec![ir::Block::HorizontalRule]);
let md = write_markdown(&doc, false);
assert!(md.contains("---"), "got: {md}");
}
#[test]
fn write_markdown_math_display() {
let doc = make_doc_with_blocks(vec![ir::Block::Math {
display: true,
tex: "E=mc^2".into(),
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("$$\n"), "got: {md}");
assert!(md.contains("E=mc^2"), "got: {md}");
}
#[test]
fn write_markdown_math_inline() {
let doc = make_doc_with_blocks(vec![ir::Block::Math {
display: false,
tex: "x+y".into(),
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("$x+y$"), "got: {md}");
}
#[test]
fn write_markdown_footnote() {
let doc = make_doc_with_blocks(vec![ir::Block::Footnote {
id: "fn1".into(),
content: vec![ir::Block::Paragraph {
inlines: vec![plain("footnote text")],
}],
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("[^fn1]:"), "got: {md}");
assert!(md.contains("footnote text"), "got: {md}");
}
#[test]
fn write_markdown_frontmatter() {
let mut doc = ir::Document::new();
doc.metadata.title = Some("My Title".into());
doc.metadata.author = Some("Author Name".into());
doc.sections.push(ir::Section { blocks: Vec::new() });
let md = write_markdown(&doc, true);
assert!(md.starts_with("---\n"), "got: {md}");
assert!(md.contains("title: \"My Title\""), "got: {md}");
assert!(md.contains("author: \"Author Name\""), "got: {md}");
}
#[test]
fn write_markdown_multi_section_separator() {
let mut doc = ir::Document::new();
doc.sections.push(ir::Section {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("Section 1")],
}],
});
doc.sections.push(ir::Section {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("Section 2")],
}],
});
let md = write_markdown(&doc, false);
assert!(md.contains("\n---\n"), "got: {md}");
assert!(md.contains("Section 1"), "got: {md}");
assert!(md.contains("Section 2"), "got: {md}");
}
#[test]
fn render_inlines_bold_italic_strikethrough() {
let inlines = vec![ir::Inline {
text: "all".into(),
bold: true,
italic: true,
strikethrough: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "~~***all***~~");
}
#[test]
fn render_inlines_underline_bold_order() {
let inlines = vec![ir::Inline {
text: "ub".into(),
bold: true,
underline: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "<u>**ub**</u>");
}
#[test]
fn render_inlines_bold_link() {
let inlines = vec![ir::Inline {
text: "click".into(),
bold: true,
link: Some("https://example.com".into()),
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "[**click**](https://example.com)");
}
#[test]
fn render_inlines_bold_then_footnote_ref() {
let inlines = vec![ir::Inline {
text: "note".into(),
bold: true,
footnote_ref: Some("fn1".into()),
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "**note**[^fn1]");
}
#[test]
fn render_inlines_empty_text_bold_skips_markers() {
let inlines = vec![ir::Inline {
text: String::new(),
bold: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "");
}
#[test]
fn render_inlines_empty_text_bold_with_footnote_ref() {
let inlines = vec![ir::Inline {
text: String::new(),
bold: true,
footnote_ref: Some("2".into()),
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "[^2]");
}
#[test]
fn render_inlines_empty_text_link() {
let inlines = vec![ir::Inline {
text: String::new(),
link: Some("https://example.com".into()),
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "[](https://example.com)");
}
#[test]
fn render_inlines_javascript_link_emits_text_only() {
let inlines = vec![ir::Inline {
text: "click me".into(),
link: Some("javascript:alert(1)".into()),
..Default::default()
}];
let out = render_inlines(&inlines);
assert!(
!out.contains("javascript:"),
"javascript: scheme must be dropped; got: {out:?}"
);
assert!(
out.contains("click me"),
"label text must still be emitted; got: {out:?}"
);
assert!(
!out.contains("]("),
"link syntax must not be present; got: {out:?}"
);
}
#[test]
fn render_inlines_data_url_emits_text_only() {
let inlines = vec![ir::Inline {
text: "img".into(),
link: Some("data:text/html,<script>alert(1)</script>".into()),
..Default::default()
}];
let out = render_inlines(&inlines);
assert!(
!out.contains("data:"),
"data: scheme must be dropped; got: {out:?}"
);
}
#[test]
fn render_inlines_https_link_emitted() {
let inlines = vec![ir::Inline {
text: "safe".into(),
link: Some("https://example.com".into()),
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "[safe](https://example.com)");
}
#[test]
fn render_inlines_mailto_link_emitted() {
let inlines = vec![ir::Inline {
text: "email".into(),
link: Some("mailto:user@example.com".into()),
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "[email](mailto:user@example.com)");
}
#[test]
fn render_inlines_javascript_link_case_insensitive() {
let inlines = vec![ir::Inline {
text: "xss".into(),
link: Some("JAVASCRIPT:alert(1)".into()),
..Default::default()
}];
let out = render_inlines(&inlines);
assert!(
!out.contains("JAVASCRIPT:"),
"uppercase JAVASCRIPT: must be dropped; got: {out:?}"
);
}
#[test]
fn render_inlines_link_with_paren_uses_angle_bracket_syntax() {
let inlines = vec![ir::Inline {
text: "click".into(),
link: Some("https://example.com/foo(bar)".into()),
..Default::default()
}];
assert_eq!(
render_inlines(&inlines),
"[click](<https://example.com/foo(bar)>)"
);
}
#[test]
fn render_inlines_link_without_paren_uses_plain_syntax() {
let inlines = vec![ir::Inline {
text: "click".into(),
link: Some("https://example.com/plain".into()),
..Default::default()
}];
assert_eq!(
render_inlines(&inlines),
"[click](https://example.com/plain)"
);
}
#[test]
fn render_inlines_escapes_asterisk() {
assert_eq!(render_inlines(&[plain("a*b")]), r"a\*b");
}
#[test]
fn render_inlines_escapes_underscore() {
assert_eq!(render_inlines(&[plain("a_b")]), r"a\_b");
}
#[test]
fn render_inlines_escapes_tilde() {
assert_eq!(render_inlines(&[plain("a~b")]), r"a\~b");
}
#[test]
fn render_inlines_escapes_brackets() {
assert_eq!(render_inlines(&[plain("[link]")]), r"\[link\]");
}
#[test]
fn render_inlines_escapes_backtick() {
assert_eq!(render_inlines(&[plain("a`b")]), "a\\`b");
}
#[test]
fn render_inlines_escapes_backslash() {
assert_eq!(render_inlines(&[plain(r"a\b")]), r"a\\b");
}
#[test]
fn paragraph_starting_with_hash_space_is_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("# not a heading")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("\\# not a heading"),
"expected escaped #; got: {md:?}"
);
}
#[test]
fn paragraph_starting_with_double_hash_is_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("## still not a heading")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("\\## still not a heading"),
"expected escaped ##; got: {md:?}"
);
}
#[test]
fn paragraph_starting_with_gt_is_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("> not a blockquote")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("\\> not a blockquote"),
"expected escaped >; got: {md:?}"
);
}
#[test]
fn paragraph_hash_in_middle_not_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("color #ff0000")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("color #ff0000"),
"mid-text # must not be escaped; got: {md:?}"
);
assert!(
!md.contains("\\#"),
"unexpected escape of mid-text #; got: {md:?}"
);
}
#[test]
fn paragraph_gt_in_middle_not_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("a > b")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("a > b"),
"mid-text > must not be escaped; got: {md:?}"
);
assert!(
!md.contains("\\>"),
"unexpected escape of mid-text >; got: {md:?}"
);
}
#[test]
fn render_inlines_code_no_escape() {
let inlines = vec![ir::Inline {
text: "a*b_c".into(),
code: true,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "`a*b_c`");
}
#[test]
fn escape_inline_no_special_chars() {
assert_eq!(escape_inline("hello world"), "hello world");
}
#[test]
fn escape_inline_all_special_chars() {
let input = "\\`*_~[]";
let expected = "\\\\\\`\\*\\_\\~\\[\\]";
assert_eq!(escape_inline(input), expected);
}
#[test]
fn escape_inline_mixed() {
assert_eq!(escape_inline("price: *$5*"), r"price: \*$5\*");
}
#[test]
fn write_markdown_frontmatter_with_created_date() {
let mut doc = ir::Document::new();
doc.metadata.title = Some("Doc".into());
doc.metadata.created = Some("2026-04-22".into());
doc.sections.push(ir::Section { blocks: Vec::new() });
let md = write_markdown(&doc, true);
assert!(md.contains("date: \"2026-04-22\""), "got: {md}");
}
#[test]
fn write_markdown_frontmatter_with_subject() {
let mut doc = ir::Document::new();
doc.metadata.subject = Some("The subject".into());
doc.sections.push(ir::Section { blocks: Vec::new() });
let md = write_markdown(&doc, true);
assert!(md.contains("subject: \"The subject\""), "got: {md}");
}
#[test]
fn write_markdown_frontmatter_with_description() {
let mut doc = ir::Document::new();
doc.metadata.description = Some("A description".into());
doc.sections.push(ir::Section { blocks: Vec::new() });
let md = write_markdown(&doc, true);
assert!(md.contains("description: \"A description\""), "got: {md}");
}
#[test]
fn write_markdown_frontmatter_with_keywords() {
let mut doc = ir::Document::new();
doc.metadata.keywords = vec!["rust".into(), "hwp".into(), "converter".into()];
doc.sections.push(ir::Section { blocks: Vec::new() });
let md = write_markdown(&doc, true);
assert!(md.contains("keywords:"), "got: {md}");
assert!(md.contains("rust"), "got: {md}");
assert!(md.contains("hwp"), "got: {md}");
assert!(md.contains("converter"), "got: {md}");
}
#[test]
fn write_markdown_frontmatter_all_fields() {
let mut doc = ir::Document::new();
doc.metadata.title = Some("Full".into());
doc.metadata.author = Some("Author".into());
doc.metadata.created = Some("2026-01-01".into());
doc.metadata.subject = Some("Subj".into());
doc.metadata.description = Some("Desc".into());
doc.metadata.keywords = vec!["a".into(), "b".into()];
doc.sections.push(ir::Section { blocks: Vec::new() });
let md = write_markdown(&doc, true);
assert!(md.starts_with("---\n"), "got: {md}");
assert!(md.contains("title:"), "got: {md}");
assert!(md.contains("author:"), "got: {md}");
assert!(md.contains("date:"), "got: {md}");
assert!(md.contains("subject:"), "got: {md}");
assert!(md.contains("description:"), "got: {md}");
assert!(md.contains("keywords:"), "got: {md}");
}
#[test]
fn write_markdown_frontmatter_no_fields_emits_empty_block() {
let mut doc = ir::Document::new();
doc.sections.push(ir::Section { blocks: Vec::new() });
let md = write_markdown(&doc, true);
assert!(md.starts_with("---\n---\n"), "got: {md}");
}
#[test]
fn write_markdown_table_cell_with_code_block_uses_fallback_text() {
let rows = vec![
ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::CodeBlock {
language: Some("rust".into()),
code: "let x = 1;".into(),
}],
..Default::default()
}],
is_header: true,
},
ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("data")],
}],
..Default::default()
}],
is_header: false,
},
];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 1 }]);
let md = write_markdown(&doc, false);
assert!(md.contains("let x = 1;") || md.contains("```"), "got: {md}");
}
#[test]
fn write_markdown_table_cell_with_image_uses_fallback_text() {
let rows = vec![ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Image {
src: "img.png".into(),
alt: "photo".into(),
}],
..Default::default()
}],
is_header: true,
}];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 1 }]);
let md = write_markdown(&doc, false);
assert!(md.contains("img.png") || md.contains("photo"), "got: {md}");
}
#[test]
fn write_markdown_table_cell_with_math_block() {
let rows = vec![ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Math {
display: true,
tex: "E=mc^2".into(),
}],
..Default::default()
}],
is_header: true,
}];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 1 }]);
let md = write_markdown(&doc, false);
assert!(md.contains("E=mc^2"), "got: {md}");
}
#[test]
fn write_markdown_blockquote_nested_paragraph() {
let doc = make_doc_with_blocks(vec![ir::Block::BlockQuote {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("quoted text")],
}],
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("> quoted text"), "got: {md}");
}
#[test]
fn write_markdown_list_item_with_multiple_blocks() {
let doc = make_doc_with_blocks(vec![ir::Block::List {
ordered: false,
start: 1,
items: vec![ir::ListItem {
blocks: vec![
ir::Block::Paragraph {
inlines: vec![plain("first block")],
},
ir::Block::Paragraph {
inlines: vec![plain("continuation block")],
},
],
children: Vec::new(),
}],
}]);
let md = write_markdown(&doc, false);
assert!(md.contains("first block"), "got: {md}");
assert!(md.contains("continuation block"), "got: {md}");
}
#[test]
fn write_markdown_html_table_cell_script_tag_is_escaped() {
let rows = vec![ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("<script>alert(1)</script>")],
}],
colspan: 2,
rowspan: 1,
}],
is_header: true,
}];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 2 }]);
let md = write_markdown(&doc, false);
assert!(
md.contains("<table>"),
"must use HTML table path; got: {md}"
);
assert!(
!md.contains("<script>"),
"raw <script> tag must be entity-escaped; got: {md}"
);
assert!(
md.contains("<script>"),
"must contain entity-escaped <script>; got: {md}"
);
}
#[test]
fn write_markdown_html_table_cell_ampersand_escaped() {
let rows = vec![ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("AT&T")],
}],
colspan: 2,
rowspan: 1,
}],
is_header: true,
}];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 2 }]);
let md = write_markdown(&doc, false);
assert!(
md.contains("&"),
"& must be escaped to &; got: {md}"
);
}
#[test]
fn write_markdown_html_table_with_rowspan() {
let rows = vec![
ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("header")],
}],
colspan: 1,
rowspan: 2,
}],
is_header: true,
},
ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("body")],
}],
colspan: 1,
rowspan: 1,
}],
is_header: false,
},
];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 1 }]);
let md = write_markdown(&doc, false);
assert!(md.contains("<table>"), "got: {md}");
assert!(md.contains("rowspan=\"2\""), "got: {md}");
}
#[test]
fn write_markdown_table_cell_pipe_escaped() {
let rows = vec![ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("a | b")],
}],
..Default::default()
}],
is_header: true,
}];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 1 }]);
let md = write_markdown(&doc, false);
assert!(md.contains("\\|"), "pipe must be escaped; got: {md}");
}
#[test]
fn paragraph_multiline_second_line_hash_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("normal line\n# second line heading")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("\\# second line heading"),
"second-line # must be escaped; got: {md:?}"
);
assert!(
md.contains("normal line"),
"first line must be preserved; got: {md:?}"
);
}
#[test]
fn paragraph_multiline_second_line_gt_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("first\n> second")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("\\> second"),
"second-line > must be escaped; got: {md:?}"
);
}
#[test]
fn write_markdown_inline_math_has_trailing_blank_line() {
let doc = make_doc_with_blocks(vec![ir::Block::Math {
display: false,
tex: "x+y".into(),
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("$x+y$\n\n"),
"inline math must be followed by a blank line; got: {md:?}"
);
}
#[test]
fn write_markdown_inline_math_followed_by_paragraph_has_blank_line() {
let doc = make_doc_with_blocks(vec![
ir::Block::Math {
display: false,
tex: "a^2".into(),
},
ir::Block::Paragraph {
inlines: vec![plain("next paragraph")],
},
]);
let md = write_markdown(&doc, false);
assert!(
md.contains("$a^2$\n\nnext paragraph"),
"inline math and following paragraph must be separated by a blank line; got: {md:?}"
);
}
#[test]
fn write_markdown_html_table_second_row_is_header_uses_th() {
let rows = vec![
ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("data")],
}],
colspan: 2, rowspan: 1,
}],
is_header: false,
},
ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("header")],
}],
colspan: 1,
rowspan: 1,
}],
is_header: true,
},
];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 2 }]);
let md = write_markdown(&doc, false);
let first_td_pos = md.find("<td").expect("<td must appear in output");
let second_th_pos = md.rfind("<th").expect("<th must appear in output");
assert!(
first_td_pos < second_th_pos,
"<td (row 0) must appear before <th (row 1); got: {md:?}"
);
}
#[test]
fn write_markdown_html_table_first_row_not_header_uses_td() {
let rows = vec![ir::TableRow {
cells: vec![ir::TableCell {
blocks: vec![ir::Block::Paragraph {
inlines: vec![plain("cell")],
}],
colspan: 2,
rowspan: 1,
}],
is_header: false,
}];
let doc = make_doc_with_blocks(vec![ir::Block::Table { rows, col_count: 2 }]);
let md = write_markdown(&doc, false);
assert!(
md.contains("<td"),
"row with is_header=false must use <td…>; got: {md:?}"
);
assert!(
!md.contains("<th"),
"row with is_header=false must not use <th…>; got: {md:?}"
);
}
#[test]
fn paragraph_multiline_list_marker_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("text\n- list item")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("\\- list item"),
"second-line list marker must be escaped; got: {md:?}"
);
}
#[test]
fn paragraph_multiline_ordered_list_marker_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("text\n1. first item")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("\\1. first item"),
"ordered list marker must be escaped; got: {md:?}"
);
}
#[test]
fn paragraph_multiline_thematic_break_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("text\n---")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("\\---"),
"thematic break must be escaped; got: {md:?}"
);
}
#[test]
fn paragraph_first_line_normal_not_double_escaped() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![plain("hello\nworld")],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("hello\nworld"),
"plain multiline must not be escaped; got: {md:?}"
);
}
#[test]
fn render_inlines_color_wraps_in_span() {
let inlines = vec![ir::Inline {
text: "red".into(),
color: Some("#FF0000".into()),
..Default::default()
}];
assert_eq!(
render_inlines(&inlines),
"<span style=\"color:#FF0000\">red</span>"
);
}
#[test]
fn render_inlines_no_color_no_span() {
let inlines = vec![ir::Inline {
text: "plain".into(),
color: None,
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "plain");
}
#[test]
fn render_inlines_color_with_bold_span_wraps_bold() {
let inlines = vec![ir::Inline {
text: "bold red".into(),
bold: true,
color: Some("#FF0000".into()),
..Default::default()
}];
assert_eq!(
render_inlines(&inlines),
"<span style=\"color:#FF0000\">**bold red**</span>"
);
}
#[test]
fn render_inlines_empty_color_string_no_span() {
let inlines = vec![ir::Inline {
text: "text".into(),
color: Some(String::new()),
..Default::default()
}];
assert_eq!(render_inlines(&inlines), "text");
}
#[test]
fn render_inlines_color_applied_before_link() {
let inlines = vec![ir::Inline {
text: "click".into(),
color: Some("#0000FF".into()),
link: Some("https://example.com".into()),
..Default::default()
}];
let out = render_inlines(&inlines);
assert_eq!(
out,
"[<span style=\"color:#0000FF\">click</span>](https://example.com)"
);
}
#[test]
fn render_inlines_ruby_annotation_produces_html_tags() {
let inlines = vec![ir::Inline {
text: "漢字".into(),
ruby: Some("한자".into()),
..ir::Inline::default()
}];
assert_eq!(render_inlines(&inlines), "<ruby>漢字<rt>한자</rt></ruby>");
}
#[test]
fn render_inlines_ruby_annotation_none_no_ruby_tags() {
let inlines = vec![ir::Inline {
text: "漢字".into(),
ruby: None,
..ir::Inline::default()
}];
let out = render_inlines(&inlines);
assert!(
!out.contains("<ruby>"),
"no ruby tags when annotation is None; got: {out}"
);
assert!(
out.contains("漢字"),
"base text must still appear; got: {out}"
);
}
#[test]
fn render_inlines_ruby_empty_annotation_no_ruby_tags() {
let inlines = vec![ir::Inline {
text: "漢字".into(),
ruby: Some(String::new()),
..ir::Inline::default()
}];
let out = render_inlines(&inlines);
assert!(
!out.contains("<ruby>"),
"empty annotation must not emit ruby tags; got: {out}"
);
}
#[test]
fn render_inlines_ruby_annotation_html_special_chars_escaped() {
let inlines = vec![ir::Inline {
text: "base".into(),
ruby: Some("a<b>&c".into()),
..ir::Inline::default()
}];
let out = render_inlines(&inlines);
assert!(out.contains("<"), "< must be escaped; got: {out}");
assert!(out.contains(">"), "> must be escaped; got: {out}");
assert!(out.contains("&"), "& must be escaped; got: {out}");
assert!(
!out.contains("<b>"),
"raw < must not appear unescaped; got: {out}"
);
}
#[test]
fn render_inlines_ruby_with_bold_base_text() {
let inlines = vec![ir::Inline {
text: "漢字".into(),
bold: true,
ruby: Some("한자".into()),
..ir::Inline::default()
}];
let out = render_inlines(&inlines);
assert!(
out.contains("<ruby>**漢字**<rt>한자</rt></ruby>"),
"got: {out}"
);
}
#[test]
fn write_markdown_paragraph_with_ruby_inline() {
let doc = make_doc_with_blocks(vec![ir::Block::Paragraph {
inlines: vec![ir::Inline {
text: "漢字".into(),
ruby: Some("한자".into()),
..ir::Inline::default()
}],
}]);
let md = write_markdown(&doc, false);
assert!(
md.contains("<ruby>漢字<rt>한자</rt></ruby>"),
"paragraph must contain ruby HTML; got: {md}"
);
}