use skyscraper::xpath::grammar::data_model::{AnyAtomicType, XpathItem};
use skyscraper::xpath::grammar::XpathItemTreeNode;
use skyscraper::{html, xpath};
#[test]
fn boolean_nan_double_returns_false() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("if (number('not-a-number')) then 'yes' else 'no'").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::String("no".to_string()),
"boolean(NaN) should be false, so the else branch should be taken"
);
}
#[test]
fn boolean_zero_returns_false() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("if (0) then 'yes' else 'no'").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::String("no".to_string()),
"boolean(0) should be false"
);
}
#[test]
fn boolean_positive_number_returns_true() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("if (42) then 'yes' else 'no'").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::String("yes".to_string()),
"boolean(42) should be true"
);
}
#[test]
fn nan_eq_nan_is_false() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("number('x') eq number('x')").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::Boolean(false),
"NaN eq NaN should be false"
);
}
#[test]
fn nan_ne_nan_is_true() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("number('x') ne number('x')").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::Boolean(true),
"NaN ne NaN should be true"
);
}
#[test]
fn nan_lt_number_is_false() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("number('x') lt 0").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::Boolean(false),
"NaN lt 0 should be false"
);
}
#[test]
fn nan_gt_number_is_false() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("number('x') gt 0").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::Boolean(false),
"NaN gt 0 should be false"
);
}
#[test]
fn nan_le_nan_is_false() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("number('x') le number('x')").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::Boolean(false),
"NaN le NaN should be false"
);
}
#[test]
fn nan_ge_nan_is_false() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("number('x') ge number('x')").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::Boolean(false),
"NaN ge NaN should be false"
);
}
#[test]
fn normal_value_comparisons_still_work() {
let text = "<html><body><div>x</div></body></html>";
let document = html::parse(text).unwrap();
let cases = vec![
("1 eq 1", true),
("1 ne 2", true),
("1 lt 2", true),
("2 gt 1", true),
("1 le 1", true),
("1 ge 1", true),
("1 eq 2", false),
("1 ne 1", false),
];
for (expr, expected) in cases {
let xpath = xpath::parse(expr).unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1, "expr: {expr}");
let value = result[0].extract_as_any_atomic_type();
assert_eq!(
*value,
AnyAtomicType::Boolean(expected),
"Expression '{expr}' should be {expected}"
);
}
}
#[test]
fn xpath_dedup_removes_duplicate_nodes() {
let text = r#"<html><body>
<div class="a">first</div>
<div class="b">second</div>
</body></html>"#;
let document = html::parse(text).unwrap();
let xpath = xpath::parse("//div | //div").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result.len(),
2,
"Union of identical sets should deduplicate: got {} items",
result.len()
);
}
#[test]
fn xpath_dedup_preserves_distinct_nodes() {
let text = r#"<html><body>
<div>one</div>
<span>two</span>
<p>three</p>
</body></html>"#;
let document = html::parse(text).unwrap();
let xpath = xpath::parse("//div | //span | //p").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result.len(),
3,
"Union of distinct nodes should preserve all: got {} items",
result.len()
);
}
#[test]
fn xpath_dedup_preserves_same_content_different_nodes() {
let text = r#"<html><body>
<div><span>x</span></div>
<div><span>x</span></div>
</body></html>"#;
let document = html::parse(text).unwrap();
let xpath = xpath::parse("//span").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result.len(),
2,
"Two distinct span nodes with same content should both be preserved: got {} items",
result.len()
);
}
#[test]
fn xpath_dedup_identity_based_for_text_nodes() {
let text = "<html><body><p>hello</p><p>hello</p><p>hello</p></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("//p/text()").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result.len(),
3,
"Three distinct text nodes with same content should all be preserved: got {} items",
result.len()
);
}
#[test]
fn leading_slash_expansion_with_lazy_statics() {
let text = "<html><body><div><p>found</p></div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("/html/body/div/p").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1, "Leading slash path should find 1 element");
}
#[test]
fn leading_double_slash_expansion_with_lazy_statics() {
let text = "<html><body><div><p>a</p><p>b</p></div></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("//p").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result.len(),
2,
"Leading double-slash path should find 2 elements"
);
}
#[test]
fn mid_path_double_slash_with_lazy_statics() {
let text = r#"<html><body>
<div><span><a>deep</a></span></div>
<div><a>shallow</a></div>
</body></html>"#;
let document = html::parse(text).unwrap();
let xpath = xpath::parse("/html//a").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result.len(),
2,
"Mid-path double-slash should find all descendant <a> elements"
);
}
#[test]
fn bare_slash_returns_document_node() {
let text = "<html><body></body></html>";
let document = html::parse(text).unwrap();
let xpath = xpath::parse("/").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result.len(),
1,
"Bare '/' should return the document node"
);
}
#[test]
fn fn_substring_nan_start_returns_empty() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"substring("hello", number("x"))"#).unwrap();
let items = xpath.apply(&document).unwrap();
assert_eq!(
items[0],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::new())),
"substring with NaN start should return empty string"
);
}
#[test]
fn fn_substring_nan_length_returns_empty() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"substring("hello", 1, number("x"))"#).unwrap();
let items = xpath.apply(&document).unwrap();
assert_eq!(
items[0],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::new())),
"substring with NaN length should return empty string"
);
}
#[test]
fn fn_substring_normal_cases_still_work() {
let document = html::parse("<html><body></body></html>").unwrap();
let cases = vec![
(r#"substring("hello", 2, 3)"#, "ell"),
(r#"substring("hello", 2)"#, "ello"),
(r#"substring("12345", 0, 3)"#, "12"),
(r#"substring("12345", -1, 5)"#, "123"),
(r#"substring("hello", 1, 5)"#, "hello"),
];
for (expr, expected) in cases {
let xpath = xpath::parse(expr).unwrap();
let items = xpath.apply(&document).unwrap();
assert_eq!(
items[0],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::from(expected))),
"Expression '{expr}' should return \"{expected}\""
);
}
}
#[test]
fn fn_apply_unpacks_array_members() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"apply(fn:concat#2, ["hello ", "world"])"#).unwrap();
let items = xpath.apply(&document).unwrap();
assert_eq!(
items[0],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::from("hello world"))),
"fn:apply should unpack array members as function arguments"
);
}
#[test]
fn fn_apply_non_array_errors() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"apply(fn:concat#2, ("a", "b"))"#).unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"fn:apply with non-array second argument should error"
);
}
#[test]
fn fn_format_number_empty_picture_errors() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"format-number(123, //nonexistent)"#).unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"fn:format-number with empty picture should error, not panic"
);
}
#[test]
fn idiv_nan_raises_error() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("number('NaN') idiv 1").unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"NaN idiv 1 should raise FOAR0002 error"
);
}
#[test]
fn idiv_infinity_raises_error() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("1.0e308 * 10 idiv 1").unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"Infinity idiv 1 should raise FOAR0002 error"
);
}
#[test]
fn idiv_normal_still_works() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("10 idiv 3").unwrap();
let items = xpath.apply(&document).unwrap();
assert_eq!(
items[0],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(3)),
"10 idiv 3 should return 3"
);
}
#[test]
fn fn_node_name_returns_qname() {
let document = html::parse("<html><body><div>test</div></body></html>").unwrap();
let xpath = xpath::parse("node-name(//div)").unwrap();
let items = xpath.apply(&document).unwrap();
match &items[0] {
XpathItem::AnyAtomicType(AnyAtomicType::QName { local_name, .. }) => {
assert_eq!(local_name, "div", "local name should be 'div'");
}
other => panic!(
"fn:node-name should return QName, got: {:?}",
other
),
}
}
#[test]
fn fn_node_name_attribute_returns_qname() {
let document =
html::parse(r#"<html><body><div class="test">x</div></body></html>"#).unwrap();
let xpath = xpath::parse("node-name(//div/@class)").unwrap();
let items = xpath.apply(&document).unwrap();
match &items[0] {
XpathItem::AnyAtomicType(AnyAtomicType::QName { local_name, .. }) => {
assert_eq!(local_name, "class", "local name should be 'class'");
}
other => panic!(
"fn:node-name on attribute should return QName, got: {:?}",
other
),
}
}
#[test]
fn fn_round_two_args() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("round(3.456e0, 2)").unwrap();
let items = xpath.apply(&document).unwrap();
assert_eq!(
items[0],
XpathItem::AnyAtomicType(AnyAtomicType::Double(ordered_float::OrderedFloat(3.46))),
"round(3.456, 2) should return 3.46"
);
}
#[test]
fn fn_round_two_args_negative_precision() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("round(1234e0, -2)").unwrap();
let items = xpath.apply(&document).unwrap();
assert_eq!(
items[0],
XpathItem::AnyAtomicType(AnyAtomicType::Double(ordered_float::OrderedFloat(1200.0))),
"round(1234, -2) should return 1200"
);
}
#[test]
fn fn_tokenize_2arg_preserves_empty_strings() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"tokenize(",a,,b,", ",")"#).unwrap();
let items = xpath.apply(&document).unwrap();
assert_eq!(
items.len(),
5,
"tokenize(',a,,b,', ',') should return 5 items including empty strings, got {}",
items.len()
);
assert_eq!(
items[0],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::new())),
"First item should be empty string"
);
assert_eq!(
items[2],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::new())),
"Third item should be empty string"
);
assert_eq!(
items[4],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::new())),
"Fifth item should be empty string"
);
}
#[test]
fn fn_codepoints_to_string_negative_errors() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("codepoints-to-string((-1))").unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"codepoints-to-string(-1) should error, not wrap to valid char"
);
}
#[test]
fn fn_codepoints_to_string_valid_still_works() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("codepoints-to-string((65, 66, 67))").unwrap();
let items = xpath.apply(&document).unwrap();
assert_eq!(
items[0],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::from("ABC"))),
"codepoints-to-string(65, 66, 67) should return 'ABC'"
);
}
#[test]
fn substring_negative_length_returns_empty() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("substring('hello', 10, -5)").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::new())),
"substring with start beyond string and negative length should return empty"
);
}
#[test]
fn substring_large_start_negative_length() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("substring('hello', 100, -50)").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::String(String::new())),
"substring with large start and negative length should return empty"
);
}
#[test]
fn fn_apply_empty_second_arg_errors() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("apply(boolean#1, ())").unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"fn:apply with empty second argument should return an error, not panic"
);
}
#[test]
fn fn_qname_empty_second_arg_errors() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("QName('http://example.com', ())").unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"fn:QName with empty lexical QName should return an error, not panic"
);
}
#[test]
fn fn_sum_preserves_integer_type() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("sum((1, 2, 3))").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(6)),
"sum of integers should return an integer, not a double"
);
}
#[test]
fn fn_min_mixed_types_errors() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("min((1, 'hello'))").unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"fn:min with mixed numeric and string values should error (FORG0006)"
);
}
#[test]
fn fn_max_mixed_types_errors() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("max((1, 'hello'))").unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"fn:max with mixed numeric and string values should error (FORG0006)"
);
}
#[test]
fn fn_min_all_strings_works() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("min(('banana', 'apple', 'cherry'))").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::String("apple".to_string())),
"fn:min on all-string sequence should return the lexicographic minimum"
);
}
#[test]
fn fn_max_all_integers_preserves_type() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("max((3, 7, 2))").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(7)),
"fn:max on all-integer sequence should return an integer"
);
}
#[test]
fn fn_replace_backreference_syntax() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"replace("abcd", "(ab)", "\1X")"#).unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::String("abXcd".to_string())),
"fn:replace should convert XPath \\1 backreferences to work correctly"
);
}
#[test]
fn fn_replace_literal_dollar_sign() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"replace("abc", "b", "$")"#).unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::String("a$c".to_string())),
"Literal $ in replacement string should not be interpreted as backreference"
);
}
#[test]
fn fn_min_with_nan_returns_nan() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("min((1.0, number('NaN'), 3.0))").unwrap();
let result = xpath.apply(&document).unwrap();
match &result[0] {
XpathItem::AnyAtomicType(AnyAtomicType::Double(d)) => {
assert!(d.0.is_nan(), "fn:min with NaN in sequence should return NaN");
}
other => panic!("Expected Double(NaN), got: {:?}", other),
}
}
#[test]
fn fn_max_with_nan_returns_nan() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("max((1.0, number('NaN'), 3.0))").unwrap();
let result = xpath.apply(&document).unwrap();
match &result[0] {
XpathItem::AnyAtomicType(AnyAtomicType::Double(d)) => {
assert!(d.0.is_nan(), "fn:max with NaN in sequence should return NaN");
}
other => panic!("Expected Double(NaN), got: {:?}", other),
}
}
#[test]
fn fn_index_of_atomized_comparison() {
let document = html::parse("<html><body><div>hello</div></body></html>").unwrap();
let xpath = xpath::parse("index-of((10, 20, 30, 20), 20)").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 2, "fn:index-of should find two matches");
assert_eq!(result[0], XpathItem::AnyAtomicType(AnyAtomicType::Integer(2)));
assert_eq!(result[1], XpathItem::AnyAtomicType(AnyAtomicType::Integer(4)));
}
#[test]
fn node_comparison_rejects_multi_item_operands() {
let document =
html::parse("<html><body><div>a</div><div>b</div></body></html>").unwrap();
let xpath = xpath::parse("//div is //div").unwrap();
let result = xpath.apply(&document);
assert!(
result.is_err(),
"Node comparison with multi-item operands should raise XPTY0004"
);
}
#[test]
fn fn_distinct_values_cross_type_numeric() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("count(distinct-values((1, 1.0, 2)))").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(2)),
"fn:distinct-values should consider integer 1 and double 1.0 as equal"
);
}
#[test]
fn fn_number_true_returns_one() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("number(true())").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Double(ordered_float::OrderedFloat(1.0))),
"fn:number(true()) should return 1.0 per XPath spec"
);
}
#[test]
fn fn_number_false_returns_zero() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("number(false())").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Double(ordered_float::OrderedFloat(0.0))),
"fn:number(false()) should return 0.0 per XPath spec"
);
}
#[test]
fn fn_substring_neg_inf_pos_inf() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath =
xpath::parse(r#"substring("motor car", -1 div 0e0, 1 div 0e0)"#).unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::String("motor car".to_string())),
"fn:substring with start=-INF and length=INF should return the full string"
);
}
#[test]
fn fn_subsequence_nan_start_returns_empty() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("subsequence((1,2,3), number('NaN'))").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 0, "fn:subsequence with NaN start should return empty");
}
#[test]
fn fn_subsequence_pos_inf_start_returns_empty() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("subsequence((1,2,3), 1 div 0e0)").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 0, "fn:subsequence with +INF start should return empty");
}
#[test]
fn fn_subsequence_neg_inf_start_2arg_returns_full() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("subsequence((1,2,3), -1 div 0e0)").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 3, "fn:subsequence with -INF start (2-arg) should return full sequence");
}
#[test]
fn fn_subsequence_nan_length_returns_empty() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("subsequence((1,2,3), 1, number('NaN'))").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 0, "fn:subsequence with NaN length should return empty");
}
#[test]
fn fn_subsequence_neg_inf_pos_inf_combo() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("subsequence((1,2,3), -1 div 0e0, 1 div 0e0)").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 0, "fn:subsequence with -INF start and +INF length: -INF+INF=NaN end");
}
#[test]
fn fn_substring_neg_inf_2arg_returns_full() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"substring("hello", -1 div 0e0)"#).unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::String("hello".to_string())),
"fn:substring with -INF start (2-arg) should return the full string"
);
}
#[test]
fn boolean_function_item_errors() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("boolean(true#0)").unwrap();
let result = xpath.apply(&document);
assert!(result.is_err(), "boolean(function-item) should error with FORG0006");
}
#[test]
fn boolean_multi_item_non_node_errors() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("boolean((1, 2))").unwrap();
let result = xpath.apply(&document);
assert!(result.is_err(), "boolean((1, 2)) should error with FORG0006");
}
#[test]
fn deep_equal_cross_type_numeric() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("deep-equal(1, 1.0e0)").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Boolean(true)),
"deep-equal(1, 1.0e0) should be true (cross-type numeric)"
);
}
#[test]
fn fn_sum_integer_result_type() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("sum((1, 2, 3))").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(6)),
"sum((1,2,3)) should return Integer(6)"
);
}
#[test]
fn fn_sort_numeric_order() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("sort((2, 10, 1))").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result[0], XpathItem::AnyAtomicType(AnyAtomicType::Integer(1)));
assert_eq!(result[1], XpathItem::AnyAtomicType(AnyAtomicType::Integer(2)));
assert_eq!(result[2], XpathItem::AnyAtomicType(AnyAtomicType::Integer(10)));
}
#[test]
fn fn_sort_string_still_works() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"sort(("banana", "apple", "cherry"))"#).unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::String("apple".to_string()))
);
assert_eq!(
result[1],
XpathItem::AnyAtomicType(AnyAtomicType::String("banana".to_string()))
);
assert_eq!(
result[2],
XpathItem::AnyAtomicType(AnyAtomicType::String("cherry".to_string()))
);
}
#[test]
fn union_with_document_node_sorts_doc_first() {
let document = html::parse("<html><body><div>text</div></body></html>").unwrap();
let xpath = xpath::parse("//div | /").unwrap();
let result = xpath.apply(&document).unwrap();
assert!(result.len() >= 2, "union should have at least 2 items");
match &result[0] {
XpathItem::Node(XpathItemTreeNode::DocumentNode(_)) => {}
other => panic!("First item in union should be document node, got: {:?}", other),
}
}
#[test]
fn fn_function_name_returns_qname() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("function-name(true#0)").unwrap();
let result = xpath.apply(&document).unwrap();
match &result[0] {
XpathItem::AnyAtomicType(AnyAtomicType::QName {
local_name, prefix, ..
}) => {
assert_eq!(local_name, "true");
assert_eq!(prefix.as_deref(), Some("fn"));
}
other => panic!("Expected QName, got: {:?}", other),
}
}
#[test]
fn regex_xpath_initial_name_char() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"matches("hello", "^\i")"#).unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Boolean(true)),
r#"matches("hello", "^\i") should be true (h is a letter)"#
);
}
#[test]
fn regex_xpath_name_char_full() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse(r#"matches("a1", "^\c+$")"#).unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Boolean(true)),
r#"matches("a1", "^\c+$") should be true (a and 1 are name chars)"#
);
}
#[test]
fn simple_map_position_returns_correct_positions() {
let document = html::parse("<html><body><div>a</div><div>b</div><div>c</div></body></html>").unwrap();
let xpath = xpath::parse("(1 to 3) ! position()").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0], XpathItem::AnyAtomicType(AnyAtomicType::Integer(1)));
assert_eq!(result[1], XpathItem::AnyAtomicType(AnyAtomicType::Integer(2)));
assert_eq!(result[2], XpathItem::AnyAtomicType(AnyAtomicType::Integer(3)));
}
#[test]
fn simple_map_last_returns_correct_size() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("(1 to 4) ! last()").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 4);
for item in &result {
assert_eq!(
*item,
XpathItem::AnyAtomicType(AnyAtomicType::Integer(4)),
"last() in simple map should reflect LHS size"
);
}
}
#[test]
fn round_with_normal_precision() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("round(3.14159, 2)").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
match &result[0] {
XpathItem::AnyAtomicType(AnyAtomicType::Double(d)) => {
assert!(
(d.0 - 3.14).abs() < 0.001,
"round(3.14159, 2) should be approximately 3.14, got {}",
d.0
);
}
XpathItem::AnyAtomicType(AnyAtomicType::Float(f)) => {
assert!(
(f.0 - 3.14).abs() < 0.01,
"round(3.14159, 2) should be approximately 3.14, got {}",
f.0
);
}
other => panic!("expected Double or Float, got {:?}", other),
}
}
#[test]
fn round_with_extreme_precision_does_not_panic() {
let document = html::parse("<html><body></body></html>").unwrap();
let xpath = xpath::parse("round(1.5, 1000000)").unwrap();
let result = xpath.apply(&document);
assert!(result.is_ok(), "round with extreme precision should not panic");
}
#[test]
fn avg_with_numeric_strings() {
let document =
html::parse("<html><body><span>10</span><span>20</span><span>30</span></body></html>")
.unwrap();
let xpath = xpath::parse("avg(//span/text())").unwrap();
let result = xpath.apply(&document).unwrap();
assert_eq!(result.len(), 1);
match &result[0] {
XpathItem::AnyAtomicType(AnyAtomicType::Double(d)) => {
assert!(
(d.0 - 20.0).abs() < 0.001,
"avg of 10, 20, 30 should be 20.0, got {}",
d.0
);
}
other => panic!("expected Double, got {:?}", other),
}
}
#[test]
fn qname_ordering_ignores_prefix() {
use std::cmp::Ordering;
let qname1 = AnyAtomicType::QName {
namespace_uri: "http://example.com".to_string(),
local_name: "foo".to_string(),
prefix: Some("a".to_string()),
};
let qname2 = AnyAtomicType::QName {
namespace_uri: "http://example.com".to_string(),
local_name: "foo".to_string(),
prefix: Some("b".to_string()),
};
assert_eq!(
qname1.partial_cmp(&qname2),
Some(Ordering::Equal),
"QNames with same namespace+localname but different prefix should be equal"
);
}
#[test]
fn if_expr_display_no_trailing_newline() {
let xpath = xpath::parse("if (true()) then 1 else 2").unwrap();
let display = format!("{}", xpath);
assert!(
!display.ends_with('\n'),
"IfExpr Display should not end with newline, got: {:?}",
display
);
}
#[test]
fn xpath_item_set_insert_adds_items() {
use skyscraper::xpath::xpath_item_set::XpathItemSet;
let mut set = XpathItemSet::new();
assert!(set.is_empty());
set.insert(XpathItem::AnyAtomicType(AnyAtomicType::Integer(1)));
assert_eq!(set.len(), 1);
set.insert(XpathItem::AnyAtomicType(AnyAtomicType::Integer(2)));
assert_eq!(set.len(), 2);
set.insert(XpathItem::AnyAtomicType(AnyAtomicType::Integer(1)));
assert_eq!(set.len(), 3);
}
#[test]
fn predicate_position_one_based_first_child() {
let text = "<ul><li>a</li><li>b</li><li>c</li></ul>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("/html/body/ul/li[1]").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let el = result[0].extract_as_node().extract_as_element_node();
assert_eq!(el.name, "li");
let text = el.text_content(&tree);
assert_eq!(text, "a");
}
#[test]
fn predicate_position_one_based_last_child() {
let text = "<ul><li>a</li><li>b</li><li>c</li></ul>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("/html/body/ul/li[last()]").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let el = result[0].extract_as_node().extract_as_element_node();
let text = el.text_content(&tree);
assert_eq!(text, "c");
}
#[test]
fn predicate_position_one_based_all_positions() {
let text = "<ul><li>a</li><li>b</li><li>c</li></ul>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("/html/body/ul/li[position() > 0]").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn unary_negation_normal_values_work() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("-(42)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::Integer(-42));
}
#[test]
fn double_negation_returns_positive() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("-(-42)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::Integer(42));
}
#[test]
fn idiv_large_double_returns_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("1e20 idiv 1").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"idiv result exceeding i64 range should return an error"
);
}
#[test]
fn idiv_normal_values_work() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("7 idiv 2").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::Integer(3));
}
#[test]
fn round_integer_negative_precision() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("round(12345, -2)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::Integer(12300));
}
#[test]
fn round_integer_positive_precision_unchanged() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("round(12345, 2)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::Integer(12345));
}
#[test]
fn round_integer_zero_precision_unchanged() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("round(99999, 0)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::Integer(99999));
}
#[test]
fn round_half_to_even_integer() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("round-half-to-even(2550, -2)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::Integer(2600));
}
#[test]
fn round_half_to_even_integer_ties_down() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("round-half-to-even(2450, -2)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::Integer(2400));
}
#[test]
fn map_find_returns_single_array() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse(r#"map:find(map { "a": 1, "b": 2 }, "a")"#).unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1, "map:find should return exactly one item (an array)");
match &result[0] {
XpathItem::Function(skyscraper::xpath::grammar::data_model::Function::Array { members }) => {
assert_eq!(members.len(), 1, "array should have one member for one matching key");
}
other => panic!("expected Function::Array, got {:?}", other),
}
}
#[test]
fn deep_equal_function_items_raises_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath =
xpath::parse("deep-equal(true#0, false#0)").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"fn:deep-equal on function items should raise an error"
);
}
#[test]
fn deep_equal_atomic_values_still_works() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("deep-equal((1, 2, 3), (1, 2, 3))").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::Boolean(true));
}
#[test]
fn substring_extreme_large_start_returns_empty() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse(r#"substring("hello", 1e19)"#).unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::String(String::new()));
}
#[test]
fn substring_extreme_large_length() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse(r#"substring("hello", 2, 1e19)"#).unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::String(String::from("ello")));
}
#[test]
fn substring_extreme_negative_start_large_length() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse(r#"substring("hello", -1e19, 1e20)"#).unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::String(String::from("hello")));
}
#[test]
fn substring_extreme_negative_start_small_length() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse(r#"substring("hello", -1e19, 5)"#).unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::String(String::new()));
}
#[test]
fn idiv_i64_min_by_neg_one_returns_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath =
xpath::parse("(-2147483648 * 2147483648 * 2) idiv (-1)").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"i64::MIN idiv -1 should return an error, not panic from overflow"
);
}
#[test]
fn format_integer_nan_returns_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("format-integer(number('NaN'), '1')").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"format-integer(NaN, '1') should return an error"
);
}
#[test]
fn format_integer_infinity_returns_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("format-integer(1 div 0, '1')").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"format-integer(Infinity, '1') should return an error"
);
}
#[test]
fn fn_root_too_many_args_returns_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("fn:root(1, 2)").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"fn:root(1, 2) should return an error for too many arguments"
);
}
#[test]
fn range_expr_too_large_returns_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("1 to 20000000").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"1 to 20000000 should return an error (exceeds maximum range size)"
);
}
#[test]
fn fn_innermost_hashset_optimization_correct() {
let text = "<html><body><div><p><span>deep</span></p></div></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("innermost(//div | //span)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1, "innermost should return only the deepest node");
let node = result[0].extract_as_node();
match node {
XpathItemTreeNode::ElementNode(e) => {
assert_eq!(e.name, "span", "innermost should return span, not div");
}
other => panic!("Expected element node, got: {:?}", other),
}
}
#[test]
fn fn_outermost_hashset_optimization_correct() {
let text = "<html><body><div><p><span>deep</span></p></div></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("outermost(//div | //span)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1, "outermost should return only the shallowest node");
let node = result[0].extract_as_node();
match node {
XpathItemTreeNode::ElementNode(e) => {
assert_eq!(e.name, "div", "outermost should return div, not span");
}
other => panic!("Expected element node, got: {:?}", other),
}
}
#[test]
fn mod_i64_min_by_neg_one_returns_zero() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath =
xpath::parse("(-2147483648 * 2147483648 * 2) mod (-1)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Integer(0),
"i64::MIN mod -1 should return 0"
);
}
#[test]
fn mod_normal_values_still_work() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("10 mod 3").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(1)),
"10 mod 3 should return 1"
);
}
#[test]
fn round_half_to_even_extreme_integer_no_panic() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath =
xpath::parse("round-half-to-even(-2147483648 * 2147483648 * 2, -1)").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_ok(),
"round-half-to-even on extreme integer should not panic: {:?}",
result.err()
);
}
#[test]
fn round_half_to_even_normal_integer_still_works() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("round-half-to-even(2550, -2)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(2600)),
"round-half-to-even(2550, -2) should return 2600"
);
}
#[test]
fn distinct_values_nan_not_collapsed() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("count(distinct-values((number('NaN'), number('NaN'), 1)))").unwrap();
let result = xpath.apply(&tree).unwrap();
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::Integer(3),
"distinct-values should treat NaN values as distinct (NaN != NaN)"
);
}
#[test]
fn index_of_nan_not_found() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("count(index-of((1, number('NaN'), 3), number('NaN')))").unwrap();
let result = xpath.apply(&tree).unwrap();
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::Integer(0),
"index-of should not find NaN (NaN != NaN)"
);
}
#[test]
fn round_float_precision_no_double_rounding() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("round(2.15e0, 1)").unwrap();
let result = xpath.apply(&tree).unwrap();
match &result[0] {
XpathItem::AnyAtomicType(AnyAtomicType::Double(d)) => {
assert!(
(d.0 - 2.2).abs() < 0.001,
"round(2.15e0, 1) should be 2.2, got {}",
d.0
);
}
other => panic!("Expected Double, got: {:?}", other),
}
}
#[test]
fn concat_multi_item_arg_errors() {
let text = "<html><body><div>a</div><div>b</div></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("concat(//div, 'x')").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"fn:concat with multi-item argument should return an error"
);
}
#[test]
fn concat_single_item_args_works() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("concat('hello', ' ', 'world')").unwrap();
let result = xpath.apply(&tree).unwrap();
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::String("hello world".to_string()),
"concat with single-item args should work"
);
}
#[test]
fn concat_empty_sequence_treated_as_empty_string() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("concat('a', (), 'b')").unwrap();
let result = xpath.apply(&tree).unwrap();
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::String("ab".to_string()),
"concat with empty sequence should produce 'ab'"
);
}
#[test]
fn round_integer_near_max_no_overflow() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("round(2147483647 * 2147483647, -1)").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_ok(),
"round on large integer should not overflow: {:?}",
result.err()
);
}
#[test]
fn round_integer_near_min_no_overflow() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("round(-2147483648 * 2147483648 * 2, -1)").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_ok(),
"round on near-i64::MIN should not overflow: {:?}",
result.err()
);
}
#[test]
fn format_integer_alpha_basic() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("format-integer(1, 'a')").unwrap();
let result = xpath.apply(&tree).unwrap();
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::String("a".to_string()));
}
#[test]
fn format_integer_alpha_z() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("format-integer(26, 'a')").unwrap();
let result = xpath.apply(&tree).unwrap();
let val = result[0].extract_as_any_atomic_type();
assert_eq!(*val, AnyAtomicType::String("z".to_string()));
}
#[test]
fn format_integer_alpha_aa() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("format-integer(27, 'a')").unwrap();
let result = xpath.apply(&tree).unwrap();
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::String("aa".to_string()),
"format-integer(27, 'a') should be 'aa' (bijective base-26)"
);
}
#[test]
fn format_integer_alpha_ba() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("format-integer(53, 'a')").unwrap();
let result = xpath.apply(&tree).unwrap();
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::String("ba".to_string()),
"format-integer(53, 'a') should be 'ba'"
);
}
#[test]
fn format_integer_upper_alpha() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("format-integer(27, 'A')").unwrap();
let result = xpath.apply(&tree).unwrap();
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::String("AA".to_string()),
"format-integer(27, 'A') should be 'AA'"
);
}
#[test]
fn trace_returns_input_unchanged() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("trace(42)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::Integer(42),
"fn:trace should return its input unchanged"
);
}
#[test]
fn trace_with_label_returns_input() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("trace('hello', 'label')").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::String("hello".to_string()),
"fn:trace with label should return its input unchanged"
);
}
#[test]
fn inline_function_basic_call() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("let $f := function($x) { $x + 1 } return $f(5)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let val = result[0].extract_as_any_atomic_type();
assert_eq!(
*val,
AnyAtomicType::Integer(6),
"Inline function should return 5 + 1 = 6"
);
}
#[test]
fn inline_function_multiple_calls() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse(
"let $double := function($x) { $x * 2 } return ($double(3), $double(5), $double(7))",
)
.unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(
result[0],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(6))
);
assert_eq!(
result[1],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(10))
);
assert_eq!(
result[2],
XpathItem::AnyAtomicType(AnyAtomicType::Integer(14))
);
}
#[test]
fn cast_nan_to_integer_should_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("number('not-a-number') cast as integer").unwrap();
let result = xpath.apply(&tree);
assert!(result.is_err(), "Casting NaN to integer should raise FOCA0002");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("FOCA0002"),
"Error should mention FOCA0002, got: {msg}"
);
}
#[test]
fn cast_infinity_to_integer_should_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("(1.0e308 * 10) cast as integer").unwrap();
let result = xpath.apply(&tree);
assert!(result.is_err(), "Casting Infinity to integer should raise FOCA0002");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("FOCA0002"),
"Error should mention FOCA0002, got: {msg}"
);
}
#[test]
fn cast_negative_infinity_to_integer_should_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("(-1.0e308 * 10) cast as integer").unwrap();
let result = xpath.apply(&tree);
assert!(result.is_err(), "Casting -Infinity to integer should raise FOCA0002");
}
#[test]
fn cast_normal_float_to_integer_succeeds() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("3.7 cast as integer").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Integer(3),
"Truncation of 3.7 should yield 3"
);
}
#[test]
fn fn_min_boolean_should_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("min((true(), 'hello'))").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"fn:min with Boolean and String should raise FORG0006"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("FORG0006"),
"Error should mention FORG0006, got: {msg}"
);
}
#[test]
fn fn_max_boolean_should_error() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("max((true(), false()))").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"fn:max with Boolean values should raise FORG0006"
);
}
#[test]
fn fn_min_all_strings_succeeds() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("min(('banana', 'apple', 'cherry'))").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::String("apple".to_string())
);
}
#[test]
fn fn_root_string_argument_should_error() {
let text = "<html><body><div>hello</div></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("fn:root('some string')").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"fn:root('string') should raise XPTY0004"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("XPTY0004"),
"Error should mention XPTY0004, got: {msg}"
);
}
#[test]
fn fn_root_integer_argument_should_error() {
let text = "<html><body><div>hello</div></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("fn:root(42)").unwrap();
let result = xpath.apply(&tree);
assert!(
result.is_err(),
"fn:root(42) should raise XPTY0004"
);
}
#[test]
fn fn_root_node_argument_succeeds() {
let text = "<html><body><div>hello</div></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("name(fn:root(//div)/html)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::String("html".to_string()),
"fn:root(node) should return the document root"
);
}
#[test]
fn fn_root_no_argument_succeeds() {
let text = "<html><body><div>hello</div></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("name(fn:root()/html)").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::String("html".to_string()),
);
}
#[test]
fn sort_mixed_integer_float_numeric_order() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("sort((10, 1, 2.5))").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Integer(1),
"First element should be 1"
);
match result[1].extract_as_any_atomic_type() {
AnyAtomicType::Float(f) => assert!(
(f.0 - 2.5).abs() < f32::EPSILON,
"Second element should be 2.5, got {}",
f.0
),
other => panic!("Expected Float(2.5), got {:?}", other),
}
assert_eq!(
*result[2].extract_as_any_atomic_type(),
AnyAtomicType::Integer(10),
"Third element should be 10"
);
}
#[test]
fn partial_ord_integer_double_comparison() {
let i = AnyAtomicType::Integer(5);
let d = AnyAtomicType::Double(ordered_float::OrderedFloat(3.0));
assert!(i > d, "Integer(5) should be > Double(3.0)");
assert!(d < i, "Double(3.0) should be < Integer(5)");
let d2 = AnyAtomicType::Double(ordered_float::OrderedFloat(5.0));
assert!(
i.partial_cmp(&d2) == Some(std::cmp::Ordering::Equal),
"Integer(5) should == Double(5.0)"
);
}
#[test]
fn fn_avg_all_integers_returns_double() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("avg((2, 4, 6))").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert!(
(d.0 - 4.0).abs() < f64::EPSILON,
"avg((2,4,6)) should return Double(4.0), got {}",
d.0
),
other => panic!("Expected Double, got {:?}", other),
}
}
#[test]
fn fn_avg_non_divisible_returns_double() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("avg((1, 2))").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert!(
(d.0 - 1.5).abs() < f64::EPSILON,
"avg((1,2)) should return 1.5, got {}",
d.0
),
other => panic!("Expected Double, got {:?}", other),
}
}
#[test]
fn fn_avg_mixed_numeric_returns_double() {
let text = "<x/>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("avg((1, 2.0, 3))").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert!(
(d.0 - 2.0).abs() < f64::EPSILON,
"avg((1,2.0,3)) should return 2.0, got {}",
d.0
),
other => panic!("Expected Double, got {:?}", other),
}
}
#[test]
fn name_test_still_matches_after_eval_refactor() {
let text = "<html><body><div class='a'>1</div><span>2</span><div class='b'>3</div></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("//div").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 2, "Should find 2 div elements");
}
#[test]
fn name_test_attribute_axis_after_refactor() {
let text = "<html><body><div id='test' class='foo'>x</div></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("//div/@class").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 1, "Should find 1 class attribute");
}
#[test]
fn name_test_wildcard_after_refactor() {
let text = "<html><body><div>1</div><span>2</span></body></html>";
let tree = html::parse(text).unwrap();
let xpath = xpath::parse("//body/*").unwrap();
let result = xpath.apply(&tree).unwrap();
assert_eq!(result.len(), 2, "Wildcard should match div and span");
}
#[test]
fn fn_round_half_towards_positive_infinity() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("round(-0.5e0)").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert_eq!(d.0, 0.0, "round(-0.5) should be 0.0"),
other => panic!("Expected Double, got: {:?}", other),
}
}
#[test]
fn fn_round_positive_half_rounds_up() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("round(0.5e0)").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert_eq!(d.0, 1.0, "round(0.5) should be 1.0"),
other => panic!("Expected Double, got: {:?}", other),
}
}
#[test]
fn fn_round_integer_neg_half_towards_positive_infinity() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("round(-15, -1)").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Integer(n) => {
assert_eq!(*n, -10, "round(-15, -1) should be -10 (towards positive infinity)")
}
other => panic!("Expected Integer, got: {:?}", other),
}
}
#[test]
fn any_kind_test_matches_attribute_nodes() {
let text = "<html><body><div id='test'>x</div></body></html>";
let tree = html::parse(text).unwrap();
let xp = xpath::parse("//div/attribute::node()").unwrap();
let result = xp.apply(&tree).unwrap();
assert!(
!result.is_empty(),
"attribute::node() should match attribute nodes"
);
}
#[test]
fn attribute_display_escapes_special_chars() {
let text = r#"<html><body><div data-val="a&b<c>d"e">x</div></body></html>"#;
let tree = html::parse(text).unwrap();
let xp = xpath::parse("//div/@data-val").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
let attr_node = result[0].extract_as_node().as_attribute_node().unwrap();
let display = format!("{}", attr_node);
assert!(
display.contains("&"),
"Ampersand should be escaped to & in attribute display: {}",
display
);
assert!(
display.contains("<"),
"< should be escaped to < in attribute display: {}",
display
);
}
#[test]
fn integer_float_comparison_uses_f64_precision() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("16777217 > 16777216.0e0").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Boolean(b) => {
assert!(*b, "16777217 > 16777216.0 should be true with f64 precision")
}
other => panic!("Expected Boolean, got: {:?}", other),
}
}
#[test]
fn fn_root_empty_sequence_returns_empty() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("root(())").unwrap();
let result = xp.apply(&tree).unwrap();
assert!(
result.is_empty(),
"root(()) should return empty sequence, got {} items",
result.len()
);
}
#[test]
fn format_integer_negative_with_padding() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("format-integer(-5, '001')").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::String(s) => {
assert_eq!(s, "-005", "format-integer(-5, '001') should produce '-005'")
}
other => panic!("Expected String, got: {:?}", other),
}
}
#[test]
fn format_integer_negative_two_digit_padding() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("format-integer(-3, '01')").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::String(s) => {
assert_eq!(s, "-03", "format-integer(-3, '01') should produce '-03'")
}
other => panic!("Expected String, got: {:?}", other),
}
}
#[test]
fn fn_data_too_many_args_errors() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("data(1, 2)").unwrap();
let result = xp.apply(&tree);
assert!(
result.is_err(),
"data(1, 2) should error — fn:data accepts at most 1 argument"
);
}
#[test]
fn fn_string_too_many_args_errors() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("string(1, 2)").unwrap();
let result = xp.apply(&tree);
assert!(
result.is_err(),
"string(1, 2) should error — fn:string accepts at most 1 argument"
);
}
#[test]
fn attribute_test_rejects_optional_marker() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("//attribute(*, xs:string?)");
if let Ok(xp) = xp {
let _ = xp.apply(&tree);
}
let xp_normal = xpath::parse("self::attribute(*, xs:string)");
assert!(xp_normal.is_ok(), "attribute(*, xs:string) should still parse");
}
#[test]
fn string_ordering_comparison_uses_lexicographic_order() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("'2' < '10'").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Boolean(b) => {
assert!(!*b, "'2' < '10' should be false (lexicographic string ordering)")
}
other => panic!("Expected Boolean, got: {:?}", other),
}
}
#[test]
fn string_equality_does_not_cast_to_double() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("'02' = '2'").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Boolean(b) => {
assert!(!*b, "'02' = '2' should be false (string equality, no cast)")
}
other => panic!("Expected Boolean, got: {:?}", other),
}
}
#[test]
fn prefixed_name_test_resolves_namespace() {
let text = r#"<html><body><svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg></body></html>"#;
let tree = html::parse(text).unwrap();
let xp = xpath::parse("//svg:rect").unwrap();
let result = xp.apply(&tree).unwrap();
assert!(
!result.is_empty(),
"//svg:rect should match SVG rect elements"
);
}
#[test]
fn prefixed_name_test_wrong_namespace_no_match() {
let text = "<html><body><div>x</div></body></html>";
let tree = html::parse(text).unwrap();
let xp = xpath::parse("//svg:div").unwrap();
let result = xp.apply(&tree).unwrap();
assert!(
result.is_empty(),
"//svg:div should not match HTML div elements"
);
}
#[test]
fn map_value_preserves_node_items() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("let $m := map { 'a': 1, 'b': 2 } return $m('a')").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Integer(n) => assert_eq!(*n, 1),
other => panic!("Expected Integer, got: {:?}", other),
}
}
#[test]
fn array_member_preserves_items() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("let $a := [1, 2, 3] return $a(2)").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Integer(n) => assert_eq!(*n, 2),
other => panic!("Expected Integer, got: {:?}", other),
}
}
#[test]
fn text_iter_collects_all_text_nodes() {
let text = "<html><body><div>Hello <span>World</span></div></body></html>";
let tree = html::parse(text).unwrap();
let xp = xpath::parse("string(//div)").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::String(s) => {
assert_eq!(s, "Hello World", "string(//div) should concatenate all text")
}
other => panic!("Expected String, got: {:?}", other),
}
}
#[test]
fn qname_equality_ignores_prefix() {
let q1 = AnyAtomicType::QName {
namespace_uri: "http://example.com".to_string(),
local_name: "foo".to_string(),
prefix: Some("a".to_string()),
};
let q2 = AnyAtomicType::QName {
namespace_uri: "http://example.com".to_string(),
local_name: "foo".to_string(),
prefix: Some("b".to_string()),
};
assert_eq!(q1, q2, "QNames with same ns+localname but different prefixes should be equal");
let q3 = AnyAtomicType::QName {
namespace_uri: "http://example.com".to_string(),
local_name: "bar".to_string(),
prefix: Some("a".to_string()),
};
assert_ne!(q1, q3, "QNames with different local names should not be equal");
}
#[test]
fn display_pretty_void_element_no_closing_tag() {
use skyscraper::xpath::grammar::DisplayFormatting;
let text = "<html><body><br><hr></body></html>";
let tree = html::parse(text).unwrap();
let xp = xpath::parse("//body").unwrap();
let result = xp.apply(&tree).unwrap();
let node = result[0].extract_as_node().as_element_node().unwrap();
let display = node.display(&tree, DisplayFormatting::Pretty, 0);
assert!(
!display.contains("</br>"),
"display_pretty should not emit </br>, got: {}",
display
);
assert!(
!display.contains("</hr>"),
"display_pretty should not emit </hr>, got: {}",
display
);
}
#[test]
fn nan_cast_as_boolean_returns_false() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("number('NaN') cast as xs:boolean").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(false),
"NaN cast as xs:boolean should be false"
);
}
#[test]
fn castable_unknown_type_returns_false() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("42 castable as xs:unknownType").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(false),
"42 castable as xs:unknownType should return false, not error"
);
}
#[test]
fn string_ordering_lexicographic() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("'apple' < 'banana'").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(true),
"'apple' < 'banana' should be true (lexicographic)"
);
let xp2 = xpath::parse("'9' < '10'").unwrap();
let result2 = xp2.apply(&tree).unwrap();
assert_eq!(
*result2[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(false),
"'9' < '10' should be false (lexicographic)"
);
}
#[test]
fn i64_min_mod_neg_one_returns_zero() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("(-10) mod -1").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Integer(0),
"(-10) mod -1 should return 0"
);
}
#[test]
fn fn_root_walks_up_from_argument() {
let text = "<html><body><div>hello</div></body></html>";
let tree = html::parse(text).unwrap();
let xp = xpath::parse("name(fn:root(//div)/html)").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::String("html".to_string()),
"fn:root(//div) should walk up from the div to the document root"
);
}
#[test]
fn fn_avg_always_returns_double() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("avg((1, 2, 3))").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert!(
(d.0 - 2.0).abs() < f64::EPSILON,
"avg((1,2,3)) should return Double(2.0), got {}",
d.0
),
other => panic!("Expected Double for avg result, got {:?}", other),
}
}
#[test]
fn fn_lang_two_arguments() {
let text = r#"<html><body><div xml:lang="en">hello</div></body></html>"#;
let tree = html::parse(text).unwrap();
let xp = xpath::parse(r#"lang("en", //div)"#).unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(true),
"lang('en', //div) should return true when div has xml:lang='en'"
);
}
#[test]
fn map_find_empty_key_returns_empty_array() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("array:size(map:find(map{}, ()))").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Integer(0),
"map:find(map{{}}, ()) should return empty array with size 0"
);
}
#[test]
fn union_non_node_items_errors() {
let tree = html::parse("<html><body><div/></body></html>").unwrap();
let xp = xpath::parse("(1, 2) | (3, 4)").unwrap();
let result = xp.apply(&tree);
assert!(
result.is_err(),
"union of non-node items should error with XPTY0004"
);
}
#[test]
fn union_node_items_succeeds() {
let tree = html::parse("<html><body><div/><span/></body></html>").unwrap();
let xp = xpath::parse("//div | //span").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 2, "union of node items should succeed");
}
#[test]
fn fn_lang_empty_second_arg_returns_error() {
let tree = html::parse(r#"<html><body><div xml:lang="en">x</div></body></html>"#).unwrap();
let xp = xpath::parse(r#"lang("en", ())"#).unwrap();
let result = xp.apply(&tree);
assert!(
result.is_err(),
"fn:lang with empty second argument should return an error, not panic"
);
}
#[test]
fn element_test_respects_svg_namespace_prefix() {
let text = r#"<html><body><svg><circle r="5"/></svg></body></html>"#;
let tree = html::parse(text).unwrap();
let xp = xpath::parse("//self::element(svg:circle)").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(
result.len(),
1,
"element(svg:circle) should match the SVG circle element"
);
}
#[test]
fn element_test_unprefixed_matches_any_namespace() {
let text = r#"<html><body><svg><circle r="5"/></svg></body></html>"#;
let tree = html::parse(text).unwrap();
let xp = xpath::parse("//self::element(circle)").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(
result.len(),
1,
"element(circle) without prefix should match by local name"
);
}
#[test]
fn attribute_test_unprefixed_matches_by_name() {
let text = r#"<html><body><div lang="en">x</div></body></html>"#;
let tree = html::parse(text).unwrap();
let xp = xpath::parse("//div/@*[self::attribute(lang)]").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(
result.len(),
1,
"attribute(lang) should match by local name"
);
}
#[test]
fn for_expr_with_spaces() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("for $x in (1, 2, 3) return $x").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 3, "for expression should return 3 items");
}
#[test]
fn fn_avg_returns_correct_value_after_dead_code_removal() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("avg((1, 2, 3))").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 1);
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert!(
(d.0 - 2.0).abs() < f64::EPSILON,
"avg((1,2,3)) should return 2.0, got {}",
d.0
),
other => panic!("Expected Double, got {:?}", other),
}
}
#[test]
fn range_expr_returns_correct_items() {
let tree = html::parse("<x/>").unwrap();
let xp = xpath::parse("1 to 5").unwrap();
let result = xp.apply(&tree).unwrap();
assert_eq!(result.len(), 5, "1 to 5 should return 5 items");
for (i, item) in result.iter().enumerate() {
assert_eq!(
*item.extract_as_any_atomic_type(),
AnyAtomicType::Integer((i + 1) as i64),
"Item {} should be {}",
i,
i + 1
);
}
}
#[test]
fn find_elements_filters_non_element_results() {
let text = r#"<html><body><div>text</div></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("//div/text()").unwrap();
let elements = xp.find_elements(&doc).unwrap();
assert!(
elements.is_empty(),
"find_elements should filter out non-element nodes"
);
}
#[test]
fn find_elements_returns_elements() {
let text = r#"<html><body><div>text</div><span>more</span></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("//div | //span").unwrap();
let elements = xp.find_elements(&doc).unwrap();
assert_eq!(elements.len(), 2, "should find both div and span");
}
#[test]
fn cast_large_double_to_integer_errors() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("1e300 cast as xs:integer").unwrap();
let result = xp.apply(&doc);
assert!(result.is_err(), "1e300 cast to integer should error (overflow)");
}
#[test]
fn cast_normal_double_to_integer_works() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("42.7 cast as xs:integer").unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Integer(42)
);
}
#[test]
fn cast_string_inf_to_double() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#""INF" cast as xs:double"#).unwrap();
let result = xp.apply(&doc).unwrap();
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert!(d.is_infinite() && d.is_sign_positive()),
other => panic!("expected Double(INF), got {:?}", other),
}
}
#[test]
fn cast_string_neg_inf_to_double() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#""-INF" cast as xs:double"#).unwrap();
let result = xp.apply(&doc).unwrap();
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert!(d.is_infinite() && d.is_sign_negative()),
other => panic!("expected Double(-INF), got {:?}", other),
}
}
#[test]
fn cast_string_nan_to_double() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#""NaN" cast as xs:double"#).unwrap();
let result = xp.apply(&doc).unwrap();
match result[0].extract_as_any_atomic_type() {
AnyAtomicType::Double(d) => assert!(d.is_nan()),
other => panic!("expected Double(NaN), got {:?}", other),
}
}
#[test]
fn idiv_finite_by_infinity_returns_zero() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("2.0 idiv (1.0 div 0.0)").unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Integer(0),
"finite idiv INF should return 0"
);
}
#[test]
fn idiv_infinity_by_finite_errors() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("(1.0 div 0.0) idiv 2.0").unwrap();
let result = xp.apply(&doc);
assert!(
result.is_err(),
"INF idiv finite should error"
);
}
#[test]
fn comparison_one_equals_true() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("1 = true()").unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(true),
"1 = true() should be true (boolean coercion)"
);
}
#[test]
fn comparison_zero_equals_false() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("0 = false()").unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(true),
"0 = false() should be true (boolean coercion)"
);
}
#[test]
fn instance_of_untyped_atomic_string() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#""hello" instance of xs:untypedAtomic"#).unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(true),
"strings should match xs:untypedAtomic"
);
}
#[test]
fn following_axis_document_order() {
let text = r#"<html><body><div id="a"><span id="b">B</span></div><div id="c">C</div><div id="d">D</div></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("//div[@id='a']/following::div").unwrap();
let result = xp.apply(&doc).unwrap();
assert!(result.len() >= 2, "should find following divs");
let names: Vec<_> = result
.iter()
.filter_map(|item| {
if let XpathItem::Node(XpathItemTreeNode::ElementNode(e)) = item {
e.get_attribute(&doc, "id").map(|s| s.to_string())
} else {
None
}
})
.collect();
assert_eq!(names, vec!["c", "d"], "following axis should be in document order");
}
#[test]
fn predicate_numeric_position_works() {
let text = r#"<html><body><div>A</div><div>B</div></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("//div[2]").unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(result.len(), 1, "//div[2] should return one element");
}
#[test]
fn map_creation_and_lookup() {
let text = r#"<html><body></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#"map {"a": 1, "b": 2}?a"#).unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Integer(1),
"map lookup should return correct value"
);
}
#[test]
fn xpath_operations_work_with_result_id() {
let text = r#"<html><body><div>text</div></body></html>"#;
let doc = html::parse(text).unwrap();
let xp = xpath::parse("//div").unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(result.len(), 1, "normal XPath should work with Result-based id()");
if let XpathItem::Node(XpathItemTreeNode::ElementNode(e)) = &result[0] {
let children: Vec<_> = e.children(&doc).collect();
assert!(!children.is_empty(), "children() should work with Result id()");
let parent = e.parent(&doc);
assert!(parent.is_some(), "parent() should work with Result id()");
} else {
panic!("expected element node");
}
}
#[test]
fn integer_double_comparison_large_value_not_equal() {
use ordered_float::OrderedFloat;
use std::cmp::Ordering;
let large_int = AnyAtomicType::Integer(9007199254740993_i64); let rounded_double = AnyAtomicType::Double(OrderedFloat(9007199254740992.0_f64));
assert_eq!(
large_int.partial_cmp(&rounded_double),
Some(Ordering::Greater),
"9007199254740993 > 9007199254740992.0 — precision must be preserved"
);
assert_ne!(
large_int.partial_cmp(&rounded_double),
Some(Ordering::Equal),
"9007199254740993 != 9007199254740992.0"
);
}
#[test]
fn integer_double_comparison_nan() {
use ordered_float::OrderedFloat;
let int_val = AnyAtomicType::Integer(42);
let nan_val = AnyAtomicType::Double(OrderedFloat(f64::NAN));
assert_eq!(int_val.partial_cmp(&nan_val), None);
}
#[test]
fn integer_double_comparison_infinity() {
use ordered_float::OrderedFloat;
use std::cmp::Ordering;
let int_val = AnyAtomicType::Integer(i64::MAX);
let pos_inf = AnyAtomicType::Double(OrderedFloat(f64::INFINITY));
let neg_inf = AnyAtomicType::Double(OrderedFloat(f64::NEG_INFINITY));
assert_eq!(int_val.partial_cmp(&pos_inf), Some(Ordering::Less));
assert_eq!(int_val.partial_cmp(&neg_inf), Some(Ordering::Greater));
}
#[test]
fn integer_double_comparison_normal_values() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse("42 = 42.0").unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(true),
"42 = 42.0 should be true"
);
}
#[test]
fn integer_double_comparison_with_nan() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse("42 = number('NaN')").unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(false),
"integer = NaN should be false"
);
}
#[test]
fn integer_double_comparison_with_fractional() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse("3 < 3.5").unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(true),
"3 < 3.5 should be true"
);
}
#[test]
fn element_node_children_works_on_normal_nodes() {
let text = "<html><body><div>text</div></body></html>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse("//div").unwrap();
let result = xp.apply(&doc).unwrap();
if let XpathItem::Node(XpathItemTreeNode::ElementNode(e)) = &result[0] {
let children: Vec<_> = e.children(&doc).collect();
assert!(!children.is_empty(), "children() should return text child");
let texts: Vec<_> = e.itertext(&doc).collect();
assert!(!texts.is_empty(), "itertext() should return text content");
assert_eq!(texts[0], "text");
} else {
panic!("expected element node");
}
}
#[test]
fn replace_rejects_zero_length_pattern() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#"replace("abc", ".*", "x")"#).unwrap();
let result = xp.apply(&doc);
assert!(result.is_err(), "fn:replace with zero-length match pattern should error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("FORX0003"),
"error should reference FORX0003, got: {}",
err
);
}
#[test]
fn tokenize_rejects_zero_length_pattern() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#"tokenize("abc", "")"#).unwrap();
let result = xp.apply(&doc);
assert!(result.is_err(), "fn:tokenize with empty pattern should error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("FORX0003"),
"error should reference FORX0003, got: {}",
err
);
}
#[test]
fn replace_normal_pattern_still_works() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#"replace("aabbb", "b+", "X")"#).unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::String("aaX".to_string()),
);
}
#[test]
fn tokenize_normal_pattern_still_works() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#"tokenize("a,b,c", ",")"#).unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn format_integer_words_large_number() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#"format-integer(1000000, "w")"#).unwrap();
let result = xp.apply(&doc).unwrap();
if let AnyAtomicType::String(s) = result[0].extract_as_any_atomic_type() {
assert!(
s.contains("million"),
"format-integer(1000000, 'w') should produce words, got: {}",
s
);
} else {
panic!("expected string result");
}
}
#[test]
fn format_integer_words_twenty_thousand() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#"format-integer(20000, "w")"#).unwrap();
let result = xp.apply(&doc).unwrap();
if let AnyAtomicType::String(s) = result[0].extract_as_any_atomic_type() {
assert!(
s.contains("thousand"),
"format-integer(20000, 'w') should produce words, got: {}",
s
);
} else {
panic!("expected string result");
}
}
#[test]
fn format_integer_words_small_numbers_unchanged() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#"format-integer(42, "w")"#).unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::String("forty two".to_string()),
);
}
#[test]
fn to_double_non_numeric_returns_nan_not_panic() {
let text = "<r/>";
let doc = html::parse(text).unwrap();
let xp = xpath::parse(r#"1 = "1""#).unwrap();
let result = xp.apply(&doc).unwrap();
assert_eq!(
*result[0].extract_as_any_atomic_type(),
AnyAtomicType::Boolean(true),
"1 = '1' should be true via general comparison coercion"
);
}