PureXML/Tests/XSDCategoryTests.swift
Mihaela Mihaljevic 2a9a5afaa9 fix(schema): a non-nillable element must carry no xsi:nil at all (cvc-elt.3.1)
cvc-elt.3.1: when an element's declaration is not nillable, the xsi:nil attribute
is forbidden entirely, whatever its value. PureXML only rejected xsi:nil="true" on
a non-nillable element, so xsi:nil="false" slipped through. ComplexValidator.nilErrors
now flags the presence of any xsi:nil on a non-nillable element via the new
nilAttributeValue helper, while a nillable element still accepts xsi:nil="false"
(explicitly not nilled, valid empty-or-content per its type).

XSTS invalid-instances-accepted 28 -> 26 (both xsi:nil-on-non-nillable cases), no
other bucket moved (valid-schemas-rejected and valid-instances-rejected held at 0
across all 14383 groups). 1452 unit tests pass.
2026-06-20 03:22:05 +02:00

113 lines
5.8 KiB
Swift

import Testing
@testable import PureXML
/// 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 located with an expected hint")
func test_contentModelMismatch() {
let particle = Schema.Particle(term: .group(Schema.Group(compositor: .sequence, particles: [
Schema.Particle(term: .element(name: Name("b"), type: nil, typeName: nil)),
])))
let type = Schema.ComplexType(content: .elementOnly(particle))
#expect(validate(element("a", children: [.element(element("b"))]), type).isEmpty)
let failure = validate(element("a", children: [.element(element("c"))]), type).first
#expect(failure?.reason == "element 'c' is not allowed here; expected <b>")
#expect(failure?.codingPath.map(\.stringValue) == ["a", "c"])
}
// 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("Nillable: xsi:nil='false' is also forbidden on a non-nillable element (cvc-elt.3.1)")
func test_notNillableNilFalse() {
let nilFalse = ("xsi:nil", "false")
let type = Schema.ComplexType(content: .empty)
// cvc-elt.3.1: a non-nillable element must carry no xsi:nil attribute at all,
// so even xsi:nil="false" is rejected (not only "true").
#expect(validate(element("a", [nilFalse]), type).contains { $0.reason.contains("not nillable") })
// A nillable element may carry xsi:nil="false" (explicitly not nilled): accepted.
let nillable = Schema.ComplexValidator(nillableElements: ["a"])
#expect(validate(element("a", [nilFalse]), 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") })
}
}