use rdom_core::Dom;
use rdom_parser::{ParseError, parse, parse_into};
fn p(s: &str) -> (Dom<()>, Vec<rdom_core::NodeId>) {
parse(s).unwrap()
}
fn round_trip(src: &str) {
let (dom, ids) = parse::<()>(src).unwrap();
let out = if ids.len() == 1 {
dom.outer_markup(ids[0])
} else {
dom.inner_markup(dom.root())
};
assert_eq!(out, src, "round-trip mismatch");
}
#[test]
fn round_trip_empty_div() {
round_trip("<div></div>");
}
#[test]
fn round_trip_nested() {
round_trip("<div><p><span></span></p></div>");
}
#[test]
fn round_trip_attrs_alphabetic() {
round_trip(r#"<div data-x="1" id="main"></div>"#);
}
#[test]
fn round_trip_boolean_attr() {
round_trip("<input disabled/>");
}
#[test]
fn round_trip_void() {
round_trip("<br/>");
round_trip("<hr/>");
round_trip("<img/>");
}
#[test]
fn round_trip_entity_text() {
round_trip("<p>&</p>");
round_trip("<p><</p>");
round_trip("<p>></p>");
}
#[test]
fn round_trip_mixed_content() {
round_trip("<p>before <b>mid</b> after</p>");
}
#[test]
fn round_trip_comment() {
round_trip("<!-- note -->");
}
#[test]
fn round_trip_classes() {
round_trip(r#"<div class="a b c"></div>"#);
}
#[test]
fn round_trip_deep_tree() {
round_trip("<html><body><h1>Hi</h1><p>Para <em>emph</em> rest.</p></body></html>");
}
#[test]
fn entity_at_text_boundary() {
let (dom, ids) = p("<p>&abc</p>");
assert_eq!(
dom.node(ids[0]).first_child().unwrap().node_value(),
Some("&abc")
);
}
#[test]
fn entity_at_end_of_text() {
let (dom, ids) = p("<p>abc&</p>");
assert_eq!(
dom.node(ids[0]).first_child().unwrap().node_value(),
Some("abc&")
);
}
#[test]
fn multiple_entities_in_row() {
let (dom, ids) = p("<p>&<></p>");
assert_eq!(
dom.node(ids[0]).first_child().unwrap().node_value(),
Some("&<>")
);
}
#[test]
fn bare_ampersand_preserved() {
let (dom, ids) = p("<p>a & b</p>");
assert_eq!(
dom.node(ids[0]).first_child().unwrap().node_value(),
Some("a & b")
);
}
#[test]
fn numeric_entity_emoji() {
let (dom, ids) = p("<p>😀</p>");
assert_eq!(
dom.node(ids[0]).first_child().unwrap().node_value(),
Some("😀")
);
}
#[test]
fn numeric_entity_hex_emoji() {
let (dom, ids) = p("<p>😀</p>");
assert_eq!(
dom.node(ids[0]).first_child().unwrap().node_value(),
Some("😀")
);
}
#[test]
fn attr_with_special_chars() {
let (dom, ids) = p(r#"<a href="https://example.com/?q=x&y=1"></a>"#);
assert_eq!(
dom.node(ids[0]).get_attribute("href"),
Some("https://example.com/?q=x&y=1")
);
}
#[test]
fn attr_empty_quoted() {
let (dom, ids) = p(r#"<div data-x=""></div>"#);
assert_eq!(dom.node(ids[0]).get_attribute("data-x"), Some(""));
assert!(dom.node(ids[0]).has_attribute("data-x"));
}
#[test]
fn attr_value_with_equals() {
let (dom, ids) = p(r#"<div data="a=b=c"></div>"#);
assert_eq!(dom.node(ids[0]).get_attribute("data"), Some("a=b=c"));
}
#[test]
fn attr_value_with_slash() {
let (dom, ids) = p(r#"<div path="a/b/c"></div>"#);
assert_eq!(dom.node(ids[0]).get_attribute("path"), Some("a/b/c"));
}
#[test]
fn multiple_classes_preserved() {
let (dom, ids) = p(r#"<div class="alpha beta gamma"></div>"#);
for c in &["alpha", "beta", "gamma"] {
assert!(dom.node(ids[0]).has_class(c));
}
}
#[test]
fn error_line_col_multi_line() {
let err = parse::<()>("<div>\n <span></p>").unwrap_err();
assert_eq!(err.line, 2);
}
#[test]
fn error_first_line_col() {
let err = parse::<()>("<div x=").unwrap_err();
assert_eq!(err.line, 1);
}
#[test]
fn display_format_reads_natural() {
let err =
ParseError::new("expected `>`", 5, 12, 100).with_hint("missing closing angle bracket");
let s = format!("{err}");
assert!(s.contains("line 5"));
assert!(s.contains("col 12"));
assert!(s.contains("hint"));
}
#[test]
fn deep_nesting() {
let mut open = String::new();
let mut close = String::new();
for _ in 0..50 {
open.push_str("<x>");
close.push_str("</x>");
}
let src = format!("{open}{close}");
let (dom, ids) = parse::<()>(&src).unwrap();
let mut cur = ids[0];
for _ in 0..49 {
cur = dom.node(cur).first_child().unwrap().id();
}
assert_eq!(dom.node(cur).tag_name(), Some("x"));
}
#[test]
fn many_siblings() {
let src: String = (0..200).map(|_| "<a></a>").collect();
let (_dom, ids) = parse::<()>(&src).unwrap();
assert_eq!(ids.len(), 200);
}
#[test]
fn parse_into_preserves_existing_children() {
let mut dom: Dom<()> = Dom::new();
let root = dom.root();
let existing = dom.create_element("existing");
dom.append_child(root, existing).unwrap();
let ids = parse_into(&mut dom, "<new></new>", root).unwrap();
assert_eq!(ids.len(), 1);
assert_eq!(dom.node(root).child_nodes().count(), 2);
}
#[test]
fn parse_into_returns_top_level_only() {
let mut dom: Dom<()> = Dom::new();
let mount = dom.create_element("body");
let root = dom.root();
dom.append_child(root, mount).unwrap();
let ids = parse_into(&mut dom, "<outer><inner></inner></outer>", mount).unwrap();
assert_eq!(ids.len(), 1, "only the <outer> is top-level");
assert_eq!(dom.node(ids[0]).tag_name(), Some("outer"));
}
#[test]
fn parse_with_unit_ext() {
let _: (Dom<()>, _) = parse("<a></a>").unwrap();
}
#[test]
fn parse_with_tui_like_ext() {
#[derive(Debug, Default, Clone, PartialEq)]
struct MyExt {
hovered: bool,
}
let (_dom, _ids): (Dom<MyExt>, _) = parse("<button disabled>Click</button>").unwrap();
}
#[test]
fn snippet_list() {
let src = r#"<ul><li>A</li><li>B</li><li>C</li></ul>"#;
let (dom, ids) = p(src);
let ul = ids[0];
assert_eq!(dom.node(ul).child_element_count(), 3);
}
#[test]
fn snippet_form() {
let src = r#"
<form id="login">
<input type="text" name="user" placeholder="Username"/>
<input type="password" name="pass"/>
<button type="submit">Log in</button>
</form>
"#;
let (dom, ids) = parse::<()>(src).unwrap();
let form = ids
.iter()
.find(|&&id| dom.node(id).tag_name() == Some("form"))
.copied()
.unwrap();
assert_eq!(dom.node(form).get_attribute("id"), Some("login"));
let inputs: usize = dom
.node(form)
.child_nodes()
.filter(|c| c.tag_name() == Some("input"))
.count();
assert_eq!(inputs, 2);
}
#[test]
fn snippet_tree_item() {
let src = r#"<tree-item expanded="true" name="Folder"><span>item-abc123</span></tree-item>"#;
let (dom, ids) = p(src);
let item = ids[0];
assert_eq!(dom.node(item).tag_name(), Some("tree-item"));
assert_eq!(dom.node(item).get_attribute("expanded"), Some("true"));
assert_eq!(dom.node(item).get_attribute("name"), Some("Folder"));
}
#[test]
fn cjk_content_preserved() {
let (dom, ids) = p("<p>中文</p>");
assert_eq!(
dom.node(ids[0]).first_child().unwrap().node_value(),
Some("中文")
);
}
#[test]
fn emoji_zwj_in_text() {
let (dom, ids) = p("<p>👨👩👧</p>");
let c = dom.node(ids[0]).first_child().unwrap();
assert_eq!(c.node_value(), Some("👨\u{200D}👩\u{200D}👧"));
}
#[test]
fn combining_marks_preserved() {
let (dom, ids) = p("<p>e\u{0301}</p>");
assert_eq!(
dom.node(ids[0]).first_child().unwrap().node_value(),
Some("e\u{0301}")
);
}
#[test]
fn many_alternating_text_elements() {
let (_dom, ids) = p("a<b>B</b>c<d>D</d>e<f>F</f>g");
assert_eq!(ids.len(), 7);
}
#[test]
fn empty_comment() {
let (dom, ids) = p("<!---->");
let c = dom.node(ids[0]);
assert_eq!(c.data(), Some(""));
}
#[test]
fn comment_with_dashes() {
let (dom, ids) = p("<!-- this - is - fine -->");
let c = dom.node(ids[0]);
assert_eq!(c.data(), Some(" this - is - fine "));
}
#[test]
fn text_follows_void_element() {
let (dom, ids) = p("<br>hello");
assert_eq!(ids.len(), 2);
assert_eq!(dom.node(ids[0]).tag_name(), Some("br"));
assert_eq!(dom.node(ids[1]).node_value(), Some("hello"));
}
#[test]
fn parser_works_with_generic_ext() {
#[derive(Default, Clone)]
struct E;
let _: (Dom<E>, Vec<_>) = parse::<E>("<x/>").unwrap();
}
fn only_text(dom: &Dom<()>, el: rdom_core::NodeId) -> String {
let kids: Vec<_> = dom.node(el).child_nodes().collect();
assert_eq!(kids.len(), 1, "expected exactly one child under {el:?}");
assert_eq!(kids[0].node_type(), rdom_core::NodeType::Text);
kids[0].node_value().unwrap().to_string()
}
#[test]
fn less_than_before_non_letter_is_text() {
let (dom, ids) = p("<p>a < b</p>");
assert_eq!(only_text(&dom, ids[0]), "a < b");
let (dom, ids) = p("<p>1<2 and 3 <= 4</p>");
assert_eq!(only_text(&dom, ids[0]), "1<2 and 3 <= 4");
let (dom, ids) = p("<p>lonely <</p>");
assert_eq!(only_text(&dom, ids[0]), "lonely <");
}
#[test]
fn style_and_script_are_raw_text() {
let css = r#"a::before { content: "<"; } b > c { color: red } .x { content: "&" }"#;
let (dom, ids) = p(&format!("<style>{css}</style><div></div>"));
assert_eq!(dom.node(ids[0]).tag_name(), Some("style"));
assert_eq!(only_text(&dom, ids[0]), css);
assert_eq!(dom.node(ids[1]).tag_name(), Some("div"));
let js = "if (a < b && c) { d = '</p>'; }";
let (dom, ids) = p(&format!("<script>{js}</script>"));
assert_eq!(only_text(&dom, ids[0]), js);
}
#[test]
fn raw_text_ends_at_the_matching_end_tag_only() {
let (dom, ids) = p("<style>x</styles> y</STYLE><b></b>");
assert_eq!(only_text(&dom, ids[0]), "x</styles> y");
assert_eq!(dom.node(ids[1]).tag_name(), Some("b"));
}
#[test]
fn textarea_and_title_are_rcdata() {
let (dom, ids) = p("<textarea><x> <b>bold</b> & more</textarea>");
assert_eq!(only_text(&dom, ids[0]), "<x> <b>bold</b> & more");
let (dom, ids) = p("<title>a & b <i>c</i></title>");
assert_eq!(only_text(&dom, ids[0]), "a & b <i>c</i>");
}
#[test]
fn named_and_numeric_character_references() {
let (dom, ids) =
p("<p>© 2026 — … «x» × € ™</p>");
assert_eq!(
only_text(&dom, ids[0]),
"\u{A9} 2026 \u{2014} \u{2026} \u{AB}x\u{BB} \u{D7} \u{20AC}\u{A0}\u{2122}"
);
let (dom, ids) = p("<p>&bogus; & � � 😀</p>");
assert_eq!(
only_text(&dom, ids[0]),
"&bogus; & \u{FFFD} \u{FFFD} \u{1F600}"
);
}
#[test]
fn doctype_is_skipped() {
let (dom, ids) = p("<!DOCTYPE html><div>x</div>");
assert_eq!(ids.len(), 1);
assert_eq!(dom.node(ids[0]).tag_name(), Some("div"));
let (dom, ids) = p("<!doctype html>\n<div>x</div>");
assert_eq!(ids.len(), 2);
assert_eq!(dom.node(ids[1]).tag_name(), Some("div"));
}
#[test]
fn processing_instruction_is_a_bogus_comment_not_a_hang() {
let (dom, ids) = p("<?xml version='1.0'?><div></div>");
assert_eq!(ids.len(), 2);
assert_eq!(dom.node(ids[0]).node_type(), rdom_core::NodeType::Comment);
assert_eq!(dom.node(ids[0]).node_value(), Some("?xml version='1.0'?"));
assert_eq!(dom.node(ids[1]).tag_name(), Some("div"));
let (dom, ids) = p("<p>a <?pi?> b</p>");
let kids: Vec<_> = dom.node(ids[0]).child_nodes().collect();
assert_eq!(kids.len(), 3);
assert_eq!(kids[1].node_type(), rdom_core::NodeType::Comment);
}
#[test]
fn error_line_is_tracked_through_raw_text_bodies() {
let err = parse::<()>("<style>\n\n\n</style><p>").unwrap_err();
assert_eq!(err.line, 4, "{err}");
}
#[test]
fn raw_text_and_rcdata_round_trip() {
for src in [
r#"<style>a > b { content: "<"; } c { x: "&" }</style>"#,
"<script>if (a < b && c) {}</script>",
"<textarea><b> & x</textarea>",
"<title>a & b</title>",
] {
let (dom, ids) = p(src);
let out = dom.outer_markup(ids[0]);
let (dom2, ids2) = p(&out);
assert_eq!(
dom.node(ids[0])
.child_nodes()
.next()
.and_then(|t| t.node_value().map(str::to_string)),
dom2.node(ids2[0])
.child_nodes()
.next()
.and_then(|t| t.node_value().map(str::to_string)),
"{src} → {out}"
);
}
let (dom, ids) = p("<style>a > b {}</style>");
assert_eq!(dom.outer_markup(ids[0]), "<style>a > b {}</style>");
}
#[test]
fn overflowing_numeric_reference_is_replacement_character() {
let (dom, ids) = p("<p>� �</p>");
assert_eq!(only_text(&dom, ids[0]), "\u{FFFD} \u{FFFD}");
}
#[test]
fn reference_scanners_agree_on_signs() {
let (dom, ids) = p("<p>&#+65;</p>");
assert_eq!(only_text(&dom, ids[0]), "&#+65;");
let (dom, ids) = p("<textarea>&#+65;</textarea>");
assert_eq!(only_text(&dom, ids[0]), "&#+65;");
}
#[test]
fn stray_top_level_end_tag_is_an_error() {
let err = parse::<()>("<p>x</p></b>garbage").unwrap_err();
let text = format!("{err}");
assert!(text.contains("closing tag"), "{text}");
}
#[test]
fn textarea_drops_a_leading_newline() {
let (dom, ids) = p("<textarea>\nfoo\nbar</textarea>");
assert_eq!(only_text(&dom, ids[0]), "foo\nbar");
let (dom, ids) = p("<textarea>\n</textarea>");
assert_eq!(dom.node(ids[0]).child_nodes().count(), 0);
}
#[test]
fn full_named_reference_table_and_legacy_no_semicolon_names() {
let (dom, ids) = parse::<()>(
"<p title=\"?x=1©=2 © 2026 ∉\">© 2026 ∳ ¬it; ½</p>",
)
.unwrap();
let p = dom.node(ids[0]);
assert_eq!(
p.text_content(),
"\u{A9} 2026 \u{2233} \u{AC}it; \u{BD}",
"text: legacy `©` and `¬` (longest-prefix) decode; a very long modern name decodes"
);
assert_eq!(
p.get_attribute("title"),
Some("?x=1©=2 \u{A9} 2026 \u{2209}"),
"attribute: `©=` stays literal, `© ` and `∉` decode"
);
}
#[test]
fn numeric_references_remap_c1_controls_and_stop_at_non_digits() {
let (dom, ids) = parse::<()>("<p>’–€Ÿ Aabc; Ag Ÿ</p>").unwrap();
assert_eq!(
dom.node(ids[0]).text_content(),
"\u{2019}\u{2013}\u{20AC}\u{178} Aabc; Ag \u{178}"
);
let (dom, ids) = parse::<()>("<p></p>").unwrap();
assert_eq!(dom.node(ids[0]).text_content(), "\u{81}");
}