PureXML/Tests/XSDCategoryTests.swift
Mihaela Mihaljevic ea5387db90 feat(xsd): located, recovering content-model errors
A content-model violation is now placed at the offending child with a
recovery hint naming what was expected, instead of one opaque "content
does not match the content model" per element. The sequence/choice
automaton is walked child by child via its follow-set to flag the first
child the model rejects, or the incomplete content at the end; an xs:all
group recovers past each stray child and reports every missing required
member. Well-placed children are still validated for their own content,
so an editor sees every problem at once. Brings XSD to the same located,
recovering standard as the RELAX NG, DTD, and Schematron validators.
2026-06-09 00:55:17 +02:00

101 lines
5.1 KiB
Swift

@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 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)),
])))
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("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") })
}
}