From 1634af137bf5247303ff9ef0eabf12bc57bf5e8a Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 11:46:18 +0200 Subject: [PATCH] test(conformance): xalan-test XSLT 1.0 runner with exact file baseline The Apache xalan-test conf corpus (1690 gold-bearing cases, 36 categories) runs opt-in via XALAN_TS_ROOT with normalized comparison: canonical XML when both sides parse, collapsed whitespace otherwise. First measurement passes 1086; the 604 known failures live in Tests/Fixtures/xalan-baseline.txt, asserted exactly in both directions. Top classes: namespace handling, xsl:number, axes corners, output options, attribute sets, id()/key(). --- CHANGELOG.md | 1 + README.md | 4 +- Tests/Fixtures/xalan-baseline.txt | 607 ++++++++++++++++++++++++++++++ Tests/XalanConfSuiteTests.swift | 112 ++++++ 4 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 Tests/Fixtures/xalan-baseline.txt create mode 100644 Tests/XalanConfSuiteTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 592e7d9..b1d158b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XSLT 1.0 conformance runner over the Apache xalan-test corpus (#122, #130): the de facto OASIS-era suite (1690 gold-bearing cases across 36 categories) runs opt-in via XALAN_TS_ROOT, never vendored. Comparison is deliberately normalized: outputs that parse as XML compare by canonical form (Xalan's indentation is not normative), others with whitespace runs collapsed. First measurement: 1086 pass; the 604 known failures are carried exactly in Tests/Fixtures/xalan-baseline.txt, asserted in both directions (a fixed case must leave the file, a regression shows as a new failure). The top burn-down classes by count: namespace handling (123, led by namespaced xsl:attribute creation), xsl:number depth (85), axes corners (68), output/serialization options (55), attribute sets (52), id()/key() (41). - RELAX NG schema-correctness validation (#131): a schema document is now validated before any pattern interpretation, in three layers. The grammar check (section 3) rejects unknown or misplaced elements, illegal unqualified and RELAX NG-namespace attributes, wrong child arity, stray text, malformed or undeclared-prefix QNames, non-URI datatypeLibrary values (absolute, fragment-free, valid percent escapes), fragment-carrying hrefs, the 4.16 except restrictions (anyName/nsName inside excepts, propagated through choice), and the xmlns name prohibitions including the ns-attribute and except-mention forms. The compiler checks reject combine conflicts and double combine-less defines (4.17), unresolved refs and parentRef without a parent, startless grammars, include/externalRef self-reference, include overrides with nothing to override, non-grammar include targets (loaded documents are grammar-checked too), unknown datatypes, and params against the built-in library. The restrictions checker (4.19, 7.1-7.4) runs over the compiled algebra after 4.20/4.21 normalization (notAllowed/empty operands simplify away first; recursion detection deliberately precedes it): prohibited paths under attribute, oneOrMore-group, list, and in start; computable content types; duplicate attributes by representative-name overlap; infinite-name-class attributes outside oneOrMore; and the interleave text/element constraints. The spec suite's incorrect class drops from 213 compiling to 7, all pre-Fifth-Edition name-class cases (the xmltest-141 class, count asserted), with zero correct-schema or instance regressions. Two leniency tests updated to the spec behavior (double combine-less defines and unknown datatypes are schema errors). - RELAX NG QName values, instance level clean (#131): `` compiles to its (namespace, local-name) value pair via the schema's xmlns/ns scope, and the derivative engine threads the instance element's in-scope namespace bindings down to value comparison (in both the tree walk and the streaming state, whose frames now carry their bindings), so an instance QName resolves its prefix, or the default namespace when unprefixed, against the instance document itself; an unbound prefix matches nothing. The streaming path also gains the tree walk's empty-element rule. With this the spec suite's instance level is fully clean: valid 289/289 accepted and invalid 291/291 rejected; only the asserted schema-correctness class remains. - RELAX NG grammar scoping and include depth, third burn-down (#131): each `grammar` element opens its own define scope (names are scope-mangled in the flat table, so nested grammars do not collide) and `parentRef` reaches the enclosing scope; `include` is transitive (an included grammar's own includes merge first) and a define carried by the include element replaces the included grammar's same-name define (the 4.7 override); `div` wrappers flatten into their grammar while still contributing ns/datatypeLibrary inheritance (4.11); `value` text is kept untrimmed so whitespace-preserving datatypes compare exactly (6.2.9); and `dataExcept` recurses through choice patterns. The spec suite is now clean except one class: valid 286/289 and invalid 287/291, with all six remaining instance cases the QName datatype (its value space needs prefix resolution in both the schema and instance contexts), plus the asserted schema-correctness item. diff --git a/README.md b/README.md index 4a19ea8..3cfe1fc 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ flowchart TB BaseURI["#138 per-entity base URIs: complete"]:::done Epic122["#122 official spec suites"]:::epic XSTS["#129 W3C XSD test suite (XSTS)"]:::todo - XSLTTS["#130 OASIS XSLT 1.0 suite (+XPath corpus)"]:::todo + XSLTTS["#130 XSLT 1.0 suite (xalan-test): runner + baselines in, burning down"]:::review RNGTS["#131 RELAX NG spec suite: complete, one documented class"]:::done C14NTS["#132 C14N spec vectors: complete"]:::done Epic105 --> Epic121 @@ -125,7 +125,7 @@ flowchart TB PureXML is a working, dependency-free XML library today: parse, emit, validate, query, and stream documents on macOS, Linux, Windows, and WASM. The test suite -currently runs **940 tests in 162 suites** (`swift test`). +currently runs **941 tests in 163 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt new file mode 100644 index 0000000..5455dde --- /dev/null +++ b/Tests/Fixtures/xalan-baseline.txt @@ -0,0 +1,607 @@ +# The xalan-test conf cases this implementation does not yet pass, +# one category/name per line, exact in both directions: a fixed case +# must leave this list and a regression shows up as a new failure. +attribset/attribset04 +attribset/attribset09 +attribset/attribset10 +attribset/attribset13 +attribset/attribset14 +attribset/attribset16 +attribset/attribset17 +attribset/attribset18 +attribset/attribset19 +attribset/attribset21 +attribset/attribset24 +attribset/attribset26 +attribset/attribset27 +attribset/attribset28 +attribset/attribset29 +attribset/attribset31 +attribset/attribset32 +attribset/attribset35 +attribset/attribset36 +attribset/attribset40 +attribset/attribset41 +attribset/attribset43 +attribset/attribset44 +attribset/attribset45 +attribset/attribset46 +attribset/attribset48 +attribvaltemplate/attribvaltemplate08 +axes/axes03 +axes/axes103 +axes/axes104 +axes/axes105 +axes/axes106 +axes/axes107 +axes/axes108 +axes/axes109 +axes/axes110 +axes/axes111 +axes/axes112 +axes/axes113 +axes/axes116 +axes/axes120 +axes/axes123 +axes/axes124 +axes/axes126 +axes/axes128 +axes/axes129 +axes/axes130 +axes/axes18 +axes/axes19 +axes/axes44 +axes/axes45 +axes/axes46 +axes/axes58 +axes/axes59 +axes/axes60 +axes/axes62 +axes/axes68 +axes/axes88 +axes/axes89 +axes/axes93 +axes/axes98 +axes/axes99 +boolean/boolean43 +boolean/boolean87 +conflictres/conflictres04 +conflictres/conflictres18 +conflictres/conflictres19 +conflictres/conflictres20 +conflictres/conflictres22 +conflictres/conflictres23 +conflictres/conflictres25 +conflictres/conflictres26 +conflictres/conflictres29 +conflictres/conflictres30 +conflictres/conflictres32 +conflictres/conflictres34 +conflictres/conflictres37 +copy/copy01 +copy/copy03 +copy/copy07 +copy/copy09 +copy/copy16 +copy/copy17 +copy/copy19 +copy/copy20 +copy/copy21 +copy/copy22 +copy/copy25 +copy/copy27 +copy/copy31 +copy/copy33 +copy/copy34 +copy/copy35 +copy/copy36 +copy/copy37 +copy/copy38 +copy/copy39 +copy/copy44 +copy/copy46 +copy/copy47 +copy/copy48 +copy/copy51 +copy/copy52 +copy/copy54 +copy/copy56 +copy/copy58 +copy/copy60 +copy/copy62 +dflt/dflt01 +embed/embed03 +embed/embed04 +expression/expression02 +extend/extend01 +extend/extend02 +extend/extend04 +extend/extend05 +idkey/idkey01 +idkey/idkey03 +idkey/idkey04 +idkey/idkey09 +idkey/idkey12 +idkey/idkey18 +idkey/idkey19 +idkey/idkey21 +idkey/idkey22 +idkey/idkey23 +idkey/idkey24 +idkey/idkey26 +idkey/idkey30 +idkey/idkey31 +idkey/idkey32 +idkey/idkey33 +idkey/idkey35 +idkey/idkey37 +idkey/idkey38 +idkey/idkey39 +idkey/idkey40 +idkey/idkey41 +idkey/idkey42 +idkey/idkey43 +idkey/idkey44 +idkey/idkey45 +idkey/idkey46 +idkey/idkey47 +idkey/idkey48 +idkey/idkey50 +idkey/idkey51 +idkey/idkey53 +idkey/idkey55 +idkey/idkey56 +idkey/idkey57 +idkey/idkey58 +idkey/idkey59 +idkey/idkey60 +idkey/idkey61 +idkey/idkey62 +idkey/idkey63 +impincl/impincl07 +impincl/impincl08 +impincl/impincl09 +impincl/impincl11 +impincl/impincl17 +impincl/impincl19 +impincl/impincl21 +impincl/impincl23 +impincl/impincl25 +impincl/impincl26 +impincl/impincl27 +lre/lre01 +lre/lre02 +lre/lre03 +lre/lre04 +lre/lre05 +lre/lre10 +lre/lre11 +lre/lre12 +lre/lre15 +lre/lre17 +lre/lre18 +lre/lre19 +lre/lre20 +lre/lre21 +lre/lre22 +lre/lre23 +match/match11 +match/match14 +match/match34 +mdocs/mdocs01 +mdocs/mdocs05 +mdocs/mdocs06 +mdocs/mdocs07 +mdocs/mdocs08 +mdocs/mdocs09 +mdocs/mdocs11 +mdocs/mdocs12 +mdocs/mdocs13 +mdocs/mdocs14 +mdocs/mdocs15 +mdocs/mdocs16 +mdocs/mdocs17 +mdocs/mdocs18 +mdocs/mdocs19 +modes/modes06 +modes/modes07 +modes/modes11 +modes/modes12 +modes/modes14 +modes/modes16 +namedtemplate/namedtemplate04 +namedtemplate/namedtemplate05 +namedtemplate/namedtemplate12 +namedtemplate/namedtemplate16 +namedtemplate/namedtemplate17 +namedtemplate/namedtemplate18 +namedtemplate/namedtemplate19 +namespace/namespace01 +namespace/namespace02 +namespace/namespace03 +namespace/namespace04 +namespace/namespace06 +namespace/namespace07 +namespace/namespace09 +namespace/namespace10 +namespace/namespace100 +namespace/namespace101 +namespace/namespace102 +namespace/namespace103 +namespace/namespace104 +namespace/namespace105 +namespace/namespace106 +namespace/namespace107 +namespace/namespace108 +namespace/namespace109 +namespace/namespace11 +namespace/namespace110 +namespace/namespace111 +namespace/namespace112 +namespace/namespace113 +namespace/namespace115 +namespace/namespace116 +namespace/namespace117 +namespace/namespace12 +namespace/namespace120 +namespace/namespace121 +namespace/namespace122 +namespace/namespace123 +namespace/namespace124 +namespace/namespace125 +namespace/namespace126 +namespace/namespace127 +namespace/namespace128 +namespace/namespace13 +namespace/namespace130 +namespace/namespace131 +namespace/namespace132 +namespace/namespace134 +namespace/namespace137 +namespace/namespace138 +namespace/namespace139 +namespace/namespace140 +namespace/namespace141 +namespace/namespace142 +namespace/namespace15 +namespace/namespace17 +namespace/namespace18 +namespace/namespace19 +namespace/namespace20 +namespace/namespace21 +namespace/namespace22 +namespace/namespace23 +namespace/namespace24 +namespace/namespace25 +namespace/namespace26 +namespace/namespace27 +namespace/namespace32 +namespace/namespace33 +namespace/namespace34 +namespace/namespace35 +namespace/namespace36 +namespace/namespace37 +namespace/namespace38 +namespace/namespace39 +namespace/namespace40 +namespace/namespace42 +namespace/namespace43 +namespace/namespace44 +namespace/namespace45 +namespace/namespace46 +namespace/namespace47 +namespace/namespace48 +namespace/namespace49 +namespace/namespace50 +namespace/namespace51 +namespace/namespace52 +namespace/namespace53 +namespace/namespace54 +namespace/namespace55 +namespace/namespace56 +namespace/namespace57 +namespace/namespace58 +namespace/namespace59 +namespace/namespace60 +namespace/namespace61 +namespace/namespace62 +namespace/namespace63 +namespace/namespace64 +namespace/namespace65 +namespace/namespace66 +namespace/namespace67 +namespace/namespace68 +namespace/namespace69 +namespace/namespace72 +namespace/namespace73 +namespace/namespace74 +namespace/namespace75 +namespace/namespace77 +namespace/namespace78 +namespace/namespace79 +namespace/namespace80 +namespace/namespace81 +namespace/namespace82 +namespace/namespace83 +namespace/namespace84 +namespace/namespace86 +namespace/namespace87 +namespace/namespace88 +namespace/namespace89 +namespace/namespace90 +namespace/namespace91 +namespace/namespace92 +namespace/namespace93 +namespace/namespace94 +namespace/namespace95 +namespace/namespace96 +namespace/namespace97 +namespace/namespace98 +namespace/namespace99 +node/node03 +node/node04 +node/node05 +node/node06 +node/node09 +node/node10 +node/node11 +node/node12 +node/node13 +node/node15 +node/node16 +node/node17 +node/node19 +numberformat/numberformat01 +numberformat/numberformat02 +numberformat/numberformat03 +numberformat/numberformat04 +numberformat/numberformat06 +numberformat/numberformat07 +numberformat/numberformat08 +numberformat/numberformat09 +numberformat/numberformat11 +numberformat/numberformat12 +numberformat/numberformat16 +numberformat/numberformat17 +numberformat/numberformat19 +numberformat/numberformat20 +numberformat/numberformat21 +numberformat/numberformat22 +numberformat/numberformat23 +numberformat/numberformat24 +numberformat/numberformat25 +numberformat/numberformat27 +numberformat/numberformat28 +numberformat/numberformat29 +numberformat/numberformat30 +numberformat/numberformat31 +numberformat/numberformat34 +numberformat/numberformat35 +numberformat/numberformat36 +numberformat/numberformat41 +numberformat/numberformat43 +numberformat/numberformat44 +numbering/numbering03 +numbering/numbering04 +numbering/numbering05 +numbering/numbering06 +numbering/numbering07 +numbering/numbering08 +numbering/numbering09 +numbering/numbering10 +numbering/numbering11 +numbering/numbering12 +numbering/numbering13 +numbering/numbering14 +numbering/numbering17 +numbering/numbering18 +numbering/numbering19 +numbering/numbering20 +numbering/numbering21 +numbering/numbering22 +numbering/numbering23 +numbering/numbering24 +numbering/numbering25 +numbering/numbering26 +numbering/numbering27 +numbering/numbering28 +numbering/numbering29 +numbering/numbering30 +numbering/numbering31 +numbering/numbering32 +numbering/numbering33 +numbering/numbering34 +numbering/numbering35 +numbering/numbering36 +numbering/numbering37 +numbering/numbering38 +numbering/numbering39 +numbering/numbering40 +numbering/numbering41 +numbering/numbering42 +numbering/numbering43 +numbering/numbering44 +numbering/numbering45 +numbering/numbering46 +numbering/numbering47 +numbering/numbering48 +numbering/numbering49 +numbering/numbering50 +numbering/numbering51 +numbering/numbering52 +numbering/numbering56 +numbering/numbering57 +numbering/numbering58 +numbering/numbering59 +numbering/numbering60 +numbering/numbering61 +numbering/numbering62 +numbering/numbering63 +numbering/numbering65 +numbering/numbering66 +numbering/numbering67 +numbering/numbering68 +numbering/numbering69 +numbering/numbering70 +numbering/numbering71 +numbering/numbering72 +numbering/numbering73 +numbering/numbering74 +numbering/numbering77 +numbering/numbering78 +numbering/numbering79 +numbering/numbering80 +numbering/numbering81 +numbering/numbering82 +numbering/numbering83 +numbering/numbering84 +numbering/numbering85 +numbering/numbering86 +numbering/numbering87 +numbering/numbering89 +numbering/numbering90 +numbering/numbering91 +numbering/numbering92 +numbering/numbering93 +numbering/numbering94 +numbering/numbering95 +output/output01 +output/output02 +output/output03 +output/output04 +output/output05 +output/output06 +output/output08 +output/output09 +output/output10 +output/output101 +output/output102 +output/output103 +output/output13 +output/output15 +output/output17 +output/output22 +output/output26 +output/output27 +output/output30 +output/output32 +output/output33 +output/output34 +output/output35 +output/output36 +output/output37 +output/output38 +output/output39 +output/output40 +output/output41 +output/output48 +output/output49 +output/output50 +output/output51 +output/output52 +output/output58 +output/output59 +output/output60 +output/output61 +output/output62 +output/output63 +output/output64 +output/output65 +output/output70 +output/output71 +output/output72 +output/output73 +output/output74 +output/output75 +output/output80 +output/output82 +output/output86 +output/output89 +output/output90 +output/output98 +output/output99 +position/position09 +position/position10 +position/position101 +position/position106 +position/position111 +position/position70 +position/position75 +position/position91 +position/position98 +predicate/predicate38 +processorinfo/processorinfo03 +reluri/reluri01 +reluri/reluri02 +reluri/reluri03 +reluri/reluri04 +reluri/reluri05 +reluri/reluri06 +reluri/reluri07 +reluri/reluri08 +reluri/reluri09 +reluri/reluri10 +reluri/reluri11 +select/select04 +select/select13 +select/select16 +select/select27 +select/select28 +select/select59 +select/select67 +select/select68 +select/select69 +select/select73 +select/select74 +select/select75 +select/select83 +select/select84 +sort/sort-alphabet-english +sort/sort-alphabet-polish +sort/sort-alphabet-russian +sort/sort01 +sort/sort02 +sort/sort08 +sort/sort09 +sort/sort10 +sort/sort13 +sort/sort14 +sort/sort21 +sort/sort27 +sort/sort28 +sort/sort32 +sort/sort33 +sort/sort34 +sort/sort40 +sort/sort41 +string/string130 +string/string132 +string/string143 +string/string30 +string/string31 +string/string32 +string/string33 +string/string34 +string/string35 +string/string36 +variable/variable29 +variable/variable31 +variable/variable32 +variable/variable33 +variable/variable34 +variable/variable35 +variable/variable41 +variable/variable48 +variable/variable54 +variable/variable55 +variable/variable56 +variable/variable63 +variable/variable67 +variable/variable70 +whitespace/whitespace06 +whitespace/whitespace07 +whitespace/whitespace13 +whitespace/whitespace17 +whitespace/whitespace20 +whitespace/whitespace21 +whitespace/whitespace35 diff --git a/Tests/XalanConfSuiteTests.swift b/Tests/XalanConfSuiteTests.swift new file mode 100644 index 0000000..566f5e6 --- /dev/null +++ b/Tests/XalanConfSuiteTests.swift @@ -0,0 +1,112 @@ +import Foundation +@testable import PureXML +import Testing + +/// Opt-in runner for the Apache xalan-test XSLT 1.0 conformance corpus (the +/// de facto OASIS-era suite: per-category stylesheet/source pairs with gold +/// outputs, Apache License 2.0). Point `XALAN_TS_ROOT` at the checkout's +/// `tests` directory to run; the corpus is never vendored. Comparison +/// normalizes deliberately: a gold and an actual that both parse as XML are +/// compared by canonical form (Xalan's indentation is not normative); other +/// outputs compare with whitespace runs collapsed. Foundation is used for +/// file access only. +@Suite("Xalan XSLT conformance (opt-in via XALAN_TS_ROOT)") +struct XalanConfSuiteTests { + private var root: String? { + ProcessInfo.processInfo.environment["XALAN_TS_ROOT"] + } + + /// Cases this implementation knowingly fails, kept exact in + /// Tests/Fixtures/xalan-baseline.txt (one category/name per line): a + /// fixed case must leave the file and a regression shows as a new + /// failure. + private func knownFailures() -> Set { + guard let url = Bundle.module.url(forResource: "xalan-baseline", withExtension: "txt", subdirectory: "Fixtures"), + let text = try? String(contentsOf: url, encoding: .utf8) + else { return [] } + return Set(text.split(separator: "\n").map(String.init).filter { !$0.hasPrefix("#") && !$0.isEmpty }) + } + + /// Categories that are out of scope: extension mechanisms beyond the + /// XSLT 1.0 core that this package does not ship. + private let outOfScopeCategories: Set = [] + + @Test("Every conf case transforms to its gold output") + func test_confSuite() throws { + guard let root else { + return // Opt-in: suite not present. + } + let confDirectory = root + "/conf" + let goldDirectory = root + "/conf-gold" + let manager = FileManager.default + let baseline = knownFailures() + var failures: [String: String] = [:] + var passed = 0 + var skippedNoGold = 0 + let categories = try manager.contentsOfDirectory(atPath: confDirectory) + .filter { !outOfScopeCategories.contains($0) } + .sorted() + for category in categories { + let categoryPath = confDirectory + "/" + category + var isDirectory = ObjCBool(false) + guard manager.fileExists(atPath: categoryPath, isDirectory: &isDirectory), isDirectory.boolValue else { continue } + for sheet in try manager.contentsOfDirectory(atPath: categoryPath).filter({ $0.hasSuffix(".xsl") }).sorted() { + let name = String(sheet.dropLast(4)) + let key = category + "/" + name + let goldPath = goldDirectory + "/" + category + "/" + name + ".out" + guard manager.fileExists(atPath: goldPath) else { + skippedNoGold += 1 + continue + } + if let problem = runCase(category: category, name: name, root: root, goldPath: goldPath) { + failures[key] = problem + } else { + passed += 1 + } + } + } + try? failures.keys.sorted().joined(separator: "\n").write(toFile: "/tmp/xalan-failures.txt", atomically: true, encoding: .utf8) + let newFailures = Set(failures.keys).subtracting(baseline).sorted() + let fixed = baseline.subtracting(failures.keys).sorted() + let sample = newFailures.prefix(3).map { ($0, failures[$0] ?? "") } + let report = "passed \(passed), no-gold \(skippedNoGold), \(newFailures.count) NEW: \(newFailures.prefix(40)) :: \(sample)" + #expect(newFailures.isEmpty, Comment(rawValue: report)) + #expect(fixed.isEmpty, "\(fixed.count) baselined cases now pass (remove from xalan-baseline.txt): \(fixed.prefix(40))") + } + + private func runCase(category: String, name: String, root: String, goldPath: String) -> String? { + let directory = root + "/conf/" + category + guard let stylesheet = try? String(contentsOfFile: directory + "/" + name + ".xsl", encoding: .utf8), + let source = try? String(contentsOfFile: directory + "/" + name + ".xml", encoding: .utf8) + else { + return "unreadable inputs" + } + guard let goldData = FileManager.default.contents(atPath: goldPath), + let gold = (try? PureXML.Parsing.ByteDecoder.decode([UInt8](goldData))) ?? String(data: goldData, encoding: .utf8) + else { + return "unreadable gold" + } + let loader: (String) -> String? = { uri in + (try? String(contentsOfFile: directory + "/" + uri, encoding: .utf8)) + } + let actual: String + do { + actual = try PureXML.XSLT.transform(stylesheet: stylesheet, source: source, documentLoader: loader) + } catch { + return "transform threw: \(error)" + } + return Self.equivalent(actual: actual, gold: gold) ? nil : "output differs from gold" + } + + /// Gold comparison: canonical XML when both sides parse (indentation is + /// not normative), collapsed whitespace otherwise. + static func equivalent(actual: String, gold: String) -> Bool { + if let actualNode = try? PureXML.parse(actual), let goldNode = try? PureXML.parse(gold) { + return PureXML.Canonical.canonicalize(actualNode) == PureXML.Canonical.canonicalize(goldNode) + } + let collapse = { (text: String) in + text.split(whereSeparator: \.isWhitespace).joined(separator: " ") + } + return collapse(actual) == collapse(gold) + } +}