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).
115 lines
5.3 KiB
Swift
115 lines
5.3 KiB
Swift
@testable import PureYAML
|
|
import Testing
|
|
|
|
/// `Optional<Mapping>` does not conform to `Validatable` in production (an Optional must never
|
|
/// satisfy a non-Optional check); this test-only alias and conformance let a machinery test box one
|
|
/// into the erased `any Validatable` boundary to prove the runtime `type(of:)` guard rejects it.
|
|
private typealias OptionalMapping = PureYAML.Model.Mapping?
|
|
extension PureYAML.Model.Mapping?: PureYAML.Validation.Validatable {}
|
|
|
|
@Suite("Type-directed check machinery")
|
|
struct ValidationCheckMachineryTests {
|
|
private typealias Check = PureYAML.Validation.Validation<PureYAML.Model.Mapping, PureYAML.Model.Value>
|
|
private typealias AnyValidation = PureYAML.Validation.AnyValidation<PureYAML.Model.Value>
|
|
private typealias Issue = PureYAML.Validation.ValidationError
|
|
|
|
private var mapping: PureYAML.Model.Mapping {
|
|
.init([.init(key: "a", value: .int(1))])
|
|
}
|
|
|
|
private func alwaysFails(description: String = "always fails") -> Check {
|
|
Check(description: description) { context in
|
|
[Issue(severity: .error, reason: "boom", path: context.path)]
|
|
}
|
|
}
|
|
|
|
// MARK: machinery negatives (the dispatch must not fire where it should not)
|
|
|
|
@Test("A check fires on its subject type and produces its error")
|
|
func test_firesOnSubjectType() {
|
|
let erased = AnyValidation(alwaysFails())
|
|
#expect(erased.apply(to: mapping, at: .root, in: .null).count == 1)
|
|
}
|
|
|
|
@Test("A check does not fire on a value of another type")
|
|
func test_doesNotFireOnWrongType() {
|
|
let erased = AnyValidation(alwaysFails())
|
|
// A Model.Value (even one wrapping a mapping) is not a Model.Mapping, so nothing fires.
|
|
#expect(erased.apply(to: PureYAML.Model.Value.mapping(mapping), at: .root, in: .null).isEmpty)
|
|
#expect(erased.apply(to: PureYAML.Model.Key.string("a"), at: .root, in: .null).isEmpty)
|
|
}
|
|
|
|
@Test("An Optional wrapping the subject type does not satisfy the non-Optional check")
|
|
func test_optionalDoesNotSatisfyNonOptionalCheck() {
|
|
let erased = AnyValidation(alwaysFails())
|
|
let optional: OptionalMapping = mapping
|
|
#expect(erased.apply(to: optional, at: .root, in: .null).isEmpty)
|
|
// The exact type still fires, so the guard above is not vacuously true.
|
|
#expect(erased.apply(to: mapping, at: .root, in: .null).count == 1)
|
|
}
|
|
|
|
@Test("A false `when` predicate yields no errors")
|
|
func test_falsePredicateYieldsNothing() {
|
|
let gated = Check(description: "gated", check: { _ in
|
|
[Issue(severity: .error, reason: "boom", path: .root)]
|
|
}, when: { _ in false })
|
|
#expect(AnyValidation(gated).apply(to: mapping, at: .root, in: .null).isEmpty)
|
|
}
|
|
|
|
// MARK: positive control (proving the negatives are not vacuous)
|
|
|
|
@Test("The same subject type occurring twice yields two errors")
|
|
func test_sameTypeTwiceYieldsTwoErrors() {
|
|
// A document with two mappings; an always-failing Check<Mapping> must fire on both.
|
|
let document = PureYAML.Model.Value.sequence(.init([
|
|
.mapping(.init([.init(key: "a", value: .int(1))])),
|
|
.mapping(.init([.init(key: "b", value: .int(2))])),
|
|
]))
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(alwaysFails())
|
|
let issues = validator.collect(document).values
|
|
#expect(issues.count == 2)
|
|
}
|
|
|
|
// MARK: the type-directed builtin (failing and a near miss)
|
|
|
|
@Test("The type-directed mappingKeysAreUnique builtin catches duplicate keys")
|
|
func test_builtinCatchesDuplicateKeys() {
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
|
|
PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique,
|
|
)
|
|
#expect(validator.validationDescriptions == ["Mapping keys are unique"])
|
|
#expect(throws: PureYAML.Validation.ValidationErrorCollection.self) {
|
|
try validator.validate(.mapping(.init([
|
|
.init(key: "a", value: .int(1)),
|
|
.init(key: "a", value: .int(2)),
|
|
])))
|
|
}
|
|
}
|
|
|
|
@Test("The builtin passes a near-miss document whose keys are unique")
|
|
func test_builtinPassesUniqueKeys() throws {
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
|
|
PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique,
|
|
)
|
|
// The boundary the rule almost trips: two keys that differ by one character.
|
|
_ = try validator.validate(.mapping(.init([
|
|
.init(key: "a", value: .int(1)),
|
|
.init(key: "b", value: .int(1)),
|
|
])))
|
|
}
|
|
|
|
@Test("Type-directed dispatch reaches a nested mapping")
|
|
func test_dispatchReachesNestedMapping() {
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
|
|
PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique,
|
|
)
|
|
let nested = PureYAML.Model.Value.mapping(.init([
|
|
.init(key: "outer", value: .mapping(.init([
|
|
.init(key: "a", value: .int(1)),
|
|
.init(key: "a", value: .int(2)),
|
|
]))),
|
|
]))
|
|
#expect(!validator.collect(nested).errors.isEmpty)
|
|
}
|
|
}
|