diff --git a/CHANGELOG.md b/CHANGELOG.md index 0475e1e..3000c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- A generated-value facet differential against libxml2 (`FacetValidationDifferentialTests`, opt-in via `FACET_DIFF` and `xmllint`) exercises the datatype and facet value space, the largest correctness surface the determinism and instance-content differentials do not touch: for each generated simple type restricting a built-in (`xs:int`, `decimal`, `double`, `string`, `token`, `boolean`, `date`, `dateTime`, `nonNegativeInteger`) by a subset of its facets (min/maxInclusive/Exclusive, length/min/maxLength, totalDigits/fractionDigits, pattern, enumeration, whiteSpace), it validates a pool of values chosen at and around the facet boundaries with PureXML and with libxml2 and flags every disagreement, asserting the cardinal rule (zero values PureXML rejects that libxml2 accepts). Over about ten thousand values it found one false positive, the `NaN` enumeration fixed below, and otherwise zero; the residual false negatives are contested datatype readings (whitespace collapse, `NaN` against a range facet) where PureXML matches the XSD spec and this libxml2 build is stricter. Test-only. + - Compiling a schema with a long `` restriction chain (`S0` restricting `S1` restricting `S2`, and so on) is no longer near-cubic in the chain length, so a deep chain no longer stalls the compiler, a keystroke-time interactive-safety hang on hostile or generated input. Such a chain is flat in the document, so the same base type was resolved over and over: the named simple types were resolved by an N-pass fixpoint that re-resolved every type on every pass, and the facet-applicability variety check re-scanned the schema's children to find each base at every link of every type's chain. Named simple types are now resolved once in dependency order (base before derived), and the variety check resolves a base through a precomputed name map in constant time and memoizes each type's variety, so a chain is walked once rather than per type. A 600-link restriction chain that took about 4.4 seconds to compile now takes about 30 milliseconds. Behaviour-preserving: the resolved types and the emitted findings are identical, verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged), the libxml2 determinism differential unchanged, and the full suite. The analogous repeated re-scan in attribute-group reference chains is milder and remains, tracked. - Compiling a schema with a long `` `simpleContent` extension chain (`T0` extending `T1` extending `T2`, and so on) is likewise no longer near-cubic. Each of the N types re-walked its whole base chain to resolve the simple type its `simpleContent` derives from, the same flat-document re-resolution. The resolved simple type of a complex type's `simpleContent` is now memoized by name (`SimpleContentMemo`, a reference type that survives the value-type context copies), so a chain is walked once rather than per type. A 400-link chain that took about 23 seconds to compile now takes about 1.2 seconds; past the content-model expansion-depth bound (512) such a chain is rejected with a located error as before. Behaviour-preserving: the resolved type is intrinsic to the name for an acyclic chain (a derivation cycle is rejected elsewhere), verified conformance-neutral over the full W3C XSTS corpus in release. The attribute flattening that accumulates each type's attributes along the chain is inherently quadratic in the bounded chain length and is tracked. @@ -29,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A value of `NaN` is no longer wrongly rejected by an `xs:double` or `xs:float` `enumeration` that lists `NaN`. Value-space equality (`OrderedValue.==`) compared with IEEE equality, under which `NaN != NaN`, so a `NaN` value never matched a `NaN` enumeration member and was rejected, a false positive (the cardinal rule: never reject a valid value). The comparison now treats `NaN` as identical to itself, the value-space identity the reference (libxml2) and XSD 1.1 use throughout, so `NaN` validates against a type restricting `xs:double` by ``; `INF`/`-INF` already matched. This equality also backs the `key`/`unique` identity constraints and `fixed`, where the same identity holds (verified against `xmllint`): two `NaN`-valued unique fields collide as duplicates, and `fixed="NaN"` matches `NaN`. The ordering facets (`minInclusive` and friends) use the order relation, in which `NaN` stays incomparable, and are unaffected. Found by the new generated-value facet differential against libxml2, with the key/fixed behaviour confirmed after an adversarial critic flagged the shared comparison; the four W3C XSTS buckets held and the full suite passes, with regressions in `SchemaDoubleEnumerationTests`. + - A long `` chain (`ag0` referencing `ag1` referencing `ag2`, and so on) no longer overflows the stack or hangs the compiler. Such a chain is flat in the schema document, so flattening a complex type's attributes through it recursed once per link, overflowing the stack past about six thousand links and running near-cubic before that (about 17 seconds at three thousand links). The attribute-group reference depth is now bounded by `maximumContentModelExpansionDepth` (512, the same bound the content-model expansion uses), since the cycle-guard `visited` set already counts the chain depth: a chain past the bound is rejected with a located error (`attribute group reference nesting exceeds the maximum depth of 512`) rather than crashed. Behaviour-preserving for every schema within the bound, which no realistic schema or the XSTS corpus approaches; verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged) and by the full suite. Disclosed residual: a schema with very many distinct over-bound attribute-group chains still rejects in time quadratic in the bounded depth per group, because the attribute flattening is re-done per group; memoizing it, as the `simpleContent` resolution now is, is the linear follow-up. - The schema compiler no longer overflows the stack on a content model whose expanded depth is unbounded. Two constructs are flat in the schema document, so they escape the parser's element-nesting `maxDepth` (256) yet make a content model arbitrarily deep once compiled: a `` chain (the group definitions are siblings, not nested) and a `complexContent` derivation chain (`B0` extends `B1` extends `B2`, and so on, the type definitions also siblings). The particle compiler inlines each group reference and composes each derivation once per link, so a long chain recursed once per link until the stack overflowed while compiling a legal, if hostile, schema, a keystroke-time interactive-safety crash. The expanded depth is now bounded by a single counter (`XSDContext.expansionDepth`, incremented at each compositor level, group-reference expansion, and derivation step) against `maximumContentModelExpansionDepth` (512, twice the element-nesting limit: room for a maximally nested model plus as much again for expansion). A model past the bound is rejected with a located error (`content model nesting ... exceeds the maximum depth of 512`, or the derivation form), not crashed: a bound, not a band-aid. The bound is on total expanded depth, so it covers both a long chain of shallow links and a short chain of internally deep ones. Together with the determinism-check fix below, this closes the group-reference-chain crash on every compiler pass that recursed on the chain. Verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged; no valid schema newly rejected, the corpus's content models nesting far below the bound), the libxml2 determinism differential unchanged, and the full suite. An adversarial over-rejection critic confirmed the counter lives in a value-type context whose copies make it track depth, not width, so a wide-but-shallow model (hundreds of sibling particles) is never falsely rejected, and that the rejection is harvested into the schema's errors rather than silently truncating the model. diff --git a/Sources/Schema/Primitive.swift b/Sources/Schema/Primitive.swift index fe9a57d..6c098cc 100644 --- a/Sources/Schema/Primitive.swift +++ b/Sources/Schema/Primitive.swift @@ -13,6 +13,21 @@ extension PureXML.Schema { default: false } } + + /// Value-space equality. A `double`/`float` value matches by IDENTITY, under which + /// `NaN` is identical to `NaN` (IEEE `==` reports `NaN != NaN`). This is what every + /// value-equality facet wants and what the reference (libxml2) and XSD 1.1 do: an + /// `enumeration` listing `NaN` admits `NaN`, two `NaN`-valued `key`/`unique` fields + /// collide as duplicates, and `fixed="NaN"` matches `NaN`. Ordering (`<`) keeps `NaN` + /// incomparable, so the range facets are unaffected. + static func == (lhs: OrderedValue, rhs: OrderedValue) -> Bool { + switch (lhs, rhs) { + case let (.decimal(left), .decimal(right)): left == right + case let (.double(left), .double(right)): left == right || (left.isNaN && right.isNaN) + case let (.dateTime(left), .dateTime(right)): left == right + default: false + } + } } /// An XSD primitive value space: the lexical recognizer, and for the ordered diff --git a/Tests/FacetValidationDifferentialTests.swift b/Tests/FacetValidationDifferentialTests.swift new file mode 100644 index 0000000..bcedfdd --- /dev/null +++ b/Tests/FacetValidationDifferentialTests.swift @@ -0,0 +1,274 @@ +import Foundation +import Testing +@testable import PureXML + +/// A generated-value differential for simple-type FACET validation against libxml2 +/// (`xmllint --schema`), the reference. The content-model matcher and the instance +/// content validator have their own differentials; this one exercises the datatype and +/// facet value space, the largest correctness surface the others do not touch: for each +/// generated simple type that restricts a built-in by a subset of its facets, it +/// validates a pool of generated `` values (chosen at and around the facet +/// boundaries) with PureXML and with libxml2 and flags every disagreement. +/// +/// Opt-in (needs `/usr/bin/xmllint`, set `FACET_DIFF=1`); never runs in plain +/// `swift test` or CI. A PureXML-rejects / libxml2-accepts disagreement is a FALSE +/// POSITIVE (the cardinal rule: never reject a valid value); a PureXML-accepts / +/// libxml2-rejects is a false negative to triage. Only schemas BOTH engines compile are +/// used, so a disagreement is about value validity, not facet-combination validity. +/// +/// Result (~10k values across xs:int/decimal/double/string/token/boolean/date/dateTime/ +/// nonNegativeInteger restricted by min/maxInclusive/Exclusive, length/min/maxLength, +/// totalDigits/fractionDigits, pattern, enumeration, whiteSpace): it FOUND one real false +/// positive, a value of `NaN` rejected by an `xs:double` `enumeration` listing `NaN` +/// (the comparison used IEEE equality, under which `NaN != NaN`), now fixed in +/// `OrderedValue.==`; after the fix, ZERO false positives. The remaining disagreements are +/// a contested datatype reading, not a bug to chase: a value with surrounding whitespace +/// against a numeric type (e.g. ` 7 ` against `xs:int`) is valid per the XSD spec, +/// since the numeric types carry a fixed `whiteSpace="collapse"` that trims it before the +/// value check; PureXML accepts it (matching the spec and Xerces) where this libxml2 build +/// is stricter, so rejecting it would itself be a false positive. (`NaN` against a co- +/// occurring range facet is the dual contested case, excluded from the generator below.) +@Suite("Facet-validation differential vs libxml2 (opt-in via FACET_DIFF)") +struct FacetValidationDifferentialTests { + private var enabled: Bool { + ProcessInfo.processInfo.environment["FACET_DIFF"] != nil + } + + private var xmllint: String? { + let path = ProcessInfo.processInfo.environment["XMLLINT"].flatMap { $0.isEmpty ? nil : $0 } ?? "/usr/bin/xmllint" + return FileManager.default.isExecutableFile(atPath: path) ? path : nil + } + + private enum Outcome: Equatable { case schemaInvalid, valid, invalid } + private enum Comparison { case falsePositive, falseNegative, agreeValid, agreeInvalid } + + private func compare(pureValid: Bool, reference: Outcome) -> Comparison { + switch (pureValid, reference) { + case (false, .valid): .falsePositive // PureXML rejects a value libxml2 accepts (cardinal-rule blocker) + case (true, .invalid): .falseNegative // PureXML accepts a value libxml2 rejects + case (true, _): .agreeValid + case (false, _): .agreeInvalid + } + } + + private func libxml2(_ schemaFile: String, _ instance: String, xmllint: String) -> Outcome? { + #if os(WASI) + return nil + #else + let instanceFile = NSTemporaryDirectory() + "facet-diff-\(abs(instance.hashValue)).xml" + guard (try? instance.write(toFile: instanceFile, atomically: true, encoding: .utf8)) != nil else { return nil } + defer { try? FileManager.default.removeItem(atPath: instanceFile) } + let process = Process() + process.executableURL = URL(fileURLWithPath: xmllint) + process.arguments = ["--noout", "--schema", schemaFile, instanceFile] + let pipe = Pipe() + process.standardError = pipe + process.standardOutput = pipe + guard (try? process.run()) != nil else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(decoding: data, as: UTF8.self) + if output.contains("failed to compile") || output.contains("Schemas parser error") { return .schemaInvalid } + return process.terminationStatus == 0 ? .valid : .invalid + #endif + } + + private struct Tally { + var compared = 0 + var agreeValid = 0 + var agreeInvalid = 0 + var falsePositives: [String] = [] + var falseNegatives: [String] = [] + } + + /// A schema both engines compile: the PureXML document, its source, and the file + /// written for libxml2. + private struct CompiledSchema { + let document: PureXML.Schema.Document + let source: String + let file: String + } + + private func compareValues( + _ schema: CompiledSchema, + xmllint: String, + generator: inout FacetSchemaGenerator, + into tally: inout Tally, + ) { + for value in generator.values() { + let instance = "\(value)" + let pureValid = ((try? schema.document.validate(instance)) ?? [PureXML.Validation.ValidationError]()).isEmpty + guard let reference = libxml2(schema.file, instance, xmllint: xmllint) else { continue } + if reference == .schemaInvalid { return } // libxml2 rejects the schema; skip its values + tally.compared += 1 + let detail = "\(schema.source)\n value: '\(value)' (PureXML \(pureValid ? "valid" : "invalid"), libxml2 \(reference))" + switch compare(pureValid: pureValid, reference: reference) { + case .falsePositive: if tally.falsePositives.count < 25 { tally.falsePositives.append(detail) } + case .falseNegative: if tally.falseNegatives.count < 25 { tally.falseNegatives.append(detail) } + case .agreeValid: tally.agreeValid += 1 + case .agreeInvalid: tally.agreeInvalid += 1 + } + } + } + + @Test("PureXML's facet-validity verdict agrees with libxml2 over generated values") + func test_facetDifferential() { + guard enabled, let xmllint else { return } + var generator = FacetSchemaGenerator(seed: 0xFACE_7D1F_F00D_C0DE) + var tally = Tally() + let schemaFile = NSTemporaryDirectory() + "facet-diff-schema.xsd" + for _ in 0 ..< 2000 { + let schema = generator.schema() + guard let document = try? PureXML.Schema.Document(schema) else { continue } // PureXML compiles + guard (try? schema.write(toFile: schemaFile, atomically: true, encoding: .utf8)) != nil else { continue } + defer { try? FileManager.default.removeItem(atPath: schemaFile) } + let compiled = CompiledSchema(document: document, source: schema, file: schemaFile) + compareValues(compiled, xmllint: xmllint, generator: &generator, into: &tally) + } + let report = "compared \(tally.compared) values (agree-valid \(tally.agreeValid), agree-invalid \(tally.agreeInvalid));" + + " false positives \(tally.falsePositives.count), false negatives \(tally.falseNegatives.count)" + FileHandle.standardError.write(Data("facet-diff: \(report)\n".utf8)) + try? (tally.falsePositives + ["--- false negatives ---"] + tally.falseNegatives).joined(separator: "\n\n") + .write(toFile: NSTemporaryDirectory() + "facet-diff.txt", atomically: true, encoding: .utf8) + // The cardinal rule: PureXML must never reject a value that libxml2 accepts. + #expect( + tally.falsePositives.isEmpty, + "\(tally.falsePositives.count) facet-validation false positives (see \(NSTemporaryDirectory())facet-diff.txt): \(tally.falsePositives.first ?? "")", + ) + } +} + +/// One built-in datatype with a pool of candidate facets (each a name and a value, drawn +/// to sit at and around the boundaries of the value pool) and a pool of `` values +/// (valid, invalid-lexical, and boundary), so a generated restriction stresses real +/// facet/value interactions. The facet and value pools are aligned by hand so a boundary +/// off-by-one surfaces. +private struct DatatypeProfile { + let base: String + let facets: [(name: String, value: String)] + let values: [String] +} + +/// Generates a `` element whose simple type restricts a built-in by a random subset of +/// its facets, and matching `value` instances drawn from the same profile, so a +/// fair fraction are valid and a rejection is a value rejection, not a parse error. +private struct FacetSchemaGenerator { + private var rng: FacetRNG + private var current = FacetSchemaGenerator.profiles[0] + + init(seed: UInt64) { + rng = FacetRNG(seed: seed) + } + + static let profiles: [DatatypeProfile] = [ + DatatypeProfile( + base: "xs:int", + facets: [ + ("minInclusive", "5"), ("maxInclusive", "20"), ("minExclusive", "4"), ("maxExclusive", "21"), + ("totalDigits", "2"), ("enumeration", "10"), ("enumeration", "15"), ("pattern", "[0-9]+"), + ], + values: ["5", "20", "4", "21", "10", "15", "0", "99", "100", "-3", "3", "abc", "", " 7 ", "+8", "007", "3.5"], + ), + DatatypeProfile( + base: "xs:decimal", + facets: [ + ("minInclusive", "1.5"), ("maxInclusive", "9.5"), ("totalDigits", "3"), ("fractionDigits", "1"), + ("enumeration", "2.5"), ("pattern", "[0-9.]+"), + ], + values: ["1.5", "9.5", "1.4", "9.6", "2.5", "5.25", "10.5", "100", "abc", "", "3", "2.50", "-1.5"], + ), + DatatypeProfile( + base: "xs:string", + facets: [ + ("minLength", "2"), ("maxLength", "6"), ("length", "4"), ("pattern", "[a-z]+"), + ("enumeration", "abc"), ("enumeration", "hello"), ("whiteSpace", "preserve"), + ], + values: ["abc", "hello", "a", "abcdef", "abcdefg", "ABC", "ab12", "", " sp ", "test", "te"], + ), + DatatypeProfile( + base: "xs:token", + facets: [("maxLength", "5"), ("minLength", "2"), ("pattern", "[a-z ]+"), ("enumeration", "yes"), ("enumeration", "no")], + values: ["yes", "no", "hello", "abcdef", "a b", "ABC", "", " trim ", " a b "], + ), + DatatypeProfile( + base: "xs:boolean", + facets: [("pattern", "true|false"), ("enumeration", "true"), ("enumeration", "1")], + values: ["true", "false", "1", "0", "yes", "", "TRUE", " true "], + ), + DatatypeProfile( + base: "xs:date", + facets: [ + ("minInclusive", "2020-01-01"), ("maxInclusive", "2020-12-31"), + ("pattern", "2020-.*"), ("enumeration", "2020-06-15"), + ], + values: ["2020-01-01", "2020-12-31", "2019-12-31", "2021-01-01", "2020-06-15", "2020-13-01", "abc", "", "2020-6-15"], + ), + DatatypeProfile( + base: "xs:nonNegativeInteger", + facets: [("minInclusive", "3"), ("maxInclusive", "30"), ("totalDigits", "2"), ("pattern", "[0-9]+")], + values: ["3", "30", "2", "31", "0", "-1", "99", "100", "abc", "", "10"], + ), + DatatypeProfile( + base: "xs:double", + facets: [("minInclusive", "1.5"), ("maxInclusive", "100"), ("minExclusive", "0"), ("pattern", "[0-9.eE+-]+")], + values: ["1.5", "100", "1.4", "100.1", "1.0E2", "1e2", "-0", ".5", "50", "abc", "", "0", "-5"], + ), + // `NaN`/`INF` are tested only against `enumeration` (value-space identity, the FP the + // differential caught), NOT range: `NaN` is incomparable for ordering, and whether + // an enumerated `NaN` then satisfies a co-occurring range facet is a contested + // reading (strict XSD excludes it; this libxml2 build admits it), so it is left out. + DatatypeProfile( + base: "xs:double", + facets: [("enumeration", "NaN"), ("enumeration", "INF"), ("enumeration", "3.14")], + values: ["NaN", "INF", "-INF", "3.14", "1.5", "abc", "", "0"], + ), + DatatypeProfile( + base: "xs:dateTime", + facets: [("minInclusive", "2020-01-01T00:00:00"), ("maxInclusive", "2020-12-31T23:59:59"), ("pattern", "2020-.*")], + values: [ + "2020-06-15T12:00:00", "2020-06-15T12:00:00Z", "2020-06-15T12:00:00+05:00", "2020-06-15T12:00:00-05:00", + "2020-01-01T00:00:00", "2019-12-31T23:59:59", "2021-01-01T00:00:00", "2020-13-01T00:00:00", "2020-06-15", "abc", "", + ], + ), + ] + + private mutating func pick(_ options: [T]) -> T { + options[Int.random(in: 0 ..< options.count, using: &rng)] + } + + mutating func schema() -> String { + current = pick(Self.profiles) + // A random non-empty subset of the profile's facets (a facet may repeat only for + // enumeration, which the pool already lists separately). + var chosen: [(name: String, value: String)] = [] + var seenName: Set = [] + for facet in current.facets where Int.random(in: 0 ..< 2, using: &rng) == 0 { + if facet.name == "enumeration" || seenName.insert(facet.name).inserted { chosen.append(facet) } + } + if chosen.isEmpty { chosen = [pick(current.facets)] } + let restriction = chosen.map { "" }.joined() + return "" + + "" + + "\(restriction)" + } + + /// The instance values for the current schema's profile (all of them, so every value + /// is tried against every generated restriction). + func values() -> [String] { + current.values + } +} + +private struct FacetRNG: RandomNumberGenerator { + private var state: UInt64 + init(seed: UInt64) { + state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed + } + + mutating func next() -> UInt64 { + state ^= state >> 12 + state ^= state << 25 + state ^= state >> 27 + return state &* 0x2545_F491_4F6C_DD1D + } +} diff --git a/Tests/SchemaDoubleEnumerationTests.swift b/Tests/SchemaDoubleEnumerationTests.swift new file mode 100644 index 0000000..7d2590b --- /dev/null +++ b/Tests/SchemaDoubleEnumerationTests.swift @@ -0,0 +1,80 @@ +import Testing +@testable import PureXML + +/// A `double`/`float` value matches by IDENTITY in every value-equality context, under +/// which `NaN` is identical to `NaN` even though IEEE `==` reports `NaN != NaN`. The prior +/// comparison used IEEE equality and so dropped `NaN`, a false positive the libxml2 facet +/// differential caught. The identity reading is what the reference (libxml2) and XSD 1.1 +/// do across the board, verified against `xmllint`: an `enumeration` listing `NaN` admits +/// `NaN`; two `NaN`-valued `unique` fields collide as duplicates; and `fixed="NaN"` matches +/// `NaN`. (A strict XSD-1.0 `·equal·` reading would instead leave `NaN` unequal to itself +/// for `fixed`/`key`; an adversarial critic flagged that path, but libxml2 rejects two +/// `NaN` keys and accepts `fixed="NaN"`, so a single identity `==` matches it.) Ordering +/// (`<`) keeps `NaN` incomparable, so the range facets are unaffected. +@Suite("Double NaN value-space identity") +struct SchemaDoubleEnumerationTests { + private func schema(_ base: String, _ facets: String) throws -> PureXML.Schema.Document { + try PureXML.Schema.Document(""" + + + \(facets) + + + """) + } + + @Test("NaN is admitted by an enumeration that lists NaN") + func test_nanInEnumeration() throws { + let doc = try schema("xs:double", #""#) + #expect(try doc.validate("NaN").isEmpty) // NaN is identical to NaN in the value space + #expect(try doc.validate("3.14").isEmpty) + #expect(try !doc.validate("2.0").isEmpty) // a value not enumerated is still rejected + } + + @Test("NaN is rejected by an enumeration that does not list it") + func test_nanNotInEnumeration() throws { + let doc = try schema("xs:double", #""#) + #expect(try !doc.validate("NaN").isEmpty) + } + + @Test("INF and -INF match their enumerations") + func test_infinityEnumeration() throws { + let doc = try schema("xs:double", #""#) + #expect(try doc.validate("INF").isEmpty) + #expect(try doc.validate("-INF").isEmpty) + #expect(try !doc.validate("5.0").isEmpty) + } + + @Test("an xs:float NaN enumeration matches too") + func test_floatNaN() throws { + let doc = try schema("xs:float", #""#) + #expect(try doc.validate("NaN").isEmpty) + } + + @Test("two NaN unique keys collide as duplicates (identity, matching libxml2)") + func test_nanUniqueKeysCollide() throws { + let doc = try PureXML.Schema.Document(""" + + + + + + + + + """) + // libxml2 reports "Duplicate key-sequence ['nan']"; NaN is identical to NaN here. + #expect(try !doc.validate("").isEmpty) + } + + @Test("a NaN fixed value matches a NaN instance (identity, matching libxml2)") + func test_nanFixedMatches() throws { + let doc = try PureXML.Schema.Document(""" + + + + """) + #expect(try doc.validate("NaN").isEmpty) // fixed="NaN" matches NaN + #expect(try !doc.validate("3.0").isEmpty) // a non-NaN value does not + } +}