From a216a221c283d25f0ee00de2298191f9d053aad0 Mon Sep 17 00:00:00 2001 From: Red Davies Date: Fri, 22 May 2026 03:01:04 -0400 Subject: [PATCH 1/4] Extensive API tests for branches, edge cases, and properties A new test file libxml2/_tests/extensive_tests.pony covering the public API beyond the happy-path basic tests. 14 new tests in five groups: API branch coverage - TestSetRootElementReplacesOldRoot: the "had a previous root" return-value branch of Xml2Doc.setRootElement - TestCreateDocWithCustomVersion: Xml2Doc.create("1.1") and verifying the version appears in serialised output - TestSetPropOverwritesExisting: repeated setProp on same name updates rather than duplicating - TestEmptyAttributeValue: distinguishes from absent Unicode / encoding - TestUnicodeContentRoundTrip: Cyrillic / Greek / CJK / Arabic / emoji content survives parse-serialize-parse - TestUnicodeAttributeValues: non-ASCII attribute values round-trip through setProp / getProp / serialize / parse - TestSerializeUTF16Encoding: UTF-16 output has the right BOM prefix; transcoding actually re-encodes the body bytes rather than relabeling the encoding declaration Parser options interaction - TestParserOptionsCombined: error_recovery + no_blanks applied together; recovery completes despite trailing garbage and element-only content has its indentation stripped XPath function coverage (XPath 1.0) - TestXPathStringFunctionsExtensive: concat, substring, substring-before, substring-after, normalize-space, string-length, contains, starts-with - TestXPathPositionAndLast: position(), last(), last()-1 in predicates; count() returns cardinality - TestXPathNameFunctions: XPath name() / local-name() / namespace-uri() match the Xml2Node namespace accessors Edge cases - TestManyAttributesRoundTrip: 50 attributes on one element survive setProp / serialize / parse / getProp, order-independent Property-based (PonyCheck) - PropSetGetPropRoundTrip: for ASCII-letter names and printable-ASCII values, setProp/getProp is identity - PropAppendChildPreservesCount: getChildren().size() equals appendChild call count, and XPath count(./elem) agrees All 77 tests pass; stability confirmed across 3 consecutive runs. No release note: test-only changes don't get user-facing entries in this project, consistent with prior PRs that added test coverage. --- libxml2/_test.pony | 17 + libxml2/_tests/extensive_tests.pony | 530 ++++++++++++++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 libxml2/_tests/extensive_tests.pony diff --git a/libxml2/_test.pony b/libxml2/_test.pony index 4b6ee30..9f66173 100755 --- a/libxml2/_test.pony +++ b/libxml2/_test.pony @@ -78,6 +78,23 @@ actor \nodoc\ Main is TestList test(TestParseDocNoBlanks) test(TestParseDocErrorRecovery) test(TestParseDocEntitiesNotSubstitutedByDefault) + // Extensive API coverage + test(TestSetRootElementReplacesOldRoot) + test(TestCreateDocWithCustomVersion) + test(TestSetPropOverwritesExisting) + test(TestEmptyAttributeValue) + test(TestUnicodeContentRoundTrip) + test(TestUnicodeAttributeValues) + test(TestSerializeUTF16Encoding) + test(TestParserOptionsCombined) + test(TestXPathStringFunctionsExtensive) + test(TestXPathPositionAndLast) + test(TestXPathNameFunctions) + test(TestManyAttributesRoundTrip) + test(Property1UnitTest[(String, String)]( + recover iso PropSetGetPropRoundTrip end)) + test(Property1UnitTest[USize]( + recover iso PropAppendChildPreservesCount end)) // Crash-resistance fuzz tests (PonyCheck Property1) test(Property1UnitTest[String](recover iso FuzzParseDoc end)) test(Property1UnitTest[(String, String)]( diff --git a/libxml2/_tests/extensive_tests.pony b/libxml2/_tests/extensive_tests.pony new file mode 100644 index 0000000..5eb4d01 --- /dev/null +++ b/libxml2/_tests/extensive_tests.pony @@ -0,0 +1,530 @@ +use "../../libxml2" +use "pony_test" +use "pony_check" +use "files" + +// Extensive coverage of the Pony API beyond the happy-path basic +// tests. Each test below targets either: +// +// * an API branch not exercised by basic_tests / coverage_tests +// (e.g. setRootElement on a doc that already has a root, +// Xml2Doc.create with a non-default XML version) +// * an edge case (empty attribute value, many attributes, very +// deep nesting, unicode in names/values/content) +// * a libxml2 / Pony interaction that's easy to misuse (encoding +// transcoding bytes, XPath functions returning different result +// types, parser options interacting) +// * a property the API claims but doesn't currently assert +// directly (setProp/getProp round-trip, appendChild count) + +// --------------------------------------------------------------- +// API branch coverage +// --------------------------------------------------------------- + +class \nodoc\ iso TestSetRootElementReplacesOldRoot is UnitTest + """ + `Xml2Doc.setRootElement` is documented to return the previous root + element when the document already had one, otherwise the new root. + Existing tests only exercise the "no previous root" branch; this + test covers the "had a root, returns the old one" branch. + """ + fun name(): String => "extensive/set-root-element-replaces" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("first")? + let original_root = doc.getRootElement()? + h.assert_eq[String]("first", original_root.name()) + + // Create a second element and install it as the new root. + let replacement = doc.createElement("second")? + let returned = doc.setRootElement(replacement)? + + // Document's current root should now be "second". + h.assert_eq[String]("second", doc.getRootElement()?.name()) + // Per the docstring, the call returns the old root. + h.assert_eq[String]("first", returned.name()) + else + h.fail("setRootElement-replace branch failed unexpectedly") + end + +class \nodoc\ iso TestCreateDocWithCustomVersion is UnitTest + """ + `Xml2Doc.create(version)` is documented to accept a non-default + XML version string and forward it to libxml2. Verify that the + version string appears in the serialised XML declaration. + """ + fun name(): String => "extensive/create-doc-custom-version" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.create("1.1")? + let root = doc.createElement("root")? + doc.setRootElement(root)? + let serialized = doc.serialize()? + h.assert_true( + serialized.contains("version=\"1.1\""), + "expected version=\"1.1\" in serialised XML, got: " + serialized) + else + h.fail("create with custom version failed") + end + +class \nodoc\ iso TestSetPropOverwritesExisting is UnitTest + """ + Calling `setProp` a second time on the same attribute name should + overwrite the previous value rather than creating a duplicate + attribute. The XML 1.0 spec forbids duplicate attribute names on + the same element and libxml2 enforces this; this test verifies + the Pony binding honours that. + """ + fun name(): String => "extensive/set-prop-overwrites" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + root.setProp("name", "first") + root.setProp("name", "second") + root.setProp("name", "third") + h.assert_eq[String]("third", root.getProp("name")) + // getProps should report the attribute once, not three times. + let props = root.getProps() + h.assert_eq[USize](1, props.size()) + h.assert_eq[String]("name", props(0)?._1) + h.assert_eq[String]("third", props(0)?._2) + else + h.fail("setProp overwrite flow failed") + end + +class \nodoc\ iso TestEmptyAttributeValue is UnitTest + """ + Attributes with empty string values are valid XML + (``) and should round-trip through getProp. + Distinguishes "attribute present with empty value" from "attribute + absent" - both return "" from getProp. + """ + fun name(): String => "extensive/empty-attribute-value" + + fun apply(h: TestHelper) => + match Xml2Parser.parseDoc("") + | let doc: Xml2Doc => + try + let root = doc.getRootElement()? + h.assert_eq[String]("", root.getProp("a")) + h.assert_eq[String]("v", root.getProp("b")) + h.assert_eq[String]("", root.getProp("missing")) + // getProps must report both "a" and "b" even though "a" is empty. + let props = root.getProps() + h.assert_eq[USize](2, props.size()) + else + h.fail("getRootElement on parsed doc failed") + end + | let err: Xml2Error => + h.fail("parse of valid XML failed: " + err.string()) + end + +// --------------------------------------------------------------- +// Unicode / encoding +// --------------------------------------------------------------- + +class \nodoc\ iso TestUnicodeContentRoundTrip is UnitTest + """ + Non-ASCII content in element bodies must survive parse → serialize + → re-parse without corruption. Covers a mix of multi-byte scripts + (Cyrillic, Greek, CJK, Arabic) and supplementary-plane characters + (a single emoji codepoint encoded as four UTF-8 bytes). + """ + fun name(): String => "extensive/unicode-content-roundtrip" + + fun apply(h: TestHelper) => + let xml = + "" + + "Привет, мир" + + "γειά σου" + + "你好世界" + + "مرحبا" + + "🎉" + + "" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + try + let serialized = doc.serialize(false)? + match Xml2Parser.parseDoc(serialized) + | let doc2: Xml2Doc => + let root2 = doc2.getRootElement()? + let kids = root2.getChildren() + h.assert_eq[USize](5, kids.size()) + h.assert_eq[String]("Привет, мир", kids(0)?.getContent()) + h.assert_eq[String]("γειά σου", kids(1)?.getContent()) + h.assert_eq[String]("你好世界", kids(2)?.getContent()) + h.assert_eq[String]("مرحبا", kids(3)?.getContent()) + h.assert_eq[String]("🎉", kids(4)?.getContent()) + | let err: Xml2Error => + h.fail("re-parse of serialised doc failed: " + err.string()) + end + else + h.fail("failed to access nodes") + end + | let err: Xml2Error => + h.fail("initial parse failed: " + err.string()) + end + +class \nodoc\ iso TestUnicodeAttributeValues is UnitTest + """ + Non-ASCII attribute values should round-trip through setProp / + getProp / serialize / parse. + """ + fun name(): String => "extensive/unicode-attribute-values" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + root.setProp("greeting", "Привет") + root.setProp("emoji", "🎉") + root.setProp("mixed", "hello-世界") + + h.assert_eq[String]("Привет", root.getProp("greeting")) + h.assert_eq[String]("🎉", root.getProp("emoji")) + h.assert_eq[String]("hello-世界", root.getProp("mixed")) + + // Round-trip through serialize + parse. + let serialized = doc.serialize(false)? + match Xml2Parser.parseDoc(serialized) + | let doc2: Xml2Doc => + let root2 = doc2.getRootElement()? + h.assert_eq[String]("Привет", root2.getProp("greeting")) + h.assert_eq[String]("🎉", root2.getProp("emoji")) + h.assert_eq[String]("hello-世界", root2.getProp("mixed")) + | let err: Xml2Error => + h.fail("re-parse failed: " + err.string()) + end + else + h.fail("unicode attribute round-trip failed") + end + +class \nodoc\ iso TestSerializeUTF16Encoding is UnitTest + """ + `serialize(_, "UTF-16")` must produce a byte stream that begins + with a UTF-16 BOM and declares `encoding="UTF-16"` in the XML + prolog. The body bytes for non-ASCII content should differ from + the UTF-8 encoding of the same document, proving real transcoding + occurred rather than the encoding declaration being a relabel. + """ + fun name(): String => "extensive/serialize-utf16-encoding" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + let child = doc.createElement("greeting", "Привет")? + root.appendChild(child)? + + let utf8 = doc.serialize(false, "UTF-8")? + let utf16 = doc.serialize(false, "UTF-16")? + + // UTF-16 output must begin with a byte-order mark (FF FE or + // FE FF). Note: we can't substring-match "UTF-16" in the + // output because the encoding declaration itself is encoded + // as UTF-16 bytes ("U" "\0" "T" "\0" ...), so the UTF-8 + // substring "UTF-16" doesn't appear. + let first_byte: U8 = + try utf16.at_offset(0)? else U8(0) end + h.assert_true( + (first_byte == 0xFF) or (first_byte == 0xFE), + "expected UTF-16 BOM (FF FE or FE FF) at start, got byte " + + first_byte.i32().string()) + // The encoded body must NOT contain the UTF-8 byte sequence + // for "Привет" - that would mean no transcoding happened. + h.assert_false( + utf16.contains("Привет"), + "UTF-16 output should not contain UTF-8 bytes for the body") + // UTF-8 output must keep the original bytes verbatim. + h.assert_true( + utf8.contains("Привет"), + "UTF-8 output should contain the literal Cyrillic bytes") + else + h.fail("UTF-16 serialization failed") + end + +// --------------------------------------------------------------- +// Parser options interactions +// --------------------------------------------------------------- + +class \nodoc\ iso TestParserOptionsCombined is UnitTest + """ + Combining `error_recovery` and `no_blanks` must apply both flags: + malformed input is still recovered AND ignorable inter-element + whitespace in element-only content is dropped. Note that + libxml2's `no_blanks` only affects whitespace between elements + that have element-only children, not whitespace adjacent to text + (mixed content). + """ + fun name(): String => "extensive/parser-options-combined" + + fun apply(h: TestHelper) => + // Element-only content with indentation: no_blanks strips the + // whitespace between children. Also slightly malformed (extra + // close tag) to verify recovery still runs. + let malformed = + """ + + 1 + 2 + 3 + """ + let opts = Xml2ParserOptions.create( + where error_recovery' = true, no_blanks' = true) + match Xml2Parser.parseDoc(malformed, opts) + | let doc: Xml2Doc => + try + let root = doc.getRootElement()? + let dump = root.nodeDump(0, 0) + // no_blanks must have dropped the inter-element indentation; + // the dump should be compact 1... with no + // "\n <" sequences. + h.assert_false( + dump.contains("\n <"), + "no_blanks should strip indented whitespace; got: " + dump) + // Recovery must have produced all three children despite + // the trailing garbage. + h.assert_eq[USize](3, root.getChildren().size()) + else + h.fail("post-parse traversal failed") + end + | let err: Xml2Error => + h.fail( + "recovery should have produced a doc; got error: " + + err.string()) + end + +// --------------------------------------------------------------- +// XPath function coverage (libxml2 implements XPath 1.0) +// --------------------------------------------------------------- + +class \nodoc\ iso TestXPathStringFunctionsExtensive is UnitTest + """ + Exercise XPath 1.0 string functions: `concat`, `substring`, + `substring-before`, `substring-after`, `normalize-space`, + `string-length`, `contains`, `starts-with`. Each is wrapped + through `xpathEvalString` and asserted on the expected value. + """ + fun name(): String => "extensive/xpath-string-functions" + + fun apply(h: TestHelper) => + let xml = " hello world " + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + match doc.xpathEvalString("concat('A', '-', 'B', '-', 'C')") + | let s: String val => h.assert_eq[String]("A-B-C", s) + | let _: Xml2Error => h.fail("concat eval failed") + end + match doc.xpathEvalString("substring('hello world', 7)") + | let s: String val => h.assert_eq[String]("world", s) + | let _: Xml2Error => h.fail("substring eval failed") + end + match doc.xpathEvalString("substring-before('foo:bar', ':')") + | let s: String val => h.assert_eq[String]("foo", s) + | let _: Xml2Error => h.fail("substring-before eval failed") + end + match doc.xpathEvalString("substring-after('foo:bar', ':')") + | let s: String val => h.assert_eq[String]("bar", s) + | let _: Xml2Error => h.fail("substring-after eval failed") + end + match doc.xpathEvalString("normalize-space(/r/a)") + | let s: String val => h.assert_eq[String]("hello world", s) + | let _: Xml2Error => h.fail("normalize-space eval failed") + end + match doc.xpathEvalF64("string-length('hello')") + | let f: F64 => h.assert_eq[F64](5.0, f) + | let _: Xml2Error => h.fail("string-length eval failed") + end + match doc.xpathEvalBool("contains('hello world', 'world')") + | let b: Bool => h.assert_eq[Bool](true, b) + | let _: Xml2Error => h.fail("contains eval failed") + end + match doc.xpathEvalBool("starts-with('hello', 'hel')") + | let b: Bool => h.assert_eq[Bool](true, b) + | let _: Xml2Error => h.fail("starts-with eval failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +class \nodoc\ iso TestXPathPositionAndLast is UnitTest + """ + XPath predicates can use `position()` and `last()` to index into a + matched set. Verify positional indexing returns the expected node. + """ + fun name(): String => "extensive/xpath-position-and-last" + + fun apply(h: TestHelper) => + let xml = + "abcde" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + // last() picks the final element. + match doc.xpathEvalString("string(/r/i[last()])") + | let s: String val => h.assert_eq[String]("e", s) + | let _: Xml2Error => h.fail("last() eval failed") + end + // position()=3 picks the third (1-indexed). + match doc.xpathEvalString("string(/r/i[position()=3])") + | let s: String val => h.assert_eq[String]("c", s) + | let _: Xml2Error => h.fail("position()=3 eval failed") + end + // last()-1 picks the penultimate. + match doc.xpathEvalString("string(/r/i[last()-1])") + | let s: String val => h.assert_eq[String]("d", s) + | let _: Xml2Error => h.fail("last()-1 eval failed") + end + // count() returns the cardinality. + match doc.xpathEvalF64("count(/r/i)") + | let f: F64 => h.assert_eq[F64](5.0, f) + | let _: Xml2Error => h.fail("count() eval failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +class \nodoc\ iso TestXPathNameFunctions is UnitTest + """ + XPath `name()`, `local-name()`, and `namespace-uri()` functions + expose the same data as `Xml2Node.name() / namespacePrefix() / + namespaceUri()` via XPath expressions. Test against a document + with namespaced elements. + """ + fun name(): String => "extensive/xpath-name-functions" + + fun apply(h: TestHelper) => + let xml = + "" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + let ns: Array[(String val, String val)] = + [("c", "http://example.com/c")] + // name() returns the qualified name (prefix:local). + match doc.xpathEvalString("name(/r/c:item)", ns) + | let s: String val => h.assert_eq[String]("c:item", s) + | let _: Xml2Error => h.fail("name() eval failed") + end + // local-name() returns just the local part. + match doc.xpathEvalString("local-name(/r/c:item)", ns) + | let s: String val => h.assert_eq[String]("item", s) + | let _: Xml2Error => h.fail("local-name() eval failed") + end + // namespace-uri() returns the URI. + match doc.xpathEvalString("namespace-uri(/r/c:item)", ns) + | let s: String val => + h.assert_eq[String]("http://example.com/c", s) + | let _: Xml2Error => h.fail("namespace-uri() eval failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +// --------------------------------------------------------------- +// Edge cases on data volume +// --------------------------------------------------------------- + +class \nodoc\ iso TestManyAttributesRoundTrip is UnitTest + """ + A single element with many attributes (50) must round-trip + through setProp / serialize / parse / getProp with all values + preserved. XML doesn't guarantee attribute order across parse + + serialize so the test is order-independent. + """ + fun name(): String => "extensive/many-attributes-roundtrip" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + var i: USize = 0 + while i < 50 do + root.setProp("a" + i.string(), "v" + i.string()) + i = i + 1 + end + + let serialized = doc.serialize(false)? + match Xml2Parser.parseDoc(serialized) + | let doc2: Xml2Doc => + let root2 = doc2.getRootElement()? + i = 0 + while i < 50 do + h.assert_eq[String]( + "v" + i.string(), + root2.getProp("a" + i.string())) + i = i + 1 + end + // getProps must report all 50 attributes. + h.assert_eq[USize](50, root2.getProps().size()) + | let err: Xml2Error => + h.fail("re-parse failed: " + err.string()) + end + else + h.fail("many-attribute round-trip failed") + end + +// --------------------------------------------------------------- +// Property-based round-trips (PonyCheck) +// --------------------------------------------------------------- + +class \nodoc\ iso PropSetGetPropRoundTrip is Property1[(String, String)] + """ + For any valid attribute name (ASCII letters, length 1-32) and any + printable-ASCII value (length 0-128), the following identity must + hold: + + setProp(name, value); getProp(name) == value + + Restricts inputs to character classes libxml2 accepts without + normalisation. Stronger generators (allowing NUL, control bytes, + multibyte unicode) would surface additional concerns (NUL + truncation, name validation rejection) that other tests cover. + """ + fun name(): String => "extensive/prop-set-get-roundtrip/property" + + fun gen(): Generator[(String, String)] => + Generators.zip2[String, String]( + Generators.ascii_letters(1, 32), + Generators.ascii_printable(0, 128)) + + fun ref property(arg1: (String, String), h: PropertyHelper) => + (let n, let v) = arg1 + h.assert_no_error({() ? => + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + root.setProp(n, v) + h.assert_eq[String](v, root.getProp(n)) + } box) + +class \nodoc\ iso PropAppendChildPreservesCount is Property1[USize] + """ + Appending N children to a fresh root element must result in + `getChildren().size() == N`. Also verifies the XPath + `count(./elem)` returns the same value, confirming both + navigation paths agree. + """ + fun name(): String => + "extensive/append-child-preserves-count/property" + + fun gen(): Generator[USize] => + Generators.usize(where min = USize(0), max = USize(50)) + + fun ref property(arg1: USize, h: PropertyHelper) => + h.assert_no_error({() ? => + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + var i: USize = 0 + while i < arg1 do + let child = doc.createElement("item")? + root.appendChild(child)? + i = i + 1 + end + h.assert_eq[USize](arg1, root.getChildren().size()) + match doc.xpathEvalF64("count(/root/item)") + | let f: F64 => h.assert_eq[F64](arg1.f64(), f) + | let _: Xml2Error => h.fail("count() XPath failed") + end + } box) From 0dd6ad0bda6c79a4907bf577db912cab6431198b Mon Sep 17 00:00:00 2001 From: Red Davies Date: Fri, 22 May 2026 03:07:12 -0400 Subject: [PATCH 2/4] Second batch of extensive API tests 15 more tests in libxml2/_tests/extensive_tests.pony, covering XPath features, parser/serialiser semantics, mutation behaviour, inheritance, and boundary cases. XPath features - TestXPathAxes: descendant, ancestor, parent, following-sibling, preceding-sibling axes exercised on a fixed tree - TestXPathNumberFunctions: sum, floor, ceiling, round, number - TestXPathBooleanFunctions: true, false, not, boolean - TestXPathAttributePredicates: existence, equality, inequality, negation, wildcard predicates on attributes Parser / serialiser semantics - TestCDATAContentPreserved: content inside reaches getContent verbatim - TestSelfClosingEquivalence: and produce equivalent trees (same children count, same name, same content) - TestCommentRoundTrip: survives both a single serialise pass and a parse-then-serialise round-trip Mutation semantics - TestSetContentReplacesChildren: setContent on an element with existing element children replaces them with the new text - TestSetUnsetGetPropEmpty: setProp + unsetProp leaves the attribute absent (getProp returns "", getProps reports zero entries) Boundary / volume - TestLongNamesAndValues: 256-char element names and 4096-char attribute values round-trip cleanly Inheritance - TestGetLangNestedScopes: xml:lang declarations are inherited through ancestors and overridden by nested redeclarations I/O options - TestSaveToFileWithFormatAndEncoding: saveToFile(auth, path, format=true, encoding="ISO-8859-1") produces a formatted file with the requested encoding declaration Property-based - PropStructuralRoundTripStable: parsing a doc built from a generated array of element names, then running 3 parse-serialise cycles, the child count and per-position names stay identical - PropGetPropsCardinality: setting N distinct-named attributes produces exactly N getProps entries - PropSetUnsetIsEmpty: setProp(n,v) + unsetProp(n) is observably equivalent to never having set the attribute All 92 tests pass; stability confirmed across 3 consecutive runs. No release note: test-only change. --- libxml2/_test.pony | 19 + libxml2/_tests/extensive_tests.pony | 582 ++++++++++++++++++++++++++++ 2 files changed, 601 insertions(+) diff --git a/libxml2/_test.pony b/libxml2/_test.pony index 9f66173..ea729e4 100755 --- a/libxml2/_test.pony +++ b/libxml2/_test.pony @@ -95,6 +95,25 @@ actor \nodoc\ Main is TestList recover iso PropSetGetPropRoundTrip end)) test(Property1UnitTest[USize]( recover iso PropAppendChildPreservesCount end)) + // Extensive API coverage (batch 2) + test(TestXPathAxes) + test(TestXPathNumberFunctions) + test(TestXPathBooleanFunctions) + test(TestXPathAttributePredicates) + test(TestCDATAContentPreserved) + test(TestSelfClosingEquivalence) + test(TestCommentRoundTrip) + test(TestSetContentReplacesChildren) + test(TestSetUnsetGetPropEmpty) + test(TestLongNamesAndValues) + test(TestGetLangNestedScopes) + test(TestSaveToFileWithFormatAndEncoding) + test(Property1UnitTest[Array[String]]( + recover iso PropStructuralRoundTripStable end)) + test(Property1UnitTest[USize]( + recover iso PropGetPropsCardinality end)) + test(Property1UnitTest[(String, String)]( + recover iso PropSetUnsetIsEmpty end)) // Crash-resistance fuzz tests (PonyCheck Property1) test(Property1UnitTest[String](recover iso FuzzParseDoc end)) test(Property1UnitTest[(String, String)]( diff --git a/libxml2/_tests/extensive_tests.pony b/libxml2/_tests/extensive_tests.pony index 5eb4d01..30806d2 100644 --- a/libxml2/_tests/extensive_tests.pony +++ b/libxml2/_tests/extensive_tests.pony @@ -528,3 +528,585 @@ class \nodoc\ iso PropAppendChildPreservesCount is Property1[USize] | let _: Xml2Error => h.fail("count() XPath failed") end } box) + +// --------------------------------------------------------------- +// XPath axes coverage +// --------------------------------------------------------------- + +class \nodoc\ iso TestXPathAxes is UnitTest + """ + Walking the XPath tree with non-default axes: descendant, + ancestor, parent, self, following-sibling, preceding-sibling. + These produce different node sets than the default child axis + and exercise libxml2's traversal beyond simple path expressions. + """ + fun name(): String => "extensive/xpath-axes" + + fun apply(h: TestHelper) => + let xml = "" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + // descendant axis from /r picks up every element below. + match doc.xpathEvalNodes("/r/descendant::*") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](4, nodes.size()) + | let _: Xml2Error => h.fail("descendant axis failed") + end + // ancestor of /r//d is r and c. + match doc.xpathEvalNodes("//d/ancestor::*") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](2, nodes.size()) + | let _: Xml2Error => h.fail("ancestor axis failed") + end + // following-sibling of /r/a is c. + match doc.xpathEvalNodes("/r/a/following-sibling::*") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](1, nodes.size()) + try h.assert_eq[String]("c", nodes(0)?.name()) end + | let _: Xml2Error => h.fail("following-sibling axis failed") + end + // preceding-sibling of /r/c is a. + match doc.xpathEvalNodes("/r/c/preceding-sibling::*") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](1, nodes.size()) + try h.assert_eq[String]("a", nodes(0)?.name()) end + | let _: Xml2Error => h.fail("preceding-sibling axis failed") + end + // parent of /r//b is a. + match doc.xpathEvalNodes("//b/parent::*") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](1, nodes.size()) + try h.assert_eq[String]("a", nodes(0)?.name()) end + | let _: Xml2Error => h.fail("parent axis failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +class \nodoc\ iso TestXPathNumberFunctions is UnitTest + """ + XPath 1.0 number functions: sum, floor, ceiling, round, number. + Exercised through xpathEvalF64. + """ + fun name(): String => "extensive/xpath-number-functions" + + fun apply(h: TestHelper) => + let xml = + "" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + match doc.xpathEvalF64("sum(/r/i/@p)") + | let f: F64 => h.assert_eq[F64](10.0, f) + | let _: Xml2Error => h.fail("sum() failed") + end + match doc.xpathEvalF64("floor(2.7)") + | let f: F64 => h.assert_eq[F64](2.0, f) + | let _: Xml2Error => h.fail("floor() failed") + end + match doc.xpathEvalF64("ceiling(2.3)") + | let f: F64 => h.assert_eq[F64](3.0, f) + | let _: Xml2Error => h.fail("ceiling() failed") + end + match doc.xpathEvalF64("round(2.5)") + | let f: F64 => h.assert_eq[F64](3.0, f) + | let _: Xml2Error => h.fail("round() failed") + end + match doc.xpathEvalF64("number('42')") + | let f: F64 => h.assert_eq[F64](42.0, f) + | let _: Xml2Error => h.fail("number() failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +class \nodoc\ iso TestXPathBooleanFunctions is UnitTest + """ + XPath 1.0 boolean functions: true, false, not, boolean. + """ + fun name(): String => "extensive/xpath-boolean-functions" + + fun apply(h: TestHelper) => + match Xml2Parser.parseDoc("") + | let doc: Xml2Doc => + match doc.xpathEvalBool("true()") + | let b: Bool => h.assert_eq[Bool](true, b) + | let _: Xml2Error => h.fail("true() failed") + end + match doc.xpathEvalBool("false()") + | let b: Bool => h.assert_eq[Bool](false, b) + | let _: Xml2Error => h.fail("false() failed") + end + match doc.xpathEvalBool("not(true())") + | let b: Bool => h.assert_eq[Bool](false, b) + | let _: Xml2Error => h.fail("not() failed") + end + match doc.xpathEvalBool("boolean('non-empty')") + | let b: Bool => h.assert_eq[Bool](true, b) + | let _: Xml2Error => h.fail("boolean('...') failed") + end + match doc.xpathEvalBool("boolean('')") + | let b: Bool => h.assert_eq[Bool](false, b) + | let _: Xml2Error => h.fail("boolean('') failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +class \nodoc\ iso TestXPathAttributePredicates is UnitTest + """ + Attribute predicates: existence (`@id`), equality (`@id='x'`), + inequality (`@n != 0`), negation (`not(@id)`), wildcard + (`@*`). These are the most common XPath patterns in practice + and exercise libxml2's attribute-node handling. + """ + fun name(): String => "extensive/xpath-attribute-predicates" + + fun apply(h: TestHelper) => + let xml = + "" + + "" + + "" + + "" + + "" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + // Existence predicate: two of three have id. + match doc.xpathEvalNodes("//i[@id]") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](2, nodes.size()) + | let _: Xml2Error => h.fail("@id existence failed") + end + // Equality predicate: one match. + match doc.xpathEvalNodes("//i[@id='2']") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](1, nodes.size()) + | let _: Xml2Error => h.fail("@id='2' failed") + end + // Inequality + numeric comparison: one match (n=5, not n=0). + match doc.xpathEvalNodes("//i[@n != 0]") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](1, nodes.size()) + | let _: Xml2Error => h.fail("@n != 0 failed") + end + // Negation: the has no @id. + match doc.xpathEvalNodes("//i[not(@id)]") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](1, nodes.size()) + | let _: Xml2Error => h.fail("not(@id) failed") + end + // Wildcard: every with at least one attribute (all three). + match doc.xpathEvalNodes("//i[@*]") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](3, nodes.size()) + | let _: Xml2Error => h.fail("@* failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +// --------------------------------------------------------------- +// XML parser / serialiser semantics +// --------------------------------------------------------------- + +class \nodoc\ iso TestCDATAContentPreserved is UnitTest + """ + Content inside a `` section must reach getContent + intact (regardless of whether libxml2 represents it as a CDATA + node or merges it into adjacent text). The point is the *value* + survives, not the representation. + """ + fun name(): String => "extensive/cdata-content-preserved" + + fun apply(h: TestHelper) => + let xml = " & raw chars]]>" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + try + let root = doc.getRootElement()? + h.assert_eq[String]( + " & raw chars", + root.getContent()) + else + h.fail("getRootElement failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +class \nodoc\ iso TestSelfClosingEquivalence is UnitTest + """ + `` and `` are semantically identical per XML 1.0 and + must parse to equivalent trees: same parent count, same children + count (zero), same name, same content (empty). + """ + fun name(): String => "extensive/self-closing-equivalence" + + fun apply(h: TestHelper) => + let xml_short = "" + let xml_long = "" + match Xml2Parser.parseDoc(xml_short) + | let doc1: Xml2Doc => + match Xml2Parser.parseDoc(xml_long) + | let doc2: Xml2Doc => + try + let r1 = doc1.getRootElement()? + let r2 = doc2.getRootElement()? + h.assert_eq[USize](1, r1.getChildren().size()) + h.assert_eq[USize](1, r2.getChildren().size()) + let a1 = r1.getChildren()(0)? + let a2 = r2.getChildren()(0)? + h.assert_eq[String]("a", a1.name()) + h.assert_eq[String]("a", a2.name()) + h.assert_eq[USize](0, a1.getChildren().size()) + h.assert_eq[USize](0, a2.getChildren().size()) + h.assert_eq[String]("", a1.getContent()) + h.assert_eq[String]("", a2.getContent()) + else + h.fail("self-closing equivalence traversal failed") + end + | let err: Xml2Error => + h.fail("long form parse failed: " + err.string()) + end + | let err: Xml2Error => + h.fail("short form parse failed: " + err.string()) + end + +class \nodoc\ iso TestCommentRoundTrip is UnitTest + """ + Comments are preserved by parse → serialize. After re-parsing + the serialised output, the comment text must still be visible + in another serialise pass. (libxml2 keeps comment nodes in the + tree even though `getChildren` filters them out.) + """ + fun name(): String => "extensive/comment-roundtrip" + + fun apply(h: TestHelper) => + let xml = "" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + try + let s1 = doc.serialize(false)? + h.assert_true( + s1.contains(""), + "first serialize must preserve comment, got: " + s1) + match Xml2Parser.parseDoc(s1) + | let doc2: Xml2Doc => + let s2 = doc2.serialize(false)? + h.assert_true( + s2.contains(""), + "second serialize must still preserve comment, got: " + s2) + | let err: Xml2Error => + h.fail("re-parse failed: " + err.string()) + end + else + h.fail("serialize failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +// --------------------------------------------------------------- +// Mutation semantics +// --------------------------------------------------------------- + +class \nodoc\ iso TestSetContentReplacesChildren is UnitTest + """ + `Xml2Node.setContent` is documented to set the text content of a + node. On an element that already has element children, libxml2's + `xmlNodeSetContent` removes the existing children and installs + the new text. Verify both effects: + + - After setContent, getChildren returns no element children. + - After setContent, getContent returns the new text. + """ + fun name(): String => "extensive/set-content-replaces-children" + + fun apply(h: TestHelper) => + match Xml2Parser.parseDoc("") + | let doc: Xml2Doc => + try + let root = doc.getRootElement()? + h.assert_eq[USize](3, root.getChildren().size()) + root.setContent("plain text") + h.assert_eq[USize](0, root.getChildren().size()) + h.assert_eq[String]("plain text", root.getContent()) + else + h.fail("traversal after setContent failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +class \nodoc\ iso TestSetUnsetGetPropEmpty is UnitTest + """ + setProp followed by unsetProp leaves the attribute absent; + getProp on the absent attribute returns the empty string, and + the call must not raise. + """ + fun name(): String => "extensive/set-unset-get-prop-empty" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + root.setProp("flag", "on") + h.assert_eq[String]("on", root.getProp("flag")) + root.unsetProp("flag") + h.assert_eq[String]("", root.getProp("flag")) + h.assert_eq[USize](0, root.getProps().size()) + else + h.fail("setProp / unsetProp flow failed") + end + +// --------------------------------------------------------------- +// Volume / boundary +// --------------------------------------------------------------- + +class \nodoc\ iso TestLongNamesAndValues is UnitTest + """ + Element names of 256 characters and attribute values of 4096 + characters round-trip cleanly. libxml2 has internal buffer + limits but neither of these should exceed them. + """ + fun name(): String => "extensive/long-names-and-values" + + fun apply(h: TestHelper) => + // Build a 256-char element name and a 4096-char value. + let long_name: String iso = recover iso String end + var i: USize = 0 + while i < 256 do + long_name.push('a') + i = i + 1 + end + let stable_name: String val = consume long_name + let long_value: String iso = recover iso String end + var j: USize = 0 + while j < 4096 do + long_value.push('v') + j = j + 1 + end + let stable_value: String val = consume long_value + + try + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + let child = doc.createElement(stable_name)? + child.setProp("data", stable_value) + root.appendChild(child)? + + let serialized = doc.serialize(false)? + match Xml2Parser.parseDoc(serialized) + | let doc2: Xml2Doc => + let r2 = doc2.getRootElement()? + let c2 = r2.getChildren()(0)? + h.assert_eq[String](stable_name, c2.name()) + h.assert_eq[String](stable_value, c2.getProp("data")) + | let err: Xml2Error => + h.fail("re-parse of long-name doc failed: " + err.string()) + end + else + h.fail("long name/value construction failed") + end + +// --------------------------------------------------------------- +// getLang inheritance semantics +// --------------------------------------------------------------- + +class \nodoc\ iso TestGetLangNestedScopes is UnitTest + """ + `xml:lang` declarations are inherited down the tree by default + and overridden by a nested `xml:lang`. Verify each child reports + the correct in-scope language and that explicit overrides take + precedence over the inherited value. + """ + fun name(): String => "extensive/get-lang-nested-scopes" + + fun apply(h: TestHelper) => + let xml = + """ + + + + + + + + + + + """ + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + try + let r = doc.getRootElement()? + h.assert_eq[String]("en", r.getLang()) + let kids = r.getChildren() + let a = kids(0)? + let c = kids(1)? + h.assert_eq[String]("en", a.getLang()) + h.assert_eq[String]("fr", c.getLang()) + let b = a.getChildren()(0)? + let d = c.getChildren()(0)? + let e = d.getChildren()(0)? + // b inherits en from root. + h.assert_eq[String]("en", b.getLang()) + // d inherits fr from c. + h.assert_eq[String]("fr", d.getLang()) + // e overrides with ja. + h.assert_eq[String]("ja", e.getLang()) + else + h.fail("nested xml:lang traversal failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +// --------------------------------------------------------------- +// saveToFile interaction with format and encoding +// --------------------------------------------------------------- + +class \nodoc\ iso TestSaveToFileWithFormatAndEncoding is UnitTest + """ + `saveToFile` accepts format and encoding parameters. Verify the + output file is formatted (indented) when format=true and that + the encoding declaration matches when encoding is specified. + """ + fun name(): String => "extensive/save-to-file-format-encoding" + + fun apply(h: TestHelper) => + let path = "/tmp/pony_libxml2_extensive_test.xml" + try + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + let child = doc.createElement("child", "hello")? + root.appendChild(child)? + + let auth = FileAuth(h.env.root) + doc.saveToFile(auth, path, true, "ISO-8859-1")? + + // Read the file back and inspect. + let fp = FilePath(auth, path) + match OpenFile(fp) + | let f: File => + let bytes = f.read(8192) + let content: String val = String.from_iso_array(consume bytes) + f.dispose() + fp.remove() + // Formatted output has newlines + indentation between root + // and child. + h.assert_true( + content.contains("\n + "extensive/structural-roundtrip-stable/property" + + fun gen(): Generator[Array[String]] => + Generators.seq_of[String, Array[String]]( + Generators.ascii_letters(1, 16), 0, 8) + + fun ref property(arg1: Array[String], h: PropertyHelper) => + h.assert_no_error({() ? => + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + for nm in arg1.values() do + let c = doc.createElement(nm)? + root.appendChild(c)? + end + // Round-trip three times; assert structure on each cycle. + var current: String val = doc.serialize(false)? + var i: USize = 0 + while i < 3 do + match Xml2Parser.parseDoc(current) + | let d: Xml2Doc => + let r = d.getRootElement()? + h.assert_eq[USize](arg1.size(), r.getChildren().size()) + var j: USize = 0 + while j < arg1.size() do + h.assert_eq[String]( + arg1(j)?, r.getChildren()(j)?.name()) + j = j + 1 + end + current = d.serialize(false)? + | let _: Xml2Error => + h.fail("re-parse in roundtrip chain failed") + return + end + i = i + 1 + end + } box) + +class \nodoc\ iso PropGetPropsCardinality is Property1[USize] + """ + Setting N attributes with distinct names on a root element must + yield exactly N entries in `getProps()`. Names are generated from + the iteration index (a0, a1, ...) to guarantee uniqueness; the + property exercises the cardinality invariant rather than name + validity. + """ + fun name(): String => "extensive/getprops-cardinality/property" + + fun gen(): Generator[USize] => + Generators.usize(where min = USize(0), max = USize(40)) + + fun ref property(arg1: USize, h: PropertyHelper) => + h.assert_no_error({() ? => + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + var i: USize = 0 + while i < arg1 do + root.setProp("a" + i.string(), "v" + i.string()) + i = i + 1 + end + h.assert_eq[USize](arg1, root.getProps().size()) + } box) + +class \nodoc\ iso PropSetUnsetIsEmpty is Property1[(String, String)] + """ + For any valid attribute name and printable-ASCII value, the + sequence setProp(n,v); unsetProp(n) must leave the attribute + absent (getProp returns empty string, getProps reports zero + entries). + """ + fun name(): String => "extensive/set-unset-is-empty/property" + + fun gen(): Generator[(String, String)] => + Generators.zip2[String, String]( + Generators.ascii_letters(1, 32), + Generators.ascii_printable(0, 64)) + + fun ref property(arg1: (String, String), h: PropertyHelper) => + (let n, let v) = arg1 + h.assert_no_error({() ? => + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + root.setProp(n, v) + root.unsetProp(n) + h.assert_eq[String]("", root.getProp(n)) + h.assert_eq[USize](0, root.getProps().size()) + } box) From 7d926e8b1dfc586881dde2c9c7eaf3e9c64842ac Mon Sep 17 00:00:00 2001 From: Red Davies Date: Fri, 22 May 2026 03:23:06 -0400 Subject: [PATCH 3/4] Third batch of extensive API tests + larger property batch sizes Adds 10 new unit tests and bumps all property-based tests' num_samples from 100 (PonyCheck default) to 500. Two property generators also gained wider input ranges (usize 0-50 -> 0-200, seq_of length 0-8 -> 0-32) for deeper coverage of larger inputs. Tree mutation - TestAppendChildPreservesOrder: appendChild on multiple new children preserves insertion order; XPath positional indexing agrees. (Re-parenting an existing child via appendChild is not supported in the current API - xmlAddChild does not detach from the previous parent, which would require an xmlUnlinkNode call the binding doesn't expose. The test docstring notes this limitation.) Parser surface - TestDoctypeParsing: documents with DOCTYPE declarations parse, navigate, and round-trip through serialise - TestEmptyDocumentVariants: and parse; XML declaration alone, whitespace-only, and empty string do not - TestXPathOnDocWithoutRoot: Xml2Doc.create() with no root element accepts XPath evaluation without crashing - TestLoadDtdFlagsAcceptValidInput: load_dtd + load_dtd_attrs options on a doc with internal DTD subset parse cleanly and preserve the DOCTYPE through serialise (DTD-default-attribute application behaviour is libxml2-version-specific and is intentionally not asserted) - TestPedanticAcceptsValidInput: pedantic = true does not reject standards-compliant XML (regression guard) Path / namespace - TestDeepXPathExpression: 20-level-deep tree built one appendChild at a time; absolute path and descendant-axis queries both find the deepest node - TestXmlSpaceAttributePreserved: xml:space attribute is in the implicit XML namespace and is readable via getPropNs(XML_NS, "space"); also survives serialise - TestNamespacePrefixShadowing: a nested xmlns:x declaration shadows the outer binding for that subtree; namespaceUri() reports the in-scope URI at each level while namespacePrefix() consistently returns the source-level prefix Error paths - TestFromPTRRejectsNull: Xml2Node.fromPTR with a None pointer raises rather than silently producing a wrapper around null Property-based budget changes - PropSetGetPropRoundTrip: 100 -> 500 samples - PropAppendChildPreservesCount: 100 -> 500 samples, max 200 - PropStructuralRoundTripStable: 100 -> 500 samples, max len 32 - PropGetPropsCardinality: 100 -> 500 samples, max 200 - PropSetUnsetIsEmpty: 100 -> 500 samples All 102 tests pass; stability confirmed across 3 consecutive runs (each running 5 property tests x 500 samples = 2500 property iterations per run on top of the unit tests). No release note: test-only change. --- libxml2/_test.pony | 11 + libxml2/_tests/extensive_tests.pony | 424 +++++++++++++++++++++++++++- 2 files changed, 432 insertions(+), 3 deletions(-) diff --git a/libxml2/_test.pony b/libxml2/_test.pony index ea729e4..761b300 100755 --- a/libxml2/_test.pony +++ b/libxml2/_test.pony @@ -114,6 +114,17 @@ actor \nodoc\ Main is TestList recover iso PropGetPropsCardinality end)) test(Property1UnitTest[(String, String)]( recover iso PropSetUnsetIsEmpty end)) + // Extensive API coverage (batch 3) + test(TestAppendChildPreservesOrder) + test(TestDoctypeParsing) + test(TestEmptyDocumentVariants) + test(TestXPathOnDocWithoutRoot) + test(TestLoadDtdFlagsAcceptValidInput) + test(TestPedanticAcceptsValidInput) + test(TestDeepXPathExpression) + test(TestXmlSpaceAttributePreserved) + test(TestNamespacePrefixShadowing) + test(TestFromPTRRejectsNull) // Crash-resistance fuzz tests (PonyCheck Property1) test(Property1UnitTest[String](recover iso FuzzParseDoc end)) test(Property1UnitTest[(String, String)]( diff --git a/libxml2/_tests/extensive_tests.pony b/libxml2/_tests/extensive_tests.pony index 30806d2..fa2790e 100644 --- a/libxml2/_tests/extensive_tests.pony +++ b/libxml2/_tests/extensive_tests.pony @@ -1,4 +1,5 @@ use "../../libxml2" +use "../../libxml2/raw" use "pony_test" use "pony_check" use "files" @@ -485,6 +486,9 @@ class \nodoc\ iso PropSetGetPropRoundTrip is Property1[(String, String)] """ fun name(): String => "extensive/prop-set-get-roundtrip/property" + fun params(): PropertyParams => + PropertyParams(where num_samples' = 500) + fun gen(): Generator[(String, String)] => Generators.zip2[String, String]( Generators.ascii_letters(1, 32), @@ -509,8 +513,11 @@ class \nodoc\ iso PropAppendChildPreservesCount is Property1[USize] fun name(): String => "extensive/append-child-preserves-count/property" + fun params(): PropertyParams => + PropertyParams(where num_samples' = 500) + fun gen(): Generator[USize] => - Generators.usize(where min = USize(0), max = USize(50)) + Generators.usize(where min = USize(0), max = USize(200)) fun ref property(arg1: USize, h: PropertyHelper) => h.assert_no_error({() ? => @@ -1026,9 +1033,12 @@ class \nodoc\ iso PropStructuralRoundTripStable is Property1[Array[String]] fun name(): String => "extensive/structural-roundtrip-stable/property" + fun params(): PropertyParams => + PropertyParams(where num_samples' = 500) + fun gen(): Generator[Array[String]] => Generators.seq_of[String, Array[String]]( - Generators.ascii_letters(1, 16), 0, 8) + Generators.ascii_letters(1, 16), 0, 32) fun ref property(arg1: Array[String], h: PropertyHelper) => h.assert_no_error({() ? => @@ -1071,8 +1081,11 @@ class \nodoc\ iso PropGetPropsCardinality is Property1[USize] """ fun name(): String => "extensive/getprops-cardinality/property" + fun params(): PropertyParams => + PropertyParams(where num_samples' = 500) + fun gen(): Generator[USize] => - Generators.usize(where min = USize(0), max = USize(40)) + Generators.usize(where min = USize(0), max = USize(200)) fun ref property(arg1: USize, h: PropertyHelper) => h.assert_no_error({() ? => @@ -1095,6 +1108,9 @@ class \nodoc\ iso PropSetUnsetIsEmpty is Property1[(String, String)] """ fun name(): String => "extensive/set-unset-is-empty/property" + fun params(): PropertyParams => + PropertyParams(where num_samples' = 500) + fun gen(): Generator[(String, String)] => Generators.zip2[String, String]( Generators.ascii_letters(1, 32), @@ -1110,3 +1126,405 @@ class \nodoc\ iso PropSetUnsetIsEmpty is Property1[(String, String)] h.assert_eq[String]("", root.getProp(n)) h.assert_eq[USize](0, root.getProps().size()) } box) + +// --------------------------------------------------------------- +// Tree mutation: re-parenting +// --------------------------------------------------------------- + +class \nodoc\ iso TestAppendChildPreservesOrder is UnitTest + """ + Multiple `appendChild` calls preserve insertion order: the + resulting `getChildren()` returns nodes in the order they were + appended, and an XPath positional query agrees. + + (Note: re-parenting an existing child via `appendChild` is not + well-defined in the current API - `xmlAddChild` does not detach + the node from its previous parent, which would require an + explicit `xmlUnlinkNode` call this binding doesn't expose. This + test focuses on the supported case of building a tree from new + nodes.) + """ + fun name(): String => "extensive/append-child-preserves-order" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + let names = ["alpha"; "beta"; "gamma"; "delta"; "epsilon"] + for nm in names.values() do + let c = doc.createElement(nm)? + root.appendChild(c)? + end + let kids = root.getChildren() + h.assert_eq[USize](names.size(), kids.size()) + var i: USize = 0 + while i < names.size() do + h.assert_eq[String](names(i)?, kids(i)?.name()) + i = i + 1 + end + // XPath positional agreement: /root/*[3] is the third child. + match doc.xpathEvalString("name(/root/*[3])") + | let s: String val => h.assert_eq[String]("gamma", s) + | let _: Xml2Error => h.fail("positional XPath failed") + end + else + h.fail("appendChild order test failed") + end + +// --------------------------------------------------------------- +// DOCTYPE handling +// --------------------------------------------------------------- + +class \nodoc\ iso TestDoctypeParsing is UnitTest + """ + Documents with a DOCTYPE declaration parse successfully and the + declaration is preserved through serialise/parse round-trips. The + Pony API doesn't yet expose DTD-specific accessors, but the doc + must remain navigable. + """ + fun name(): String => "extensive/doctype-parsing" + + fun apply(h: TestHelper) => + let html_like = + "

hi

" + match Xml2Parser.parseDoc(html_like) + | let doc: Xml2Doc => + try + let root = doc.getRootElement()? + h.assert_eq[String]("html", root.name()) + // The body and p must be navigable. + let body = root.getChildren()(0)? + h.assert_eq[String]("body", body.name()) + h.assert_eq[String]("p", body.getChildren()(0)?.name()) + // Round-trip preserves the DOCTYPE in serialised output. + let s = doc.serialize(false)? + h.assert_true( + s.contains(" + h.fail("DOCTYPE parse failed: " + err.string()) + end + +// --------------------------------------------------------------- +// Empty / minimal document edge cases +// --------------------------------------------------------------- + +class \nodoc\ iso TestEmptyDocumentVariants is UnitTest + """ + Distinguish what parses from what doesn't at the boundary of + "empty": + + - `` - valid empty element + - `` - valid empty element (long) + - `` - declaration with no root, + should be rejected + - ` ` (whitespace only) - should be rejected + - `""` (empty string) - should be rejected + """ + fun name(): String => "extensive/empty-document-variants" + + fun apply(h: TestHelper) => + // Valid forms succeed. + match Xml2Parser.parseDoc("") + | let _: Xml2Doc => None + | let err: Xml2Error => + h.fail(" should parse, got: " + err.string()) + end + match Xml2Parser.parseDoc("") + | let _: Xml2Doc => None + | let err: Xml2Error => + h.fail(" should parse, got: " + err.string()) + end + // Invalid forms produce an error rather than a doc. + match Xml2Parser.parseDoc("") + | let _: Xml2Doc => + h.fail("XML declaration alone should not produce a doc") + | let _: Xml2Error => None + end + match Xml2Parser.parseDoc(" ") + | let _: Xml2Doc => h.fail("whitespace-only should not parse") + | let _: Xml2Error => None + end + match Xml2Parser.parseDoc("") + | let _: Xml2Doc => h.fail("empty string should not parse") + | let _: Xml2Error => None + end + +// --------------------------------------------------------------- +// XPath against a doc with no root +// --------------------------------------------------------------- + +class \nodoc\ iso TestXPathOnDocWithoutRoot is UnitTest + """ + `Xml2Doc.create()` produces a doc with no root element. XPath + evaluation against such a doc must not crash. Both `xpathEval` + (non-partial) and the typed convenience methods are exercised. + """ + fun name(): String => "extensive/xpath-on-doc-without-root" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.create()? + // Bare-result form: any of the union variants is acceptable + // (None / empty array / Xml2Error). The important thing is + // no crash. + let _ = doc.xpathEval("//anything") + // Typed-nodeset form: with no root, "//anything" matches + // nothing. Should return an empty Array[Xml2Node] (libxml2 + // succeeds with a nodeset of size 0). + match doc.xpathEvalNodes("//anything") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](0, nodes.size()) + | let _: Xml2Error => + // Acceptable: some libxml2 versions may error rather than + // returning an empty set; either is non-crashing. + None + end + else + h.fail("Xml2Doc.create / xpath on empty doc failed") + end + +// --------------------------------------------------------------- +// Parser flags: DTD attribute defaulting +// --------------------------------------------------------------- + +class \nodoc\ iso TestLoadDtdFlagsAcceptValidInput is UnitTest + """ + Documents containing an internal DTD subset must parse with the + load_dtd and load_dtd_attrs options set without error, and the + document tree must be usable after parsing. (Whether default + attributes from inline ATTLIST declarations are applied to + matching elements depends on libxml2's validation pass and is + not asserted here.) + """ + fun name(): String => "extensive/load-dtd-flags-accept-valid" + + fun apply(h: TestHelper) => + let xml = + "]>" + + "" + let opts = Xml2ParserOptions.create( + where load_dtd' = true, load_dtd_attrs' = true) + match Xml2Parser.parseDoc(xml, opts) + | let doc: Xml2Doc => + try + let r = doc.getRootElement()? + h.assert_eq[String]("r", r.name()) + h.assert_eq[USize](1, r.getChildren().size()) + h.assert_eq[String]("child", r.getChildren()(0)?.name()) + // Round-trip preserves the DOCTYPE. + let s = doc.serialize(false)? + h.assert_true( + s.contains(" + h.fail( + "load_dtd options should accept valid input; got error: " + + err.string()) + end + +// --------------------------------------------------------------- +// Pedantic mode regression +// --------------------------------------------------------------- + +class \nodoc\ iso TestPedanticAcceptsValidInput is UnitTest + """ + `pedantic = true` enables stricter warning reporting but must not + reject standards-compliant XML. This test guards against a + regression where pedantic mode accidentally fails on valid input. + """ + fun name(): String => "extensive/pedantic-accepts-valid" + + fun apply(h: TestHelper) => + let xml = + "firstsecond" + let opts = Xml2ParserOptions.create(where pedantic' = true) + match Xml2Parser.parseDoc(xml, opts) + | let doc: Xml2Doc => + try + let root = doc.getRootElement()? + h.assert_eq[USize](2, root.getChildren().size()) + h.assert_eq[String]("first", + root.getChildren()(0)?.getContent()) + else + h.fail("pedantic traversal failed") + end + | let err: Xml2Error => + h.fail( + "pedantic mode should accept valid XML; got error: " + + err.string()) + end + +// --------------------------------------------------------------- +// Deeply nested XPath +// --------------------------------------------------------------- + +class \nodoc\ iso TestDeepXPathExpression is UnitTest + """ + Build a 20-level-deep tree and verify that an absolute XPath + navigates to the deepest node and that a descendant-axis query + finds it from the root. Exercises libxml2's path resolution on + longer paths than the basic tests use. + """ + fun name(): String => "extensive/deep-xpath-expression" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("root")? + var parent = doc.getRootElement()? + var i: USize = 0 + while i < 20 do + let child = doc.createElement("n")? + parent.appendChild(child)? + parent = child + i = i + 1 + end + // Tag the deepest node so we can find it specifically. + parent.setProp("marker", "leaf") + + // Absolute path: /root/n/n/.../n (20 n's deep). + let absolute_path: String iso = recover iso String end + absolute_path.append("/root") + var j: USize = 0 + while j < 20 do + absolute_path.append("/n") + j = j + 1 + end + let absolute_path_str: String val = consume absolute_path + + match doc.xpathEvalNodes(absolute_path_str) + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](1, nodes.size()) + h.assert_eq[String]("leaf", nodes(0)?.getProp("marker")) + | let _: Xml2Error => h.fail("absolute deep path failed") + end + + // Descendant axis: find the marker via attribute predicate. + match doc.xpathEvalNodes("//n[@marker='leaf']") + | let nodes: Array[Xml2Node] => + h.assert_eq[USize](1, nodes.size()) + | let _: Xml2Error => h.fail("descendant-marker query failed") + end + else + h.fail("deep-tree construction failed") + end + +// --------------------------------------------------------------- +// xml:space attribute +// --------------------------------------------------------------- + +class \nodoc\ iso TestXmlSpaceAttributePreserved is UnitTest + """ + `xml:space` is in the implicit XML namespace + `http://www.w3.org/XML/1998/namespace`. Verify that it is + preserved through parse / serialise and is readable via + `getPropNs` with the XML namespace URI. (Reading it via + `getProp("xml:space")` is implementation-defined since `getProp` + is namespace-unaware.) + """ + fun name(): String => "extensive/xml-space-preserved" + + fun apply(h: TestHelper) => + let xml_ns = "http://www.w3.org/XML/1998/namespace" + let xml = + "" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + try + let root = doc.getRootElement()? + h.assert_eq[String]( + "preserve", root.getPropNs(xml_ns, "space")) + let a = root.getChildren()(0)? + h.assert_eq[String]( + "default", a.getPropNs(xml_ns, "space")) + // Round-trip via serialise. + let s = doc.serialize(false)? + h.assert_true( + s.contains("xml:space=\"preserve\""), + "serialised root must keep xml:space attribute; got: " + + s) + else + h.fail("xml:space traversal failed") + end + | let err: Xml2Error => + h.fail("xml:space parse failed: " + err.string()) + end + +// --------------------------------------------------------------- +// Namespace prefix shadowing +// --------------------------------------------------------------- + +class \nodoc\ iso TestNamespacePrefixShadowing is UnitTest + """ + When the same namespace prefix is redeclared on a nested element, + the inner declaration shadows the outer for that subtree. + `Xml2Node.namespaceUri()` must return the URI in scope at each + node - the outer URI for nodes outside the redeclaration, the + inner URI for the redeclaring element and its descendants. + """ + fun name(): String => "extensive/namespace-prefix-shadowing" + + fun apply(h: TestHelper) => + let outer_uri = "http://example.com/outer" + let inner_uri = "http://example.com/inner" + let xml: String val = + "" + + "" + + "" + + "" + + "" + + "" + + "" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + try + let r = doc.getRootElement()? + let outer = r.getChildren()(0)? + let inner = outer.getChildren()(0)? + let deepest = inner.getChildren()(0)? + h.assert_eq[String](outer_uri, outer.namespaceUri()) + h.assert_eq[String](inner_uri, inner.namespaceUri()) + // deepest inherits the inner declaration. + h.assert_eq[String](inner_uri, deepest.namespaceUri()) + // All three report prefix "x" - prefix is source-level + // and doesn't change with shadowing. + h.assert_eq[String]("x", outer.namespacePrefix()) + h.assert_eq[String]("x", inner.namespacePrefix()) + h.assert_eq[String]("x", deepest.namespacePrefix()) + else + h.fail("shadowing traversal failed") + end + | let err: Xml2Error => + h.fail("shadowing parse failed: " + err.string()) + end + +// --------------------------------------------------------------- +// Xml2Node.fromPTR error path +// --------------------------------------------------------------- + +class \nodoc\ iso TestFromPTRRejectsNull is UnitTest + """ + `Xml2Node.fromPTR` is documented to raise on a `None` pointer. + Verify the error path explicitly: the call must raise rather + than silently produce a wrapper around a null pointer. + """ + fun name(): String => "extensive/from-ptr-rejects-null" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("root")? + try + let _ = Xml2Node.fromPTR( + recover tag doc end, + NullablePointer[XmlNode].none())? + h.fail("fromPTR with None pointer should have raised") + end + else + h.fail("could not even build the doc to test fromPTR null path") + end From 722f80edbc254833152eb382e3ad2a0e487c5c70 Mon Sep 17 00:00:00 2001 From: Red Davies Date: Fri, 22 May 2026 06:38:00 -0400 Subject: [PATCH 4/4] Finish API surface coverage: primitive sweeps, edges, error fields 15 new tests + tightens the existing TestNodeUtilityMethods getLineNo tautology assertion that the earlier review flagged as counterfactually meaningless. Primitive identity sweeps (3) - TestAllXml2ErrorDomainPrimitives: every documented xmlErrorDomain integer (0-30) plus out-of-range maps to the expected Xml2ErrorDomain* primitive; every primitive's string() returns the documented short name. Catches any drift between libxml2's enum ordering and the Pony mapping table. - TestAllXml2ErrorLevelPrimitives: same shape for the 4 documented levels plus Unknown. - TestAllXPathTypePrimitives: each XPathType* primitive's apply() returns the corresponding xmlXPathObjectType integer. Edge cases on creation and content (7) - TestCreateTextNodeEntityChars: text nodes containing <, >, &, quotes are escaped in the serialised output and decode back to the original on re-parse. - TestCreateCommentEdgeCases: empty, single-space, and unicode comments serialise + re-parse cleanly. - TestSetPropEmptyValue: attribute with explicit empty value is distinct from absent attribute (getProps lists it). - TestGetContentMixedChildren: getContent on a mixed-content element returns concatenated descendant text; getChildren filters to element nodes. - TestAddChildWithContent: Xml2Node.addChild(name, content) and addChild(name) (default empty content) both work and produce the right children. - TestCommentsFilteredFromGetChildren: getChildren skips comment nodes; XPath count(/*) and count(comment()) agree with the Pony-side filter. - TestSerializeUnsupportedEncoding: serialize with an unknown encoding either succeeds or raises - it does not crash (regression guard for the "safe from crashes" promise). Error-path field assertions (3) - TestXPathErrorFields: malformed XPath produces an Xml2Error with domain=XPath, non-zero code, non-empty message. - TestParseFileMissingFile: parseFile on a non-existent path returns an Xml2Error with domain=Io. - TestParseErrorFieldsAreAccessible: after a parse error, every Xml2Error field (str1/2/3 and int1/2 included) is readable without raising or crashing. Structural-access guarantee rather than value assertion, since libxml2 only populates some of these per error class. Property-based (2) - PropElementNameRoundTrip: for any ASCII-letter name, createElement(name) then getRootElement().name() returns the input. 500 samples. - PropMultiplePropsAllRetrievable: for N (name, value) pairs with unique names, every pair is retrievable via getProp and getProps cardinality is N. 500 samples. Existing test tightened - TestNodeUtilityMethods's getLineNo block previously asserted `true == true` (called out as counterfactually weak in an earlier review). Replaced with a documented-range assertion: libxml2 returns -1 when line tracking is unavailable, otherwise a non-negative line number; both are acceptable but anything else (NaN-equivalent, very negative) would fail. All 117 tests pass; stability confirmed across 3 consecutive runs. No release note: test-only change. --- libxml2/_test.pony | 19 ++ libxml2/_tests/coverage_tests.pony | 11 +- libxml2/_tests/extensive_tests.pony | 507 ++++++++++++++++++++++++++++ 3 files changed, 534 insertions(+), 3 deletions(-) diff --git a/libxml2/_test.pony b/libxml2/_test.pony index 761b300..cdc6a3e 100755 --- a/libxml2/_test.pony +++ b/libxml2/_test.pony @@ -125,6 +125,25 @@ actor \nodoc\ Main is TestList test(TestXmlSpaceAttributePreserved) test(TestNamespacePrefixShadowing) test(TestFromPTRRejectsNull) + // Extensive API coverage (batch 4: primitive sweeps + edges + // + error-field reads + remaining property-based) + test(TestAllXml2ErrorDomainPrimitives) + test(TestAllXml2ErrorLevelPrimitives) + test(TestAllXPathTypePrimitives) + test(TestCreateTextNodeEntityChars) + test(TestCreateCommentEdgeCases) + test(TestSetPropEmptyValue) + test(TestGetContentMixedChildren) + test(TestAddChildWithContent) + test(TestCommentsFilteredFromGetChildren) + test(TestSerializeUnsupportedEncoding) + test(TestXPathErrorFields) + test(TestParseFileMissingFile) + test(TestParseErrorFieldsAreAccessible) + test(Property1UnitTest[String]( + recover iso PropElementNameRoundTrip end)) + test(Property1UnitTest[USize]( + recover iso PropMultiplePropsAllRetrievable end)) // Crash-resistance fuzz tests (PonyCheck Property1) test(Property1UnitTest[String](recover iso FuzzParseDoc end)) test(Property1UnitTest[(String, String)]( diff --git a/libxml2/_tests/coverage_tests.pony b/libxml2/_tests/coverage_tests.pony index 87b3dc0..94207bd 100644 --- a/libxml2/_tests/coverage_tests.pony +++ b/libxml2/_tests/coverage_tests.pony @@ -29,10 +29,15 @@ class \nodoc\ iso TestNodeUtilityMethods is UnitTest // The string cast includes all text content from the node tree h.assert_eq[String]("\n hello\n world\n", cast_str) - // Test getLineNo on root (returns 0 unless globally enabled) + // Test getLineNo on root. libxml2 returns -1 when line + // tracking is unavailable, otherwise a non-negative line + // number. Either is acceptable; what matters is the return + // value is in the documented range. let line_no = root.getLineNo() - // Just verify the method runs and returns a value - h.assert_true(true) // Method executed without error + h.assert_true( + line_no >= I64(-1), + "getLineNo should return -1 (unavailable) or >= 0; got " + + line_no.string()) // Test these methods on child nodes let children = root.getChildren() diff --git a/libxml2/_tests/extensive_tests.pony b/libxml2/_tests/extensive_tests.pony index fa2790e..9e0f6a2 100644 --- a/libxml2/_tests/extensive_tests.pony +++ b/libxml2/_tests/extensive_tests.pony @@ -1528,3 +1528,510 @@ class \nodoc\ iso TestFromPTRRejectsNull is UnitTest else h.fail("could not even build the doc to test fromPTR null path") end + +// --------------------------------------------------------------- +// Primitive identity sweeps +// --------------------------------------------------------------- + +class \nodoc\ iso TestAllXml2ErrorDomainPrimitives is UnitTest + """ + Iterate the full libxml2 error-domain enum and verify each + integer code maps to the documented `Xml2ErrorDomain*` primitive + via `Xml2ErrorDomainFromI32`, and that each primitive's + `string()` method returns the documented short name. Out-of- + range codes map to `Xml2ErrorDomainUnknown`. + + This catches any drift between the libxml2 `xmlErrorDomain` enum + ordering and the Pony-side mapping table. + """ + fun name(): String => "extensive/all-error-domain-primitives" + + fun apply(h: TestHelper) => + h.assert_true(Xml2ErrorDomainFromI32(0) is Xml2ErrorDomainNone) + h.assert_true(Xml2ErrorDomainFromI32(1) is Xml2ErrorDomainParser) + h.assert_true(Xml2ErrorDomainFromI32(2) is Xml2ErrorDomainTree) + h.assert_true(Xml2ErrorDomainFromI32(3) is Xml2ErrorDomainNamespace) + h.assert_true(Xml2ErrorDomainFromI32(4) is Xml2ErrorDomainDtd) + h.assert_true(Xml2ErrorDomainFromI32(5) is Xml2ErrorDomainHtml) + h.assert_true(Xml2ErrorDomainFromI32(6) is Xml2ErrorDomainMemory) + h.assert_true(Xml2ErrorDomainFromI32(7) is Xml2ErrorDomainOutput) + h.assert_true(Xml2ErrorDomainFromI32(8) is Xml2ErrorDomainIo) + h.assert_true(Xml2ErrorDomainFromI32(9) is Xml2ErrorDomainFtp) + h.assert_true(Xml2ErrorDomainFromI32(10) is Xml2ErrorDomainHttp) + h.assert_true(Xml2ErrorDomainFromI32(11) is Xml2ErrorDomainXInclude) + h.assert_true(Xml2ErrorDomainFromI32(12) is Xml2ErrorDomainXPath) + h.assert_true(Xml2ErrorDomainFromI32(13) is Xml2ErrorDomainXPointer) + h.assert_true(Xml2ErrorDomainFromI32(14) is Xml2ErrorDomainRegexp) + h.assert_true(Xml2ErrorDomainFromI32(15) is Xml2ErrorDomainDatatype) + h.assert_true(Xml2ErrorDomainFromI32(16) is Xml2ErrorDomainSchemasParser) + h.assert_true(Xml2ErrorDomainFromI32(17) is Xml2ErrorDomainSchemasValid) + h.assert_true(Xml2ErrorDomainFromI32(18) is Xml2ErrorDomainRelaxNgParser) + h.assert_true(Xml2ErrorDomainFromI32(19) is Xml2ErrorDomainRelaxNgValid) + h.assert_true(Xml2ErrorDomainFromI32(20) is Xml2ErrorDomainCatalog) + h.assert_true(Xml2ErrorDomainFromI32(21) is Xml2ErrorDomainC14n) + h.assert_true(Xml2ErrorDomainFromI32(22) is Xml2ErrorDomainXslt) + h.assert_true(Xml2ErrorDomainFromI32(23) is Xml2ErrorDomainValid) + h.assert_true(Xml2ErrorDomainFromI32(24) is Xml2ErrorDomainCheck) + h.assert_true(Xml2ErrorDomainFromI32(25) is Xml2ErrorDomainWriter) + h.assert_true(Xml2ErrorDomainFromI32(26) is Xml2ErrorDomainModule) + h.assert_true(Xml2ErrorDomainFromI32(27) is Xml2ErrorDomainI18n) + h.assert_true(Xml2ErrorDomainFromI32(28) is Xml2ErrorDomainSchematronValid) + h.assert_true(Xml2ErrorDomainFromI32(29) is Xml2ErrorDomainBuffer) + h.assert_true(Xml2ErrorDomainFromI32(30) is Xml2ErrorDomainUri) + h.assert_true(Xml2ErrorDomainFromI32(31) is Xml2ErrorDomainUnknown) + h.assert_true(Xml2ErrorDomainFromI32(-1) is Xml2ErrorDomainUnknown) + + // Per-primitive string() round-trip. + h.assert_eq[String]("None", Xml2ErrorDomainNone.string()) + h.assert_eq[String]("Parser", Xml2ErrorDomainParser.string()) + h.assert_eq[String]("Tree", Xml2ErrorDomainTree.string()) + h.assert_eq[String]("Namespace", Xml2ErrorDomainNamespace.string()) + h.assert_eq[String]("Dtd", Xml2ErrorDomainDtd.string()) + h.assert_eq[String]("Html", Xml2ErrorDomainHtml.string()) + h.assert_eq[String]("Memory", Xml2ErrorDomainMemory.string()) + h.assert_eq[String]("Output", Xml2ErrorDomainOutput.string()) + h.assert_eq[String]("Io", Xml2ErrorDomainIo.string()) + h.assert_eq[String]("Ftp", Xml2ErrorDomainFtp.string()) + h.assert_eq[String]("Http", Xml2ErrorDomainHttp.string()) + h.assert_eq[String]("XInclude", Xml2ErrorDomainXInclude.string()) + h.assert_eq[String]("XPath", Xml2ErrorDomainXPath.string()) + h.assert_eq[String]("XPointer", Xml2ErrorDomainXPointer.string()) + h.assert_eq[String]("Regexp", Xml2ErrorDomainRegexp.string()) + h.assert_eq[String]("Datatype", Xml2ErrorDomainDatatype.string()) + h.assert_eq[String]("SchemasParser", Xml2ErrorDomainSchemasParser.string()) + h.assert_eq[String]("SchemasValid", Xml2ErrorDomainSchemasValid.string()) + h.assert_eq[String]("RelaxNgParser", Xml2ErrorDomainRelaxNgParser.string()) + h.assert_eq[String]("RelaxNgValid", Xml2ErrorDomainRelaxNgValid.string()) + h.assert_eq[String]("Catalog", Xml2ErrorDomainCatalog.string()) + h.assert_eq[String]("C14n", Xml2ErrorDomainC14n.string()) + h.assert_eq[String]("Xslt", Xml2ErrorDomainXslt.string()) + h.assert_eq[String]("Valid", Xml2ErrorDomainValid.string()) + h.assert_eq[String]("Check", Xml2ErrorDomainCheck.string()) + h.assert_eq[String]("Writer", Xml2ErrorDomainWriter.string()) + h.assert_eq[String]("Module", Xml2ErrorDomainModule.string()) + h.assert_eq[String]("I18n", Xml2ErrorDomainI18n.string()) + h.assert_eq[String]("SchematronValid", Xml2ErrorDomainSchematronValid.string()) + h.assert_eq[String]("Buffer", Xml2ErrorDomainBuffer.string()) + h.assert_eq[String]("Uri", Xml2ErrorDomainUri.string()) + h.assert_eq[String]("Unknown", Xml2ErrorDomainUnknown.string()) + +class \nodoc\ iso TestAllXml2ErrorLevelPrimitives is UnitTest + """ + All four documented libxml2 `xmlErrorLevel` values plus the + out-of-range fall-through map correctly through + `Xml2ErrorLevelFromI32` and report the expected `string()`. + """ + fun name(): String => "extensive/all-error-level-primitives" + + fun apply(h: TestHelper) => + h.assert_true(Xml2ErrorLevelFromI32(0) is Xml2ErrorLevelNone) + h.assert_true(Xml2ErrorLevelFromI32(1) is Xml2ErrorLevelWarning) + h.assert_true(Xml2ErrorLevelFromI32(2) is Xml2ErrorLevelError) + h.assert_true(Xml2ErrorLevelFromI32(3) is Xml2ErrorLevelFatal) + h.assert_true(Xml2ErrorLevelFromI32(4) is Xml2ErrorLevelUnknown) + h.assert_true(Xml2ErrorLevelFromI32(-1) is Xml2ErrorLevelUnknown) + h.assert_eq[String]("None", Xml2ErrorLevelNone.string()) + h.assert_eq[String]("Warning", Xml2ErrorLevelWarning.string()) + h.assert_eq[String]("Error", Xml2ErrorLevelError.string()) + h.assert_eq[String]("Fatal", Xml2ErrorLevelFatal.string()) + h.assert_eq[String]("Unknown", Xml2ErrorLevelUnknown.string()) + +class \nodoc\ iso TestAllXPathTypePrimitives is UnitTest + """ + All ten libxml2 `xmlXPathObjectType` integers have a + corresponding `XPathType*` primitive whose `apply()` returns the + expected integer code. Catches drift between libxml2's enum + ordering and the Pony-side primitive set. + """ + fun name(): String => "extensive/all-xpath-type-primitives" + + fun apply(h: TestHelper) => + h.assert_eq[I32](0, XPathTypeUndefined()) + h.assert_eq[I32](1, XPathTypeNodeset()) + h.assert_eq[I32](2, XPathTypeBoolean()) + h.assert_eq[I32](3, XPathTypeNumber()) + h.assert_eq[I32](4, XPathTypeString()) + h.assert_eq[I32](5, XPathTypePoint()) + h.assert_eq[I32](6, XPathTypeRange()) + h.assert_eq[I32](7, XPathTypeLocationSet()) + h.assert_eq[I32](8, XPathTypeUsers()) + h.assert_eq[I32](9, XPathTypeXsltTree()) + +// --------------------------------------------------------------- +// Edge cases on text / comment / attribute creation +// --------------------------------------------------------------- + +class \nodoc\ iso TestCreateTextNodeEntityChars is UnitTest + """ + Text nodes containing the five XML special characters (`<`, + `>`, `&`, `'`, `"`) must escape them in the serialised output. + libxml2 does this transparently on `xmlNodeSetContent` / + `xmlNewDocText`. + """ + fun name(): String => "extensive/text-node-entity-chars" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("r")? + let root = doc.getRootElement()? + let txt = doc.createTextNode( + "a < b & c > d \"quoted\" 'apostrophe'")? + root.appendChild(txt)? + let s = doc.serialize(false)? + // < > & must be escaped; apostrophes and quotes are + // typically passed through in text (only attribute values + // require quote escaping). + h.assert_true(s.contains("a < b & c > d"), + "expected < > & escaped in text body; got: " + s) + // Round-trip must restore original characters. + match Xml2Parser.parseDoc(s) + | let doc2: Xml2Doc => + h.assert_eq[String]( + "a < b & c > d \"quoted\" 'apostrophe'", + doc2.getRootElement()?.getContent()) + | let err: Xml2Error => + h.fail("re-parse failed: " + err.string()) + end + else + h.fail("text-node entity-char flow failed") + end + +class \nodoc\ iso TestCreateCommentEdgeCases is UnitTest + """ + `createComment` with empty / single-character / space-only + content produces nodes that survive serialise + re-parse. (XML + forbids `--` inside comments; that's the user's responsibility + rather than something the binding validates, so we don't probe + it here.) + """ + fun name(): String => "extensive/create-comment-edge-cases" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("r")? + let root = doc.getRootElement()? + let empty_comment = doc.createComment("")? + let space_comment = doc.createComment(" ")? + let unicode_comment = doc.createComment("نص عربي")? + root.appendChild(empty_comment)? + root.appendChild(space_comment)? + root.appendChild(unicode_comment)? + let s = doc.serialize(false)? + h.assert_true(s.contains(""), + "expected empty-comment delimiters; got: " + s) + h.assert_true(s.contains(""), + "expected single-space comment; got: " + s) + h.assert_true(s.contains("نص عربي"), + "expected unicode comment content preserved; got: " + s) + // Re-parse must succeed. + match Xml2Parser.parseDoc(s) + | let _: Xml2Doc => None + | let err: Xml2Error => + h.fail("re-parse failed: " + err.string()) + end + else + h.fail("create-comment edge-case flow failed") + end + +class \nodoc\ iso TestSetPropEmptyValue is UnitTest + """ + `setProp(name, "")` creates an attribute with an empty value + that's distinct from "attribute absent": `getProp` returns "" in + both cases, but `getProps()` lists the explicitly-set one. + """ + fun name(): String => "extensive/set-prop-empty-value" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("r")? + let root = doc.getRootElement()? + root.setProp("present", "") + // getProp returns "" for both present-empty and absent. + h.assert_eq[String]("", root.getProp("present")) + h.assert_eq[String]("", root.getProp("absent")) + // getProps must list `present` exactly once. + let props = root.getProps() + h.assert_eq[USize](1, props.size()) + h.assert_eq[String]("present", props(0)?._1) + h.assert_eq[String]("", props(0)?._2) + // Serialised form must include `present=""`. + let s = doc.serialize(false)? + h.assert_true(s.contains("present=\"\""), + "expected empty-value attribute in serialised form; got: " + s) + else + h.fail("set-prop-empty flow failed") + end + +class \nodoc\ iso TestGetContentMixedChildren is UnitTest + """ + `getContent` on an element with mixed text and element children + returns the concatenated text content of the whole subtree + (libxml2's `xmlNodeGetContent` walks descendants). + """ + fun name(): String => "extensive/get-content-mixed-children" + + fun apply(h: TestHelper) => + match Xml2Parser.parseDoc("hello bold world") + | let doc: Xml2Doc => + try + let root = doc.getRootElement()? + h.assert_eq[String]("hello bold world", root.getContent()) + // getChildren returns only the element children, not text. + let kids = root.getChildren() + h.assert_eq[USize](1, kids.size()) + h.assert_eq[String]("b", kids(0)?.name()) + h.assert_eq[String]("bold", kids(0)?.getContent()) + else + h.fail("traversal failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +class \nodoc\ iso TestAddChildWithContent is UnitTest + """ + `Xml2Node.addChild(name, content)` creates and appends in one + step. Verify both the resulting child has the requested name + AND its content equals what was passed. + """ + fun name(): String => "extensive/add-child-with-content" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("r")? + let root = doc.getRootElement()? + let added_full = root.addChild("with-content", "hello")? + let added_empty = root.addChild("no-content")? + h.assert_eq[String]("with-content", added_full.name()) + h.assert_eq[String]("hello", added_full.getContent()) + h.assert_eq[String]("no-content", added_empty.name()) + h.assert_eq[String]("", added_empty.getContent()) + // Both must appear as element children of the root. + h.assert_eq[USize](2, root.getChildren().size()) + else + h.fail("addChild flow failed") + end + +class \nodoc\ iso TestCommentsFilteredFromGetChildren is UnitTest + """ + `getChildren` filters to element nodes only - comments and + text-only nodes are skipped. XPath's `*` axis agrees: it + matches elements, not comments. + """ + fun name(): String => "extensive/comments-filtered-from-getchildren" + + fun apply(h: TestHelper) => + let xml = + "" + match Xml2Parser.parseDoc(xml) + | let doc: Xml2Doc => + try + let root = doc.getRootElement()? + // Three element children; the two comments are filtered. + h.assert_eq[USize](3, root.getChildren().size()) + h.assert_eq[String]("a", root.getChildren()(0)?.name()) + h.assert_eq[String]("b", root.getChildren()(1)?.name()) + h.assert_eq[String]("c", root.getChildren()(2)?.name()) + // XPath /r/* matches the same three elements. + match doc.xpathEvalF64("count(/r/*)") + | let f: F64 => h.assert_eq[F64](3.0, f) + | let _: Xml2Error => h.fail("count(/r/*) failed") + end + // XPath /r/comment() matches the two comments. + match doc.xpathEvalF64("count(/r/comment())") + | let f: F64 => h.assert_eq[F64](2.0, f) + | let _: Xml2Error => h.fail("count(/r/comment()) failed") + end + else + h.fail("traversal failed") + end + | let err: Xml2Error => + h.fail("parse failed: " + err.string()) + end + +class \nodoc\ iso TestSerializeUnsupportedEncoding is UnitTest + """ + `serialize` with an unknown encoding name must not crash. The + expected behaviour is libxml2 either rejects (and the partial + function raises) or falls back. Either is acceptable; the test + guards against an unrecoverable crash that the "safe from + crashes" promise forbids. + """ + fun name(): String => "extensive/serialize-unsupported-encoding" + + fun apply(h: TestHelper) => + try + let doc = Xml2Doc.createWithRoot("root")? + try + let s = doc.serialize(false, "this-encoding-does-not-exist")? + // Successful return is acceptable as long as we get a + // String of some kind. Trivially true once s is bound. + h.assert_true(s.size() >= 0) + end + // Whether we reached here via success or partial-error, the + // process did not crash - that's the contract. + h.assert_true(true) + else + h.fail("could not build doc for unsupported-encoding test") + end + +// --------------------------------------------------------------- +// Error-path field assertions +// --------------------------------------------------------------- + +class \nodoc\ iso TestXPathErrorFields is UnitTest + """ + Triggering a malformed XPath expression must produce an + `Xml2Error` with domain set to `Xml2ErrorDomainXPath`, a + non-zero error code, and a non-empty message. + """ + fun name(): String => "extensive/xpath-error-fields" + + fun apply(h: TestHelper) => + match Xml2Parser.parseDoc("") + | let doc: Xml2Doc => + match doc.xpathEvalNodes("invalid[expression") + | let _: Array[Xml2Node] => + h.fail("malformed XPath should not have succeeded") + | let err: Xml2Error => + h.assert_true( + err.domain is Xml2ErrorDomainXPath, + "expected domain=XPath; got " + err.domain.string()) + h.assert_ne[I32](I32(0), err.code) + h.assert_true(err.message.size() > 0, + "expected non-empty error message") + end + | let err: Xml2Error => + h.fail("setup parse failed: " + err.string()) + end + +class \nodoc\ iso TestParseFileMissingFile is UnitTest + """ + `Xml2Parser.parseFile` on a non-existent path must return an + `Xml2Error` (not crash), and the error's domain should reflect + an I/O failure - libxml2 reports this in the `IO` domain. + """ + fun name(): String => "extensive/parse-file-missing-file" + + fun apply(h: TestHelper) => + let auth = FileAuth(h.env.root) + match Xml2Parser.parseFile( + auth, "/tmp/this-file-really-should-not-exist-xyz-12345.xml") + | let _: Xml2Doc => + h.fail("parseFile on missing file should not have succeeded") + | let err: Xml2Error => + // libxml2 reports missing-file errors in the IO domain. + h.assert_true( + err.domain is Xml2ErrorDomainIo, + "expected domain=Io for missing file; got " + + err.domain.string()) + h.assert_ne[I32](I32(0), err.code) + end + +class \nodoc\ iso TestParseErrorFieldsAreAccessible is UnitTest + """ + After a parse error, every `Xml2Error` field is readable + without raising or crashing. This is a structural-access + guarantee rather than a value assertion - the str1/str2/str3 + and int1/int2 fields are populated by libxml2 for some error + classes and left empty for others, so we can't assert specific + contents portably. The point is the wrapper exposes them. + """ + fun name(): String => "extensive/parse-error-fields-accessible" + + fun apply(h: TestHelper) => + match Xml2Parser.parseDoc(" + h.fail("malformed XML should have errored") + | let err: Xml2Error => + // Read every field; assert structural sanity where we can. + h.assert_true( + err.domain is Xml2ErrorDomainParser, + "expected parser-domain error") + let level_is_real_error = + (err.level is Xml2ErrorLevelError) or + (err.level is Xml2ErrorLevelFatal) + h.assert_true(level_is_real_error, + "expected level >= Error") + h.assert_ne[I32](I32(0), err.code) + h.assert_true(err.message.size() > 0, + "expected non-empty message") + // file can be empty for in-memory parsing - just access it. + let _ = err.file.size() + // line should be >= 0 (libxml2 may use 0 for unknown). + h.assert_true(err.line >= I32(0), + "line should be non-negative; got " + err.line.string()) + // str1/str2/str3 and int1/int2 are accessible; their values + // are libxml2-error-specific. Just exercise the getters. + let _ = err.str1.size() + let _ = err.str2.size() + let _ = err.str3.size() + let _ = err.int1 + let _ = err.int2 + end + +// --------------------------------------------------------------- +// Property-based: element-name and multi-prop coverage +// --------------------------------------------------------------- + +class \nodoc\ iso PropElementNameRoundTrip is Property1[String] + """ + For any valid XML element name (ASCII letters, length 1-32), + `createElement(name)` produces a node whose `name()` is the + input. Verifies that the binding doesn't mangle names en + route to libxml2 and back. + """ + fun name(): String => + "extensive/element-name-roundtrip/property" + + fun params(): PropertyParams => + PropertyParams(where num_samples' = 500) + + fun gen(): Generator[String] => + Generators.ascii_letters(1, 32) + + fun ref property(arg1: String, h: PropertyHelper) => + h.assert_no_error({() ? => + let doc = Xml2Doc.create()? + let elem = doc.createElement(arg1)? + doc.setRootElement(elem)? + h.assert_eq[String](arg1, doc.getRootElement()?.name()) + } box) + +class \nodoc\ iso PropMultiplePropsAllRetrievable + is Property1[USize] + """ + For N (name, value) pairs where names are guaranteed unique via + indexing (`a0`..`aN-1`), every pair is retrievable via + `getProp(name)` independently and `getProps()` reports exactly + N entries. + """ + fun name(): String => + "extensive/multiple-props-all-retrievable/property" + + fun params(): PropertyParams => + PropertyParams(where num_samples' = 500) + + fun gen(): Generator[USize] => + Generators.usize(where min = USize(0), max = USize(100)) + + fun ref property(arg1: USize, h: PropertyHelper) => + h.assert_no_error({() ? => + let doc = Xml2Doc.createWithRoot("root")? + let root = doc.getRootElement()? + var i: USize = 0 + while i < arg1 do + root.setProp("a" + i.string(), "v" + i.string()) + i = i + 1 + end + // Each name retrievable independently. + var k: USize = 0 + while k < arg1 do + h.assert_eq[String]( + "v" + k.string(), + root.getProp("a" + k.string())) + k = k + 1 + end + // Cardinality matches. + h.assert_eq[USize](arg1, root.getProps().size()) + } box)