The tagged-node validator carried its own copy of the validation machinery: a Tagged.Rule cloning Validation's check type and a Tagged.Context cloning its context, differing only in subject type. That is the per-layer duplication the validation idiom exists to prevent (principle 10). Generalise the type-directed framework over both the subject and the document: SubjectContext, Check, and AnyCheck now take a Document type parameter for the validated tree's root. The model layer validates with a Model.Value document, the tagged layer with a Tagged.Node document, each truthful rather than forcing one root type to stand in for the other. The tagged layer keeps only what is genuinely its own: the Node document type, its built-in check (now Tagged.BuiltinCheck, a Check<Node, Node>), and its traversal. Tagged.Rule and the tagged Context are removed. The validator surface is unchanged but validating now takes a Check<Node, Node>. All 399 tests pass; the coverage gate reconciles to 62 public types.
92 lines
3.7 KiB
Swift
92 lines
3.7 KiB
Swift
@testable import PureYAML
|
|
import Testing
|
|
|
|
private struct Address: PureYAML.Validation.Validatable {
|
|
var city: String
|
|
}
|
|
|
|
private struct Person: PureYAML.Validation.Validatable {
|
|
var name: String
|
|
var age: Int
|
|
var address: Address
|
|
}
|
|
|
|
@Suite("Validation combinators")
|
|
struct ValidationCombinatorTests {
|
|
private typealias Context = PureYAML.Validation.SubjectContext<Person, PureYAML.Model.Value>
|
|
|
|
private func context(name: String = "Ada", age: Int = 30, city: String = "Lovelace") -> Context {
|
|
.init(document: .null, subject: Person(name: name, age: age, address: Address(city: city)), path: .root)
|
|
}
|
|
|
|
private func failing(_ description: String) -> PureYAML.Validation.Check<Person, PureYAML.Model.Value> {
|
|
.init(description: description) { ctx in
|
|
[PureYAML.Validation.Issue(severity: .error, reason: description, path: ctx.path)]
|
|
}
|
|
}
|
|
|
|
private func passing(_ description: String) -> PureYAML.Validation.Check<Person, PureYAML.Model.Value> {
|
|
.init(description: description) { _ in [] }
|
|
}
|
|
|
|
@Test("Comparison operators lift a key path and a literal into a predicate")
|
|
func test_comparisonOperators() {
|
|
let isAdult = (\Context.subject.age) >= 18
|
|
#expect(isAdult(context(age: 30)) == true)
|
|
#expect(isAdult(context(age: 17)) == false)
|
|
let named = (\Context.subject.name) == "Ada"
|
|
#expect(named(context(name: "Ada")) == true)
|
|
#expect(named(context(name: "Bob")) == false)
|
|
}
|
|
|
|
@Test("take digs to a value and runs arbitrary logic")
|
|
func test_take() {
|
|
let cityIsSet: @Sendable (Context) -> Bool = PureYAML.Validation.take(\Person.address) { !$0.city.isEmpty }
|
|
#expect(cityIsSet(context(city: "Lovelace")) == true)
|
|
#expect(cityIsSet(context(city: "")) == false)
|
|
}
|
|
|
|
@Test("A Bool check built from an operator auto-formats its failure")
|
|
func test_boolCheckFromOperator() {
|
|
let adult = PureYAML.Validation.Check<Person, PureYAML.Model.Value>(
|
|
description: "Person is an adult",
|
|
check: (\Context.subject.age) >= 18,
|
|
)
|
|
#expect(adult.apply(context(age: 30)).isEmpty)
|
|
let issues = adult.apply(context(age: 10))
|
|
#expect(issues.count == 1)
|
|
#expect(issues.first?.reason == "Failed to satisfy: Person is an adult")
|
|
}
|
|
|
|
@Test("all applies every check to the same context, concatenating errors")
|
|
func test_all() {
|
|
let combined = PureYAML.Validation.all(failing("one"), passing("ok"), failing("two"))
|
|
#expect(combined.apply(context()).map(\.reason) == ["one", "two"])
|
|
}
|
|
|
|
@Test("&& concatenates both error lists")
|
|
func test_andConcatenates() {
|
|
let combined = failing("a") && failing("b")
|
|
#expect(combined.apply(context()).map(\.reason) == ["a", "b"])
|
|
#expect((passing("a") && passing("b")).apply(context()).isEmpty)
|
|
}
|
|
|
|
@Test("|| short-circuits and errors only when both fail")
|
|
func test_orShortCircuits() {
|
|
#expect((passing("a") || failing("b")).apply(context()).isEmpty)
|
|
#expect((failing("a") || passing("b")).apply(context()).isEmpty)
|
|
#expect((failing("a") || failing("b")).apply(context()).map(\.reason) == ["a", "b"])
|
|
}
|
|
|
|
@Test("lift runs child-typed checks against a value reached by key path")
|
|
func test_lift() {
|
|
let cityNotEmpty = PureYAML.Validation.Check<Address, PureYAML.Model.Value>(
|
|
description: "City is set",
|
|
check: { !$0.subject.city.isEmpty },
|
|
)
|
|
let lifted = PureYAML.Validation.lift(\Person.address, into: cityNotEmpty)
|
|
#expect(lifted.apply(context(city: "Lovelace")).isEmpty)
|
|
#expect(lifted.apply(context(city: "")).count == 1)
|
|
}
|
|
}
|