Merge epic/101-validator-decomposition: scope decision + category isolation tests
Closes #101
This commit is contained in:
commit
c29bf69f07
@ -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> {
|
||||
|
||||
98
Tests/XSDCategoryTests.swift
Normal file
98
Tests/XSDCategoryTests.swift
Normal 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") })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user