The validation module already implemented the composable-rule idiom under its own names (Check/Issue/SubjectContext) and a value-type Validator with duplicated traversal machinery per layer. Renamed the core types to the canonical names (Validation/ValidationError/ValidationErrorCollection/ ValidationContext/BuiltinValidation/AnyValidation), converted Validator to a reference-type class with the two-tier default-rule split, and genericized it over both Subject and Document via a new Traversable protocol so the model and tagged layers share one walk engine instead of two copies. Wired the JSON-representability rule set into the same shape and closed the two missing pieces (unwrap combinator, JSON coverage/config-pin tests).
121 lines
4.8 KiB
Swift
121 lines
4.8 KiB
Swift
@testable import PureYAML
|
|
import Testing
|
|
|
|
private struct Address: PureYAML.Validation.Validatable {
|
|
var city: String
|
|
}
|
|
|
|
private struct Nickname: PureYAML.Validation.Validatable {
|
|
var value: String
|
|
}
|
|
|
|
private struct Person: PureYAML.Validation.Validatable {
|
|
var name: String
|
|
var age: Int
|
|
var address: Address
|
|
var nickname: Nickname?
|
|
}
|
|
|
|
@Suite("Validation combinators")
|
|
struct ValidationCombinatorTests {
|
|
private typealias Context = PureYAML.Validation.ValidationContext<Person, PureYAML.Model.Value>
|
|
|
|
private func context(name: String = "Ada", age: Int = 30, city: String = "Lovelace", nickname: String? = nil) -> Context {
|
|
.init(
|
|
document: .null,
|
|
subject: Person(name: name, age: age, address: Address(city: city), nickname: nickname.map(Nickname.init)),
|
|
path: .root,
|
|
)
|
|
}
|
|
|
|
private func failing(_ description: String) -> PureYAML.Validation.Validation<Person, PureYAML.Model.Value> {
|
|
.init(description: description) { ctx in
|
|
[PureYAML.Validation.ValidationError(severity: .error, reason: description, path: ctx.path)]
|
|
}
|
|
}
|
|
|
|
private func passing(_ description: String) -> PureYAML.Validation.Validation<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.Validation<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.Validation<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)
|
|
}
|
|
|
|
@Test("unwrap errors on nil and otherwise runs child checks against the unwrapped value")
|
|
func test_unwrap() {
|
|
let notEmpty = PureYAML.Validation.Validation<Nickname, PureYAML.Model.Value>(
|
|
description: "Nickname is not empty",
|
|
check: { !$0.subject.value.isEmpty },
|
|
)
|
|
let unwrapped = PureYAML.Validation.unwrap(\Person.nickname, into: notEmpty)
|
|
|
|
let onNil = unwrapped.apply(context(nickname: nil))
|
|
#expect(onNil.count == 1)
|
|
#expect(onNil.first?.reason.contains("nil") == true)
|
|
|
|
let onValid = unwrapped.apply(context(nickname: "Ada"))
|
|
#expect(onValid.isEmpty)
|
|
|
|
let onInvalid = unwrapped.apply(context(nickname: ""))
|
|
#expect(onInvalid.count == 1)
|
|
#expect(onInvalid.first?.reason == "Failed to satisfy: Nickname is not empty")
|
|
}
|
|
}
|