Merge epic/101-validator-decomposition: scope decision + category isolation tests

Closes #101
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 16:41:50 +02:00
commit c29bf69f07
2 changed files with 115 additions and 4 deletions

View File

@ -34,10 +34,23 @@ public extension PureXML.Validation {
}
/// XSD validation expressed as composable ``Validation`` values over an
/// ``XSDContext``. The content-model check is inherently recursive (a child's
/// type depends on its position), so each rule delegates to the recursive
/// validators while contributing located ``ValidationError`` results to one
/// collection.
/// ``XSDContext``, in the OpenAPIKit idiom: each rule is a named, removable
/// ``Validation`` value with a positive description, and the ``validator()``
/// composes them; failures are located ``ValidationError``s.
///
/// Scope note (the #101 decision). XSD content validation is *one* recursive
/// ``Validation`` (``contentValidity``) rather than a separate rule per
/// constraint, because XSD is a recursive type system: a child's type is
/// resolved from its parent's content model and position, then rewritten by
/// `xsi:type` and the derivation controls mid-walk. The constraint categories
/// (attributes, content model, derivation, nillability, fixed values) are
/// interdependent through that resolution, so they cannot be split into
/// independent rules without either changing semantics or duplicating the
/// recursion. This is the sanctioned multi-error form (the rule's `check`
/// returns one located error per violation). Each category is isolation-tested
/// through crafted inputs so it is verifiable on its own, which is the
/// testability the idiom asks for. Identity constraints, which *are*
/// independent, are a separate rule (``identityConstraints``).
enum XSD {
/// The document element is valid against its declared XSD type.
static var contentValidity: Validation<PureXML.Model.Node, XSDContext> {

View File

@ -0,0 +1,98 @@
@testable import PureXML
import Testing
/// Each XSD constraint category exercised against `ComplexValidator` directly with
/// crafted inputs, so it is verifiable on its own (the OpenAPIKit isolation recipe
/// applied to the inherently-recursive XSD validator; see XSDValidations.swift's
/// #101 scope note). Derivation, wildcards, and namespaces are isolation-tested
/// through crafted schema strings in their own suites.
@Suite("XSD constraint categories in isolation")
struct XSDCategoryTests {
private typealias Schema = PureXML.Schema
private typealias Name = PureXML.Model.QualifiedName
private func element(_ name: String, _ attributes: [(String, String)] = [], children: [PureXML.Model.Node] = []) -> PureXML.Model.Element {
PureXML.Model.Element(name, attributes: attributes.map { PureXML.Model.Attribute($0.0, $0.1) }, children: children)
}
private func validate(
_ element: PureXML.Model.Element,
_ type: Schema.ComplexType,
validator: Schema.ComplexValidator = Schema.ComplexValidator(),
) -> [PureXML.Validation.ValidationError] {
validator.validate(element, against: type, at: [.element(element.name.description)])
}
// MARK: Attributes
@Test("Attributes: a required attribute is missing")
func test_requiredMissing() {
let type = Schema.ComplexType(attributes: [Schema.AttributeUse(name: Name("id"), type: Schema.SimpleType(base: .string), required: true)], content: .empty)
let errors = validate(element("a"), type)
#expect(errors.count == 1)
#expect(errors.first?.reason == "missing required attribute 'id'")
#expect(errors.first?.codingPath.map(\.stringValue) == ["a"])
}
@Test("Attributes: a fixed attribute must match")
func test_fixedAttribute() {
let use = Schema.AttributeUse(name: Name("v"), type: Schema.SimpleType(base: .string), valueConstraint: .fixed("1"))
let type = Schema.ComplexType(attributes: [use], content: .empty)
#expect(validate(element("a", [("v", "1")]), type).isEmpty)
#expect(validate(element("a", [("v", "2")]), type).first?.reason.contains("fixed") == true)
}
@Test("Attributes: an undeclared attribute is rejected unless a wildcard admits it")
func test_undeclaredAttribute() {
let strict = Schema.ComplexType(content: .empty)
#expect(validate(element("a", [("x", "1")]), strict).first?.reason == "undeclared attribute 'x'")
let lax = Schema.ComplexType(attributeWildcard: Schema.Wildcard(namespace: .any, processContents: .skip), content: .empty)
#expect(validate(element("a", [("x", "1")]), lax).isEmpty)
}
// MARK: Content
@Test("Content: an EMPTY type rejects children")
func test_emptyWithChildren() {
let type = Schema.ComplexType(content: .empty)
#expect(validate(element("a", children: [.element(element("b"))]), type).first?.reason == "element must be empty")
}
@Test("Content: simpleContent validates the text against its datatype")
func test_simpleContent() {
let type = Schema.ComplexType(content: .simpleContent(Schema.SimpleType(base: .integer)))
#expect(validate(element("a", children: [.text("5")]), type).isEmpty)
#expect(!validate(element("a", children: [.text("x")]), type).isEmpty)
}
@Test("Content: a child outside the content model is a model mismatch")
func test_contentModelMismatch() {
let particle = Schema.Particle(term: .group(Schema.Group(compositor: .sequence, particles: [
Schema.Particle(term: .element(name: Name("b"), type: nil)),
])))
let type = Schema.ComplexType(content: .elementOnly(particle))
#expect(validate(element("a", children: [.element(element("b"))]), type).isEmpty)
#expect(validate(element("a", children: [.element(element("c"))]), type).first?.reason == "content does not match the content model")
}
// MARK: Nillable and fixed
@Test("Nillable: xsi:nil on a non-nillable element is rejected")
func test_notNillable() {
let xsi = ("xsi:nil", "true")
let type = Schema.ComplexType(content: .empty)
// Default validator declares no nillable elements.
#expect(validate(element("a", [xsi]), type).contains { $0.reason.contains("not nillable") })
// A validator that declares it nillable accepts the empty nilled element.
let nillable = Schema.ComplexValidator(nillableElements: ["a"])
#expect(validate(element("a", [xsi]), type, validator: nillable).isEmpty)
}
@Test("Fixed: an element fixed value must match")
func test_fixedElement() {
let validator = Schema.ComplexValidator(elementConstraints: ["a": .fixed("yes")])
let type = Schema.ComplexType(content: .simpleContent(Schema.SimpleType(base: .string)))
#expect(validate(element("a", children: [.text("yes")]), type, validator: validator).isEmpty)
#expect(validate(element("a", children: [.text("no")]), type, validator: validator).contains { $0.reason.contains("fixed") })
}
}