PureXML/Tests/XSDLocatedContentTests.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

61 lines
3.0 KiB
Swift

@testable import PureXML
import Testing
@Suite("XSD located content-model errors")
struct XSDLocatedContentTests {
private typealias Schema = PureXML.Schema
private typealias Name = PureXML.Model.QualifiedName
private func element(_ name: String, children: [PureXML.Model.Node] = []) -> PureXML.Model.Element {
PureXML.Model.Element(name: Name(localName: name), attributes: [], children: children)
}
private func validate(_ element: PureXML.Model.Element, _ type: Schema.ComplexType) -> [PureXML.Validation.ValidationError] {
Schema.ComplexValidator().validate(element, against: type)
}
private func sequence(_ names: [String]) -> Schema.ComplexType {
let particles = names.map { Schema.Particle(term: .element(name: Name(localName: $0), type: nil)) }
return Schema.ComplexType(content: .elementOnly(Schema.Particle(term: .group(Schema.Group(compositor: .sequence, particles: particles)))))
}
@Test("An unexpected child is located with the expected element named")
func test_unexpectedLocated() {
let type = sequence(["a", "b"])
let bad = element("root", children: [.element(element("a")), .element(element("x"))])
let failure = validate(bad, type).first
#expect(failure?.reason == "element 'x' is not allowed here; expected <b>")
#expect(failure?.codingPath.map(\.stringValue) == ["x"])
}
@Test("Missing required content reports what was expected, at the element")
func test_incompleteLocated() {
let type = sequence(["a", "b"])
let bad = element("root", children: [.element(element("a"))])
let failure = validate(bad, type).first
#expect(failure?.reason == "content is incomplete; expected <b>")
#expect(failure?.codingPath.isEmpty == true)
}
@Test("A valid sequence produces no content errors")
func test_valid() {
let type = sequence(["a", "b"])
let good = element("root", children: [.element(element("a")), .element(element("b"))])
#expect(validate(good, type).isEmpty)
}
@Test("An xs:all group reports every stray child and every missing member (recovery)")
func test_allRecovery() {
let members = ["a", "b"].map {
Schema.Particle(minOccurs: 1, maxOccurs: 1, term: .element(name: Name(localName: $0), type: nil))
}
let type = Schema.ComplexType(content: .elementOnly(Schema.Particle(term: .group(Schema.Group(compositor: .all, particles: members)))))
// Two stray children, and member b never appears: three located problems.
let bad = element("root", children: [.element(element("x")), .element(element("y")), .element(element("a"))])
let reasons = validate(bad, type).map(\.reason)
#expect(reasons.contains { $0.contains("'x'") && $0.contains("not allowed") })
#expect(reasons.contains { $0.contains("'y'") && $0.contains("not allowed") })
#expect(reasons.contains { $0.contains("'b'") && $0.contains("required but missing") })
}
}