PureYAML/Tests/ValidationCombinatorTests.swift
Mihaela M fc1a6db1dc refactor(validation): fold the tagged validator onto one framework
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.
2026-06-28 10:41:10 +02:00

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