From 1fbe359adad5eba89771c8172cd174d7bc274ae4 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 12:00:34 +0200 Subject: [PATCH] feat(validation): composable validation framework + structural rule Slice 1 of #92. Introduce the OpenAPIKit-idiom validation framework, replacing the imperative if-tree structural validator: - ValidationError (reason + coding path) renders " at path: root/row/cell"; ValidationErrorCollection gathers every failure and is thrown once. - Validation is the atomic, composable rule value, with the multi-error and single-error Bool inits (the latter auto-forms "Failed to satisfy: "). Predicate-gated, type-directed via an erased AnyValidation that guards an optional from matching a non-optional rule. - The framework is parameterized over both Subject and Document so the structural, DTD, XSD, and Schematron layers share one machinery (Document = Void for rules that need only their subject). - Validator walks the node tree, building the coding path, with blank / default factories and fluent validating / withoutValidating. - The combinator algebra (&&, ||, comparison lifts, take, lift, unwrap, all) as free functions returning closures. The structural duplicate-attribute rule is reborn as Validation. Combinators and the erased wrapper are unit-tested as pure functions per the rule. The legacy Validation.Issue stays only for the not-yet-migrated schema validators and is retired in a later slice. --- README.md | 7 +- Sources/API.swift | 14 +- Sources/Validation/Issue.swift | 2 + .../Validation/StructuralValidations.swift | 47 ++++++ Sources/Validation/Validation.swift | 73 ++++++++++ .../Validation/ValidationCombinators.swift | 112 +++++++++++++++ Sources/Validation/ValidationCore.swift | 91 ++++++++++++ Sources/Validation/Validator.swift | 134 ++++++++++++------ Tests/ValidationFrameworkTests.swift | 89 ++++++++++++ Tests/ValidationTests.swift | 39 ++++- 10 files changed, 554 insertions(+), 54 deletions(-) create mode 100644 Sources/Validation/StructuralValidations.swift create mode 100644 Sources/Validation/Validation.swift create mode 100644 Sources/Validation/ValidationCombinators.swift create mode 100644 Sources/Validation/ValidationCore.swift create mode 100644 Tests/ValidationFrameworkTests.swift diff --git a/README.md b/README.md index 57be1cd..451dff5 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,9 @@ fetching (`nanohttp`/`nanoftp`) and the threading/memory infrastructure stay out and external resolution is opt-in through an injected resolver so XXE stays closed. The original epics and the documented-subset parity work (#61) are all shipped -(summarized above). A read-only conformance audit then surfaced the remaining -depth gaps versus libxml2, tracked under one epic with per-subsystem children: +(summarized above). The current focus is the differentiator: fault-tolerant +reading and a rich, composable validation layer (#92). A read-only conformance +audit also surfaced the remaining depth gaps versus libxml2 (#71): ```mermaid flowchart TB @@ -64,7 +65,9 @@ flowchart TB classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827 classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827 classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827 + Validation["#92 OpenAPIKit-idiom validation framework + recovering reader: read invalid documents without crashing, return rich located results"]:::epic Gaps["#71 Beyond-parity libxml2 gaps: DTD entities/defaulting (#72 #73), parser breadth (#74), XSD instance/derivation/wildcards/namespaces (#75 #76 #77 #78), RELAX NG + Schematron (#79 #80), XSLT elements/functions (#81 #82), HTML5 full tree (#83 #84), C14N/catalog/XInclude (#85 #86 #87), XPath/XPointer (#88), regex (#89), DOM/serialization (#90 #91)"]:::epic + Validation --> Gaps ``` ## Status diff --git a/Sources/API.swift b/Sources/API.swift index b2460a8..8d470b0 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -125,13 +125,15 @@ public extension PureXML { try Model.TreeNode(parse(xml, limits: limits, resolver: resolver)) } - /// Validates a parsed XML node with the default validation rules. - @discardableResult + /// Validates a parsed XML node with the default structural rules, throwing a + /// ``PureXML/Validation/ValidationErrorCollection`` that locates every failure + /// by coding path. Pass a custom ``PureXML/Validation/Validator`` to add or + /// remove rules, or call ``Validation/Validator/errors(for:)`` to collect the + /// errors without throwing. static func validate( _ node: Model.Node, - using validator: Validation.Validator = .init(), - strict: Bool = true, - ) throws -> [Validation.Issue] { - try validator.validate(node, strict: strict) + using validator: Validation.Validator = .init(), + ) throws { + try validator.validate(node) } } diff --git a/Sources/Validation/Issue.swift b/Sources/Validation/Issue.swift index 9e4eb0d..cedd3ac 100644 --- a/Sources/Validation/Issue.swift +++ b/Sources/Validation/Issue.swift @@ -14,3 +14,5 @@ public extension PureXML.Validation { } } } + +extension PureXML.Validation.Issue: Swift.Error {} diff --git a/Sources/Validation/StructuralValidations.swift b/Sources/Validation/StructuralValidations.swift new file mode 100644 index 0000000..28198e8 --- /dev/null +++ b/Sources/Validation/StructuralValidations.swift @@ -0,0 +1,47 @@ +public extension PureXML.Validation { + /// The schema-independent structural validations: well-formedness rules that + /// hold for any document regardless of a DTD or schema. + enum Structural { + /// An element must not declare the same attribute name twice. Reports one + /// error per duplicate. + public static var uniqueAttributes: Validation { + .init(description: "Element attribute names are unique") { context in + var seen: Set = [] + var errors: [ValidationError] = [] + for attribute in context.subject.attributes { + let key = attribute.name.description + if !seen.insert(key).inserted { + errors.append(ValidationError( + reason: "Duplicate attribute '\(key)' on <\(context.subject.name.description)>", + at: context.codingPath, + )) + } + } + return errors + } + } + + /// The default structural rule set. + static var defaults: [Validation] { + [uniqueAttributes] + } + } +} + +public extension PureXML.Validation.Validator where Document == Void { + /// The default structural validator: schema-independent well-formedness rules. + init() { + self.init(validations: PureXML.Validation.Structural.defaults.map { PureXML.Validation.AnyValidation($0) }) + } + + /// Validates a node with the structural rules, throwing a + /// ``PureXML/Validation/ValidationErrorCollection`` on any failure. + func validate(_ node: PureXML.Model.Node) throws { + try validate(node, in: ()) + } + + /// The structural errors for a node, in document order; empty when valid. + func errors(for node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] { + errors(for: node, in: ()) + } +} diff --git a/Sources/Validation/Validation.swift b/Sources/Validation/Validation.swift new file mode 100644 index 0000000..2b62f56 --- /dev/null +++ b/Sources/Validation/Validation.swift @@ -0,0 +1,73 @@ +public extension PureXML.Validation { + /// An atomic validation: a positive `description` of the correct state, a + /// `validate` function returning one ``ValidationError`` per problem, and a + /// `predicate` that scopes when the rule applies. Validations are composable + /// values, combined with the operator algebra, never imperative if-trees. + /// + /// Specialized on `Subject` (the value type it fires on, anywhere in the tree) + /// and parameterized over `Document` (the cross-cutting context, or `Void` + /// when a rule needs nothing beyond its subject). + struct Validation { + public let validate: (ValidationContext) -> [ValidationError] + public let predicate: (ValidationContext) -> Bool + public let description: String + + /// Runs the validation against `subject`, gating on the predicate first. + public func apply(to subject: Subject, at codingPath: [PathKey], in document: Document) -> [ValidationError] { + let context = ValidationContext(document: document, subject: subject, codingPath: codingPath) + guard predicate(context) else { return [] } + return validate(context) + } + + /// The multi-error form: return one error per problem. Use when a single + /// value can fail in several places at once. + public init( + description: String? = nil, + check validate: @escaping (ValidationContext) -> [ValidationError], + when predicate: @escaping (ValidationContext) -> Bool = { _ in true }, + ) { + self.validate = validate + self.predicate = predicate + self.description = description ?? String(describing: Subject.self) + } + + /// The single-error Bool form (the dominant one): a positive `description` + /// and a check returning `false` when invalid, which auto-produces the + /// error `"Failed to satisfy: "`. + public init( + description: String, + check validate: @escaping (ValidationContext) -> Bool, + when predicate: @escaping (ValidationContext) -> Bool = { _ in true }, + ) { + self.init( + description: description, + check: { context in + validate(context) ? [] : [ValidationError(reason: "Failed to satisfy: \(description)", at: context.codingPath)] + }, + when: predicate, + ) + } + } +} + +extension PureXML.Validation { + /// Erases the subject type of a ``Validation`` so a heterogeneous list can be + /// applied at every node. Filters by runtime type, guarding against an + /// optional satisfying a non-optional validation. + struct AnyValidation { + let description: String + private let applyErased: (Any, [PathKey], Document) -> [ValidationError] + + init(_ validation: Validation) { + description = validation.description + applyErased = { input, codingPath, document in + guard let subject = input as? Subject, type(of: subject) == type(of: input) else { return [] } + return validation.apply(to: subject, at: codingPath, in: document) + } + } + + func apply(to value: Any, at codingPath: [PathKey], in document: Document) -> [ValidationError] { + applyErased(value, codingPath, document) + } + } +} diff --git a/Sources/Validation/ValidationCombinators.swift b/Sources/Validation/ValidationCombinators.swift new file mode 100644 index 0000000..5ddba73 --- /dev/null +++ b/Sources/Validation/ValidationCombinators.swift @@ -0,0 +1,112 @@ +// The declarative combinator algebra for ``PureXML/Validation/Validation``. +// Free functions returning closures, so validations compose by value rather than +// through imperative control flow. Generic over both the subject type and the +// document, per the one-framework-parameterized-over-the-document rule. + +typealias Validatable = PureXML.Validation.Validatable +typealias VContext = + PureXML.Validation.ValidationContext +typealias VError = PureXML.Validation.ValidationError +typealias VRule = + PureXML.Validation.Validation + +// MARK: Context -> error array + +func && ( + lhs: @escaping (VContext) -> [VError], + rhs: @escaping (VContext) -> [VError], +) -> (VContext) -> [VError] { + { context in lhs(context) + rhs(context) } +} + +func || ( + lhs: @escaping (VContext) -> [VError], + rhs: @escaping (VContext) -> [VError], +) -> (VContext) -> [VError] { + { context in + let left = lhs(context) + if left.isEmpty { return [] } + let right = rhs(context) + return right.isEmpty ? [] : left + right + } +} + +// MARK: Context -> Bool + +func && ( + lhs: @escaping (VContext) -> Bool, + rhs: @escaping (VContext) -> Bool, +) -> (VContext) -> Bool { + { context in lhs(context) && rhs(context) } +} + +func || ( + lhs: @escaping (VContext) -> Bool, + rhs: @escaping (VContext) -> Bool, +) -> (VContext) -> Bool { + { context in lhs(context) || rhs(context) } +} + +// MARK: Subject KeyPath -> Bool + +func == (lhs: KeyPath, rhs: U) -> (VContext) -> Bool { + { $0.subject[keyPath: lhs] == rhs } +} + +func != (lhs: KeyPath, rhs: U) -> (VContext) -> Bool { + { $0.subject[keyPath: lhs] != rhs } +} + +func > (lhs: KeyPath, rhs: U) -> (VContext) -> Bool { + { $0.subject[keyPath: lhs] > rhs } +} + +func >= (lhs: KeyPath, rhs: U) -> (VContext) -> Bool { + { $0.subject[keyPath: lhs] >= rhs } +} + +func < (lhs: KeyPath, rhs: U) -> (VContext) -> Bool { + { $0.subject[keyPath: lhs] < rhs } +} + +func <= (lhs: KeyPath, rhs: U) -> (VContext) -> Bool { + { $0.subject[keyPath: lhs] <= rhs } +} + +// MARK: Digging and lifting + +/// Dig to a value by KeyPath and run arbitrary logic on it. +func take(_ path: KeyPath, check: @escaping (U) -> Bool) -> (VContext) -> Bool { + { check($0.subject[keyPath: path]) } +} + +/// Run child-typed validations against a lifted value, keeping the parent path +/// and document. +func lift( + _ path: KeyPath, + into validations: VRule..., +) -> (VContext) -> [VError] { + { context in + validations.flatMap { $0.apply(to: context.subject[keyPath: path], at: context.codingPath, in: context.document) } + } +} + +/// Unwrap an optional, erroring if nil, else run child validations. +func unwrap( + _ path: KeyPath, + into validations: VRule..., + description: String? = nil, +) -> (VContext) -> [VError] { + { context in + guard let subject = context.subject[keyPath: path] else { + let reason = description.map { "Tried to unwrap but found nil: \($0)" } ?? "Tried to unwrap an optional and found nil" + return [VError(reason: reason, at: context.codingPath)] + } + return validations.flatMap { $0.apply(to: subject, at: context.codingPath, in: context.document) } + } +} + +/// Apply many validations to the same context (`lift` with `\.self`). +func all(_ validations: VRule...) -> (VContext) -> [VError] { + { context in validations.flatMap { $0.apply(to: context.subject, at: context.codingPath, in: context.document) } } +} diff --git a/Sources/Validation/ValidationCore.swift b/Sources/Validation/ValidationCore.swift new file mode 100644 index 0000000..73bdbb8 --- /dev/null +++ b/Sources/Validation/ValidationCore.swift @@ -0,0 +1,91 @@ +public extension PureXML.Validation { + /// A marker for values a ``Validation`` can specialize on. Conform the model + /// types (and the containers traversed) so a `Validation` + /// can fire on every `Subject` in the tree. + protocol Validatable {} + + /// One step of a validation coding path: an element name (with an optional + /// sibling index) or an attribute (`@name`). Conforms to the standard-library + /// `CodingKey`, so paths render and thread without bookkeeping. + struct PathKey: CodingKey, Equatable, Sendable { + public var stringValue: String + public var intValue: Int? + + public init(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + public init?(intValue: Int) { + stringValue = String(intValue) + self.intValue = intValue + } + + init(stringValue: String, intValue: Int?) { + self.stringValue = stringValue + self.intValue = intValue + } + + /// An element step, optionally carrying its position among same-named + /// siblings (rendered `name[index]`). + static func element(_ name: String, index: Int? = nil) -> PathKey { + PathKey(stringValue: name, intValue: index) + } + + /// An attribute step, rendered `@name`. + static func attribute(_ name: String) -> PathKey { + PathKey(stringValue: "@\(name)", intValue: nil) + } + + /// Renders a path the XML way: `root/item[2]/@id`. + static func render(_ path: [PathKey]) -> String { + path.map { key in + key.intValue.map { "\(key.stringValue)[\($0)]" } ?? key.stringValue + }.joined(separator: "/") + } + } + + /// The read-only bundle every check receives: the whole `Document` (the + /// cross-cutting context a rule may consult), the current `Subject` value, and + /// the coding path where it sits. + struct ValidationContext { + public let document: Document + public let subject: Subject + public let codingPath: [PathKey] + } + + /// A validation failure: a reason and the coding path where it occurred. The + /// description strips a trailing period and renders the location consistently. + struct ValidationError: Swift.Error, Equatable, Sendable, CustomStringConvertible { + public let reason: String + public let codingPath: [PathKey] + + public init(reason: String, at path: [PathKey]) { + self.reason = reason + codingPath = path + } + + public var description: String { + let reasonText = reason.hasSuffix(".") ? String(reason.dropLast()) : reason + let path = PathKey.render(codingPath) + return path.isEmpty ? "\(reasonText) at root of document" : "\(reasonText) at path: \(path)" + } + } + + /// The one value thrown at the end of validation, holding every error found. + struct ValidationErrorCollection: Swift.Error, Equatable, Sendable, CustomStringConvertible { + public let values: [ValidationError] + + public init(values: [ValidationError]) { + self.values = values + } + + public var description: String { + values.map(\.description).joined(separator: "\n") + } + } +} + +extension PureXML.Model.Node: PureXML.Validation.Validatable {} +extension PureXML.Model.Element: PureXML.Validation.Validatable {} +extension PureXML.Model.Attribute: PureXML.Validation.Validatable {} diff --git a/Sources/Validation/Validator.swift b/Sources/Validation/Validator.swift index f958039..b586542 100644 --- a/Sources/Validation/Validator.swift +++ b/Sources/Validation/Validator.swift @@ -1,58 +1,112 @@ public extension PureXML.Validation { - /// Path-aware validation over a parsed XML tree. + /// Applies a set of ``Validation`` values across a parsed node tree, gathering + /// every error with its coding path and throwing one ``ValidationErrorCollection`` + /// at the end. Holds an ordered list of erased validations (a layer's default + /// set plus any custom additions). Build one with ``blank`` and the fluent + /// ``validating(_:)``/``withoutValidating(_:)`` methods, or take a layer's + /// default factory. /// - /// The default validator checks structural rules that do not depend on a - /// schema: an element must not declare the same attribute name twice. Schema - /// (DTD, XSD, RELAX NG) validation is intentionally out of scope for the - /// library target and is layered on by callers. - struct Validator: Sendable { - public init() {} + /// Parameterized over `Document`, the cross-cutting context the rules consult + /// (a schema, a scope, or `Void` for rules that need only their subject). + struct Validator { + var validations: [AnyValidation] - /// Collects validation issues for a node tree. - public func collect(_ node: PureXML.Model.Node) -> [Issue] { - var issues: [Issue] = [] - walk(node, into: &issues) - return issues + init(validations: [AnyValidation]) { + self.validations = validations } - /// Validates a node tree, throwing the first error-severity issue. - @discardableResult - public func validate(_ node: PureXML.Model.Node, strict: Bool = true) throws -> [Issue] { - let issues = collect(node) - if let error = issues.first(where: { $0.severity == .error }) { - throw error - } - if strict, let warning = issues.first(where: { $0.severity == .warning }) { - throw warning - } - return issues + /// A validator with no rules; add your own with ``validating(_:)``. + public static var blank: Validator { + Validator(validations: []) } - private func walk(_ node: PureXML.Model.Node, into issues: inout [Issue]) { + /// The descriptions of the active rules, in application order. + public var validationDescriptions: [String] { + validations.map(\.description) + } + + /// Returns a validator with `additions` appended after the current rules. + public func validating(_ additions: Validation...) -> Validator { + Validator(validations: validations + additions.map { AnyValidation($0) }) + } + + /// Returns a validator with a single-error Bool rule appended. + public func validating( + _ description: String, + check: @escaping (ValidationContext) -> Bool, + when predicate: @escaping (ValidationContext) -> Bool = { _ in true }, + ) -> Validator { + validating(Validation(description: description, check: check, when: predicate)) + } + + /// Returns a validator with the rules matching `descriptions` removed. + public func withoutValidating(_ descriptions: String...) -> Validator { + Validator(validations: validations.filter { !descriptions.contains($0.description) }) + } + + // MARK: Running + + /// Gathers every error from validating `node` in `document`, in document + /// order. Returns an empty array when the tree is valid. + public func errors(for node: PureXML.Model.Node, in document: Document) -> [ValidationError] { + var errors: [ValidationError] = [] + walk(node, path: [], document, &errors) + return errors + } + + /// Validates `node` in `document`, throwing a ``ValidationErrorCollection`` + /// if any rule fails. + public func validate(_ node: PureXML.Model.Node, in document: Document) throws { + let found = errors(for: node, in: document) + if !found.isEmpty { throw ValidationErrorCollection(values: found) } + } + + private func walk(_ node: PureXML.Model.Node, path: [PathKey], _ document: Document, _ errors: inout [ValidationError]) { + apply(node, at: path, in: document, into: &errors) switch node { case let .document(children): - for child in children { - walk(child, into: &issues) - } + walkChildren(children, path: path, document, &errors) case let .element(element): - var seen: Set = [] + apply(element, at: path, in: document, into: &errors) for attribute in element.attributes { - let key = attribute.name.description - if !seen.insert(key).inserted { - issues.append(.init( - severity: .error, - message: "duplicate attribute '\(key)' on <\(element.name.description)>", - )) - } - } - for child in element.children { - walk(child, into: &issues) + apply(attribute, at: path + [.attribute(attribute.name.localName)], in: document, into: &errors) } + walkChildren(element.children, path: path, document, &errors) case .text, .cdata, .comment, .processingInstruction: break } } + + /// Recurses element children, extending the path by `name` (and a sibling + /// index only when more than one child shares that name). + private func walkChildren( + _ children: [PureXML.Model.Node], + path: [PathKey], + _ document: Document, + _ errors: inout [ValidationError], + ) { + var totals: [String: Int] = [:] + for child in children { + if case let .element(element) = child { totals[element.name.description, default: 0] += 1 } + } + var seen: [String: Int] = [:] + for child in children { + guard case let .element(element) = child else { + walk(child, path: path, document, &errors) + continue + } + let name = element.name.description + let index = (seen[name] ?? 0) + 1 + seen[name] = index + let step: PathKey = (totals[name] ?? 0) > 1 ? .element(name, index: index) : .element(name) + walk(child, path: path + [step], document, &errors) + } + } + + private func apply(_ value: some Validatable, at path: [PathKey], in document: Document, into errors: inout [ValidationError]) { + for validation in validations { + errors += validation.apply(to: value, at: path, in: document) + } + } } } - -extension PureXML.Validation.Issue: Swift.Error {} diff --git a/Tests/ValidationFrameworkTests.swift b/Tests/ValidationFrameworkTests.swift new file mode 100644 index 0000000..ce181ea --- /dev/null +++ b/Tests/ValidationFrameworkTests.swift @@ -0,0 +1,89 @@ +@testable import PureXML +import Testing + +/// Unit tests for the validation framework primitives: the `Validation` value, +/// the erased wrapper's type guard, and the combinator algebra exercised as pure +/// functions against hand-built contexts (no full traversal). +@Suite("Validation framework") +struct ValidationFrameworkTests { + private typealias Element = PureXML.Model.Element + private typealias Error = PureXML.Validation.ValidationError + private typealias Context = PureXML.Validation.ValidationContext + + private func context(_ element: Element, path: [PureXML.Validation.PathKey] = []) -> Context { + Context(document: (), subject: element, codingPath: path) + } + + private let sample = PureXML.Model.Element("e", attributes: [.init("a", "1")]) + + @Test("The single-error Bool form auto-formats the failure") + func test_boolForm() { + let rule = PureXML.Validation.Validation( + description: "Element is named box", + check: \Element.name.localName == "box", + ) + #expect(rule.apply(to: sample, at: [.element("e")], in: ()).first?.reason == "Failed to satisfy: Element is named box") + let box = PureXML.Model.Element("box") + #expect(rule.apply(to: box, at: [], in: ()).isEmpty) + } + + @Test("The predicate gates the check") + func test_predicateGate() { + var ran = false + let rule = PureXML.Validation.Validation( + description: "never", + check: { _ in ran = true + return [] + }, + when: \Element.name.localName == "other", + ) + _ = rule.apply(to: sample, at: [], in: ()) + #expect(ran == false) + } + + @Test("The erased wrapper fires only on the exact type") + func test_erasureTypeGuard() { + let rule = PureXML.Validation.Validation(description: "always fails", check: { _ in false }) + let erased = PureXML.Validation.AnyValidation(rule) + // Wrong type yields no errors. + #expect(erased.apply(to: "a string", at: [], in: ()).isEmpty) + // An optional wrapping the type must not satisfy a non-optional validation. + let optional: Element? = sample + #expect(erased.apply(to: optional as Any, at: [], in: ()).isEmpty) + // The exact type runs. + #expect(erased.apply(to: sample, at: [], in: ()).count == 1) + } + + @Test("&& concatenates both error lists; || short-circuits") + func test_andOr() { + let fail: (Context) -> [Error] = { [Error(reason: "x", at: $0.codingPath)] } + let pass: (Context) -> [Error] = { _ in [] } + #expect((fail && fail)(context(sample)).count == 2) + #expect((pass || fail)(context(sample)).isEmpty) + #expect((fail || fail)(context(sample)).count == 2) + } + + @Test("take digs to a value and runs logic") + func test_take() { + let check: (Context) -> Bool = take(\Element.attributes) { $0.count == 1 } + #expect(check(context(sample))) + #expect(!check(context(PureXML.Model.Element("e")))) + } + + @Test("all applies many validations to the same context") + func test_all() { + let one = PureXML.Validation.Validation(description: "one", check: { _ in false }) + let two = PureXML.Validation.Validation(description: "two", check: { _ in false }) + let combined: (Context) -> [Error] = all(one, two) + #expect(combined(context(sample, path: [.element("e")])).count == 2) + } + + @Test("withoutValidating removes a rule by description") + func test_withoutValidating() { + let validator = PureXML.Validation.Validator.blank + .validating(PureXML.Validation.Structural.uniqueAttributes) + #expect(validator.validationDescriptions == ["Element attribute names are unique"]) + let stripped = validator.withoutValidating("Element attribute names are unique") + #expect(stripped.validationDescriptions.isEmpty) + } +} diff --git a/Tests/ValidationTests.swift b/Tests/ValidationTests.swift index c7ef740..498a1cb 100644 --- a/Tests/ValidationTests.swift +++ b/Tests/ValidationTests.swift @@ -3,15 +3,16 @@ import Testing @Suite("Validation") struct ValidationTests { - @Test("Duplicate attribute is an error") + @Test("Duplicate attribute is one located error") func test_duplicateAttributeIsError() { let element = PureXML.Model.Element( "input", attributes: [.init("id", "a"), .init("id", "b")], ) - let issues = PureXML.Validation.Validator().collect(.element(element)) - #expect(issues.count == 1) - #expect(issues.first?.severity == .error) + let errors = PureXML.Validation.Validator().errors(for: .element(element)) + #expect(errors.count == 1) + #expect(errors.first?.reason == "Duplicate attribute 'id' on ") + #expect(errors.first?.codingPath.isEmpty == true) } @Test("Unique attributes validate cleanly") @@ -20,7 +21,33 @@ struct ValidationTests { "input", attributes: [.init("id", "a"), .init("name", "b")], ) - let issues = try PureXML.validate(.element(element)) - #expect(issues.isEmpty) + try PureXML.validate(.element(element)) + } + + @Test("A located failure renders its coding path") + func test_errorRendersPath() { + let bad = PureXML.Model.Element("cell", attributes: [.init("id", "a"), .init("id", "b")]) + let root = PureXML.Model.Element("table", children: [ + .element(PureXML.Model.Element("row", children: [.element(bad)])), + ]) + // Parse output is a document node, so the root element is named in the path. + let errors = PureXML.Validation.Validator().errors(for: .document([.element(root)])) + #expect(errors.count == 1) + #expect(String(describing: errors[0]) == "Duplicate attribute 'id' on at path: table/row/cell") + } + + @Test("Throwing form collects errors into one collection") + func test_throwsCollection() { + let element = PureXML.Model.Element("x", attributes: [.init("a", "1"), .init("a", "2")]) + #expect(throws: PureXML.Validation.ValidationErrorCollection.self) { + try PureXML.validate(.element(element)) + } + } + + @Test("A blank validator runs no rules") + func test_blankValidator() { + let element = PureXML.Model.Element("x", attributes: [.init("a", "1"), .init("a", "2")]) + let validator = PureXML.Validation.Validator.blank + #expect(validator.errors(for: .element(element), in: ()).isEmpty) } }