PureYAML/Tests/ValidationCombinatorTests.swift
Mihaela Mihaljevic bf2dc4ece7 refactor(validation): rename framework to match the validation idiom
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).
2026-07-12 18:10:00 +02:00

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")
}
}