use devup_editor_core::{
Block, BlockId, Document, DocumentExport, DocumentImport, Mark, SequentialIdGenerator, TextSpan,
};
use devup_editor_html::Html;
use serde_json::{Map, Value};
fn doc_with(blocks: Vec<Block>) -> Document {
let mut doc = Document::new();
for b in blocks {
doc.push_root_block(b);
}
doc
}
fn styled_mark(mark_type: &str, style_key: &str, style_value: &str) -> Mark {
let mut style = Map::new();
style.insert(style_key.into(), Value::String(style_value.into()));
let mut attrs = Map::new();
attrs.insert("style".into(), Value::Object(style));
Mark::with_attrs(mark_type, attrs)
}
fn block_types(doc: &Document) -> Vec<String> {
doc.root_block_ids()
.iter()
.filter_map(|id| doc.get_block(id).map(|b| b.ty.clone()))
.collect()
}
fn block_texts(doc: &Document) -> Vec<String> {
doc.root_block_ids()
.iter()
.filter_map(|id| doc.get_block(id).map(devup_editor_core::Block::plain_text))
.collect()
}
#[test]
fn export_heading_levels() {
for level in 1u64..=6 {
let mut b = Block::new(BlockId::new(format!("h{level}")), "heading");
b.content = vec![TextSpan::plain(format!("Heading {level}"))];
b.props.insert("level".into(), Value::from(level));
let out = Html::export(&doc_with(vec![b])).unwrap();
assert!(
out.contains(&format!("<h{level}>Heading {level}</h{level}>")),
"missing h{level}: {out}"
);
}
}
#[test]
fn export_paragraph_with_all_marks() {
let mut b = Block::new_paragraph(BlockId::new("p"));
b.content = vec![
TextSpan::with_marks("bold", vec![Mark::bold()]),
TextSpan::plain(" "),
TextSpan::with_marks("italic", vec![Mark::italic()]),
TextSpan::plain(" "),
TextSpan::with_marks("code", vec![Mark::code()]),
TextSpan::plain(" "),
TextSpan::with_marks("under", vec![Mark::underline()]),
TextSpan::plain(" "),
TextSpan::with_marks("strike", vec![Mark::strike()]),
];
let out = Html::export(&doc_with(vec![b])).unwrap();
assert!(out.contains("<strong>bold</strong>"));
assert!(out.contains("<em>italic</em>"));
assert!(out.contains("<code>code</code>"));
assert!(out.contains("<u>under</u>"));
assert!(out.contains("<s>strike</s>"));
}
#[test]
fn export_color_and_highlight_produce_span_style() {
let mut b = Block::new_paragraph(BlockId::new("p"));
b.content = vec![TextSpan::with_marks(
"rgb",
vec![
styled_mark("color", "color", "#ff0000"),
styled_mark("highlight", "backgroundColor", "#fff000"),
],
)];
let out = Html::export(&doc_with(vec![b])).unwrap();
assert!(out.contains("color:#ff0000"), "missing color style: {out}");
assert!(
out.contains("background-color:#fff000"),
"missing highlight: {out}"
);
}
#[test]
fn export_link_emits_a_with_rel() {
let mut href_attrs = Map::new();
href_attrs.insert("href".into(), Value::String("https://example.com".into()));
let mut b = Block::new_paragraph(BlockId::new("p"));
b.content = vec![TextSpan::with_marks(
"click",
vec![Mark::with_attrs("link", href_attrs)],
)];
let out = Html::export(&doc_with(vec![b])).unwrap();
assert!(
out.contains("<a href=\"https://example.com\" rel=\"noopener noreferrer\">click</a>"),
"missing link: {out}"
);
}
#[test]
fn export_link_strips_javascript_scheme() {
let mut href_attrs = Map::new();
href_attrs.insert("href".into(), Value::String("javascript:alert(1)".into()));
let mut b = Block::new_paragraph(BlockId::new("p"));
b.content = vec![TextSpan::with_marks(
"evil",
vec![Mark::with_attrs("link", href_attrs)],
)];
let out = Html::export(&doc_with(vec![b])).unwrap();
assert!(
!out.contains("javascript:"),
"should drop unsafe href: {out}"
);
assert!(
out.contains(">evil</"),
"text content should still render: {out}"
);
}
#[test]
fn export_todo_checked_and_unchecked() {
let checked = Block::new_todo(BlockId::new("t1"), true);
let mut unchecked = Block::new_todo(BlockId::new("t2"), false);
unchecked.content = vec![TextSpan::plain("task")];
let mut checked = checked;
checked.content = vec![TextSpan::plain("done")];
let out = Html::export(&doc_with(vec![checked, unchecked])).unwrap();
assert!(
out.contains("<p data-type=\"todo\" data-checked=\"true\">"),
"checked todo: {out}"
);
assert!(
out.contains("<p data-type=\"todo\" data-checked=\"false\">"),
"unchecked todo: {out}"
);
assert!(out.contains(">done</p>"));
assert!(out.contains(">task</p>"));
}
#[test]
fn export_code_block_with_language() {
let mut b = Block::new(BlockId::new("c"), "code");
b.content = vec![TextSpan::plain("fn main() {}")];
b.props
.insert("language".into(), Value::String("rust".into()));
let out = Html::export(&doc_with(vec![b])).unwrap();
assert!(
out.contains("<pre><code class=\"language-rust\">fn main() {}</code></pre>"),
"got: {out}"
);
}
#[test]
fn export_escapes_html_in_text() {
let mut b = Block::new_paragraph(BlockId::new("p"));
b.content = vec![TextSpan::plain("<script>alert(1)</script>")];
let out = Html::export(&doc_with(vec![b])).unwrap();
assert!(!out.contains("<script>"));
assert!(out.contains("<script>"));
}
#[test]
fn export_empty_document_is_empty_string() {
let out = Html::export(&Document::new()).unwrap();
assert!(out.is_empty());
}
#[test]
fn import_h1_to_h6() {
for level in 1u64..=6 {
let mut id_gen = SequentialIdGenerator::new("t");
let src = format!("<h{level}>Title</h{level}>");
let doc = Html::import(src, &mut id_gen).unwrap();
assert_eq!(block_types(&doc), vec!["heading".to_string()]);
let block = doc.get_block(&BlockId::new("t-1")).unwrap();
assert_eq!(
block.props.get("level").and_then(Value::as_u64),
Some(level),
"h{level} produced wrong level"
);
assert_eq!(block.plain_text(), "Title");
}
}
#[test]
fn import_todo_via_explicit_marker() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = r#"<ul data-devup-type="todo"><li><label><input type="checkbox" checked> done</label></li></ul>"#;
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
assert_eq!(block_types(&doc), vec!["todo".to_string()]);
let block = doc.get_block(&BlockId::new("t-1")).unwrap();
assert_eq!(
block.props.get("checked").and_then(Value::as_bool),
Some(true)
);
assert!(block.plain_text().contains("done"));
}
#[test]
fn import_todo_via_checkbox_heuristic() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = r#"<ul><li><input type="checkbox"> open</li></ul>"#;
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
assert_eq!(block_types(&doc), vec!["todo".to_string()]);
let block = doc.get_block(&BlockId::new("t-1")).unwrap();
assert_eq!(
block.props.get("checked").and_then(Value::as_bool),
Some(false)
);
}
#[test]
fn import_ordered_vs_unordered_list() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = "<ul><li>a</li><li>b</li></ul><ol><li>c</li></ol>";
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
let types = block_types(&doc);
assert_eq!(types, vec!["list", "list", "list"]);
let styles: Vec<_> = doc
.root_block_ids()
.iter()
.filter_map(|id| {
doc.get_block(id).and_then(|b| {
b.props
.get("style")
.and_then(Value::as_str)
.map(String::from)
})
})
.collect();
assert_eq!(styles, vec!["unordered", "unordered", "ordered"]);
}
#[test]
fn import_blockquote() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = "<blockquote>quoted</blockquote>";
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
assert_eq!(block_types(&doc), vec!["quote".to_string()]);
assert_eq!(block_texts(&doc), vec!["quoted".to_string()]);
}
#[test]
fn import_pre_code_with_language_class() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = "<pre><code class=\"language-python\">print('hi')\n</code></pre>";
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
assert_eq!(block_types(&doc), vec!["code".to_string()]);
let block = doc.get_block(&BlockId::new("t-1")).unwrap();
assert_eq!(
block.props.get("language").and_then(Value::as_str),
Some("python")
);
assert!(block.plain_text().contains("print('hi')"));
}
#[test]
fn import_hr_produces_divider() {
let mut id_gen = SequentialIdGenerator::new("t");
let doc = Html::import("<hr>".to_string(), &mut id_gen).unwrap();
assert_eq!(block_types(&doc), vec!["divider".to_string()]);
}
#[test]
fn import_inline_marks_round_trip() {
let mut id_gen = SequentialIdGenerator::new("t");
let src =
"<p><strong>bold</strong> <em>italic</em> <u>under</u> <s>strike</s> <code>c</code></p>";
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
let block = doc.get_block(&BlockId::new("t-1")).unwrap();
let collect_marks = |mark_type: &str| -> Vec<&str> {
block
.content
.iter()
.filter(|s| s.has_mark(mark_type))
.map(|s| s.text.as_str())
.collect()
};
assert_eq!(collect_marks("bold"), vec!["bold"]);
assert_eq!(collect_marks("italic"), vec!["italic"]);
assert_eq!(collect_marks("underline"), vec!["under"]);
assert_eq!(collect_marks("strike"), vec!["strike"]);
assert_eq!(collect_marks("code"), vec!["c"]);
}
#[test]
fn import_span_color_and_highlight() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = r#"<p><span style="color:#ff0000;background-color:#fff000">rgb</span></p>"#;
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
let block = doc.get_block(&BlockId::new("t-1")).unwrap();
let span = &block.content[0];
assert!(span.has_mark("color"));
assert!(span.has_mark("highlight"));
}
#[test]
fn import_link_captures_href() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = r#"<p><a href="https://example.com">click</a></p>"#;
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
let block = doc.get_block(&BlockId::new("t-1")).unwrap();
let span = &block.content[0];
let link_mark = span.marks.iter().find(|m| m.ty == "link").unwrap();
assert_eq!(
link_mark.attrs.get("href").and_then(Value::as_str),
Some("https://example.com")
);
}
#[test]
fn import_unknown_tag_flattens_to_paragraph() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = "<article>mystery content</article>";
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
assert_eq!(block_types(&doc), vec!["paragraph".to_string()]);
assert!(
block_texts(&doc)[0].contains("mystery content"),
"got: {:?}",
block_texts(&doc)
);
}
#[test]
fn import_transparent_containers_descend() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = "<div><section><p>inside nested wrappers</p></section></div>";
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
assert_eq!(block_types(&doc), vec!["paragraph".to_string()]);
assert_eq!(block_texts(&doc)[0], "inside nested wrappers");
}
#[test]
fn import_details_becomes_toggle() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = "<details><summary>click me</summary><p>nested</p></details>";
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
let types = block_types(&doc);
assert!(types.contains(&"toggle".to_string()), "types: {types:?}");
}
#[test]
fn import_malformed_html_does_not_panic() {
let mut id_gen = SequentialIdGenerator::new("t");
let src = "<p>unclosed <strong>text";
let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
assert!(!block_types(&doc).is_empty());
}
#[test]
fn import_empty_document() {
let mut id_gen = SequentialIdGenerator::new("t");
let doc = Html::import(String::new(), &mut id_gen).unwrap();
assert_eq!(doc.root_block_count(), 0);
}
#[test]
fn roundtrip_preserves_block_types_and_text() {
let mut doc = Document::new();
let mut h1 = Block::new(BlockId::new("h"), "heading");
h1.props.insert("level".into(), Value::from(1u64));
h1.content = vec![TextSpan::plain("Title")];
doc.push_root_block(h1);
let mut p = Block::new_paragraph(BlockId::new("p"));
p.content = vec![
TextSpan::plain("Hello, "),
TextSpan::with_marks("world", vec![Mark::bold()]),
TextSpan::plain("."),
];
doc.push_root_block(p);
let mut li = Block::new(BlockId::new("l"), "list");
li.props
.insert("style".into(), Value::String("unordered".into()));
li.content = vec![TextSpan::plain("item one")];
doc.push_root_block(li);
let exported = Html::export(&doc).unwrap();
let mut id_gen = SequentialIdGenerator::new("r");
let re = Html::import(exported, &mut id_gen).unwrap();
assert_eq!(
block_types(&re),
vec!["heading", "paragraph", "list"],
"block types drifted"
);
let texts = block_texts(&re);
assert_eq!(texts[0], "Title");
assert_eq!(texts[1], "Hello, world.");
assert_eq!(texts[2], "item one");
}
#[test]
fn import_tolerates_pathological_row_height_styles() {
for raw_height in [
"1e400px",
"NaNpx",
"-48px",
"0px",
"forty-eight-px",
"",
] {
let html = format!(
r#"<table><tbody><tr style="height: {raw_height}"><td>cell</td></tr></tbody></table>"#
);
let mut id_gen = SequentialIdGenerator::new("p");
let result = Html::import(html, &mut id_gen);
assert!(
result.is_ok(),
"import panicked or erred on row height {raw_height:?}"
);
}
}