PureXML/Tests/RelaxNGSpecSuiteTests.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

209 lines
8.8 KiB
Swift

import Foundation
import Testing
@testable import PureXML
/// Opt-in runner for James Clark's RELAX NG spec test suite (`spectest.xml`,
/// distributed with jing-trang under its BSD license). Point `RNG_TS_ROOT` at
/// the directory containing `spectest.xml` to run; without the variable the
/// suite is skipped and it is never vendored into this repository. The
/// manifest nests testSuite/testCase elements: an `incorrect` schema must
/// fail to compile, a `correct` schema must compile and then `valid`
/// instances validate while `invalid` instances do not. `resource` and `dir`
/// elements model the include/externalRef files a case references, served to
/// the compiler through its loader keyed by relative path. Foundation is used
/// for file access only.
@Suite("RELAX NG spec suite (opt-in via RNG_TS_ROOT)")
struct RelaxNGSpecSuiteTests {
private var root: String? {
ProcessInfo.processInfo.environment["RNG_TS_ROOT"]
}
/// The suite is clean to one documented class. Instance level: every
/// valid instance accepted, every invalid one rejected. Schema level:
/// incorrect schemas are rejected by the grammar check (section 3), the
/// compiler checks (4.5-4.19), and the restrictions checker (7.1-7.4),
/// except the seven cases whose schema names use pre-Fifth-Edition
/// character classes (70, 72-74, 79, 86, 87): this package implements
/// XML 1.0 5e name productions, where those characters became legal (the
/// same class as xmltest 141 and the IBM P85-89 block). The count is
/// asserted exactly so drift in either direction is caught.
private let knownIncorrectCompiledCount = 7
private let knownCorrectRejected: Set<Int> = []
private let knownValidRejected: Set<Int> = []
private let knownInvalidAccepted: Set<Int> = []
private struct SpecCase {
let index: Int
let section: String
let schema: String
let mustCompile: Bool
let validInstances: [String]
let invalidInstances: [String]
let resources: [String: String]
}
private final class Collector {
var cases: [SpecCase] = []
var counter = 0
}
private func loadCases(from path: String) throws -> [SpecCase] {
let source = try String(contentsOfFile: path, encoding: .utf8)
let document = try PureXML.parse(source, limits: .init(allowDoctype: true))
guard case let .document(children) = document,
let suite = children.compactMap(\.element).first
else { return [] }
let collector = Collector()
collect(suite, resources: [:], collector: collector)
return collector.cases
}
/// Walks nested testSuite elements, accumulating the `resource`/`dir`
/// definitions in scope, and turns each testCase into a SpecCase.
private func collect(_ element: PureXML.Model.Element, resources: [String: String], collector: Collector) {
var scope = resources
for child in element.children.compactMap(\.element) {
switch child.name.localName {
case "resource", "dir":
merge(child, prefix: "", into: &scope)
default:
break
}
}
for child in element.children.compactMap(\.element) {
switch child.name.localName {
case "testSuite":
collect(child, resources: scope, collector: collector)
case "testCase":
appendCase(child, resources: scope, collector: collector)
default:
break
}
}
}
/// Files a `resource` (name -> serialized content) or recurses into a
/// `dir`, prefixing names with the directory path.
private func merge(_ element: PureXML.Model.Element, prefix: String, into scope: inout [String: String]) {
let name = element.attributes.first { $0.name.description == "name" }?.value ?? ""
if element.name.localName == "resource" {
if let content = firstElementChild(element) {
scope[prefix + name] = PureXML.serialize(.element(content))
}
return
}
for child in element.children.compactMap(\.element) {
merge(child, prefix: prefix + name + "/", into: &scope)
}
}
private func firstElementChild(_ element: PureXML.Model.Element) -> PureXML.Model.Element? {
element.children.compactMap(\.element).first
}
/// The wrapper's first element child, serialized back to markup.
private func serializedChild(_ element: PureXML.Model.Element) -> String? {
firstElementChild(element).map { PureXML.serialize(.element($0)) }
}
private func appendCase(_ testCase: PureXML.Model.Element, resources: [String: String], collector: Collector) {
var scope = resources
var section = ""
var schema: String?
var mustCompile = true
var valid: [String] = []
var invalid: [String] = []
for child in testCase.children.compactMap(\.element) {
switch child.name.localName {
case "section":
section = child.children.compactMap {
if case let .text(value) = $0 {
value
} else {
nil
}
}.joined()
case "resource", "dir":
merge(child, prefix: "", into: &scope)
case "correct", "incorrect":
mustCompile = child.name.localName == "correct"
schema = serializedChild(child)
case "valid":
serializedChild(child).map { valid.append($0) }
case "invalid":
serializedChild(child).map { invalid.append($0) }
default:
break
}
}
guard let schema else { return }
collector.counter += 1
collector.cases.append(SpecCase(
index: collector.counter,
section: section,
schema: schema,
mustCompile: mustCompile,
validInstances: valid,
invalidInstances: invalid,
resources: scope,
))
}
@Test("Every spec case behaves: compile, reject, validate, invalidate")
func test_specSuite() throws {
guard let root else {
return // Opt-in: suite not present.
}
let cases = try loadCases(from: root + "/spectest.xml")
var incorrectAccepted: [Int] = []
var correctRejected: [String: String] = [:]
var validRejected: [String] = []
var invalidAccepted: [String] = []
for specCase in cases {
let resources = specCase.resources
let loader: (String) -> String? = { resources[$0] }
let compiled = try? PureXML.Schema.RelaxNG(specCase.schema, schemaLoader: loader)
guard specCase.mustCompile else {
if compiled != nil {
incorrectAccepted.append(specCase.index)
}
continue
}
guard let compiled else {
if !knownCorrectRejected.contains(specCase.index) {
correctRejected["#\(specCase.index) §\(specCase.section)"] = compileError(specCase, loader: loader)
}
continue
}
for (offset, instance) in specCase.validInstances.enumerated() {
let accepted = (try? compiled.validate(instance)) == true
if !accepted, !knownValidRejected.contains(specCase.index) {
validRejected.append("#\(specCase.index).\(offset) §\(specCase.section)")
}
}
for (offset, instance) in specCase.invalidInstances.enumerated() {
let accepted = (try? compiled.validate(instance)) == true
if accepted, !knownInvalidAccepted.contains(specCase.index) {
invalidAccepted.append("#\(specCase.index).\(offset) §\(specCase.section)")
}
}
}
#expect(
incorrectAccepted.count == knownIncorrectCompiledCount,
"incorrect-schema class drifted: \(incorrectAccepted.count) (baseline \(knownIncorrectCompiledCount)): \(incorrectAccepted)",
)
#expect(correctRejected.isEmpty, "rejected \(correctRejected.count) correct schemas: \(correctRejected)")
#expect(validRejected.isEmpty, "rejected \(validRejected.count) valid instances: \(validRejected)")
#expect(invalidAccepted.isEmpty, "accepted \(invalidAccepted.count) invalid instances: \(invalidAccepted)")
}
private func compileError(_ specCase: SpecCase, loader: @escaping (String) -> String?) -> String {
do {
_ = try PureXML.Schema.RelaxNG(specCase.schema, schemaLoader: loader)
return "compiled on retry"
} catch {
return String(describing: error)
}
}
}