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).
280 lines
10 KiB
Swift
280 lines
10 KiB
Swift
@testable import PureYAML
|
|
import Testing
|
|
|
|
@Suite("Validation Modes")
|
|
struct ValidationModeTests {
|
|
@Test("Custom validation rules report exact errors")
|
|
func test_customValidationRuleErrors() throws {
|
|
let value = try PureYAML.parse(
|
|
"""
|
|
mode: legacy
|
|
nested:
|
|
mode: legacy
|
|
""",
|
|
)
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(legacyModeRule(severity: .error))
|
|
|
|
expectValidationError(value, using: validator) { collection in
|
|
#expect(collection.values == [
|
|
.init(
|
|
severity: .error,
|
|
reason: "Legacy mode is not allowed",
|
|
path: .init([.key("mode")]),
|
|
),
|
|
.init(
|
|
severity: .error,
|
|
reason: "Legacy mode is not allowed",
|
|
path: .init([.key("nested"), .key("mode")]),
|
|
),
|
|
])
|
|
}
|
|
}
|
|
|
|
@Test("Strict validation treats warnings as failures")
|
|
func test_strictValidationTreatsWarningsAsFailures() throws {
|
|
let value = try PureYAML.parse("mode: legacy")
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(legacyModeRule(severity: .warning))
|
|
|
|
expectValidationError(value, using: validator) { collection in
|
|
#expect(collection.values == [
|
|
.init(
|
|
severity: .warning,
|
|
reason: "Legacy mode is not allowed",
|
|
path: .init([.key("mode")]),
|
|
),
|
|
])
|
|
}
|
|
}
|
|
|
|
@Test("Non-strict validation returns warnings")
|
|
func test_nonStrictValidationReturnsWarnings() throws {
|
|
let value = try PureYAML.parse("mode: legacy")
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(legacyModeRule(severity: .warning))
|
|
|
|
let warnings = try PureYAML.validate(value, using: validator, strict: false)
|
|
|
|
#expect(warnings == [
|
|
PureYAML.Validation.ValidationError(
|
|
severity: .warning,
|
|
reason: "Legacy mode is not allowed",
|
|
path: .init([.key("mode")]),
|
|
),
|
|
])
|
|
}
|
|
|
|
@Test("Non-strict validation throws only errors when errors and warnings are mixed")
|
|
func test_nonStrictValidationThrowsOnlyErrorsWhenErrorsAndWarningsAreMixed() throws {
|
|
let value = try PureYAML.parse(
|
|
"""
|
|
title: First
|
|
title: Second
|
|
mode: legacy
|
|
""",
|
|
)
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>()
|
|
.validating(legacyModeRule(severity: .warning))
|
|
|
|
expectValidationError(value, using: validator, strict: false) { collection in
|
|
#expect(collection.values == [
|
|
.init(
|
|
severity: .error,
|
|
reason: "Duplicate mapping key 'title'",
|
|
path: .init([.key("title")]),
|
|
),
|
|
])
|
|
#expect(!collection.values.contains(.init(
|
|
severity: .warning,
|
|
reason: "Legacy mode is not allowed",
|
|
path: .init([.key("mode")]),
|
|
)))
|
|
}
|
|
}
|
|
|
|
@Test("Validation traversal preserves rule order before child paths")
|
|
func test_validationTraversalPreservesRuleOrderBeforeChildPaths() throws {
|
|
let value = try PureYAML.parse(
|
|
"""
|
|
root:
|
|
mode: legacy
|
|
list:
|
|
- mode: legacy
|
|
""",
|
|
)
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank
|
|
.validating(traceRule(label: "first"))
|
|
.validating(traceRule(label: "second"))
|
|
.validating(legacyModeRule(severity: .warning))
|
|
|
|
let result = validator.collect(value)
|
|
|
|
#expect(result.values.map(\.description) == [
|
|
"first at root at root of document",
|
|
"second at root at root of document",
|
|
"first at $.root at path: $.root",
|
|
"second at $.root at path: $.root",
|
|
"first at $.root.mode at path: $.root.mode",
|
|
"second at $.root.mode at path: $.root.mode",
|
|
"Legacy mode is not allowed at path: $.root.mode",
|
|
"first at $.list at path: $.list",
|
|
"second at $.list at path: $.list",
|
|
"first at $.list[0] at path: $.list[0]",
|
|
"second at $.list[0] at path: $.list[0]",
|
|
"first at $.list[0].mode at path: $.list[0].mode",
|
|
"second at $.list[0].mode at path: $.list[0].mode",
|
|
"Legacy mode is not allowed at path: $.list[0].mode",
|
|
])
|
|
}
|
|
|
|
@Test("Validation rule predicate prevents rule from running")
|
|
func test_validationRulePredicatePreventsRuleFromRunning() throws {
|
|
let value = try PureYAML.parse("mode: legacy")
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
|
|
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
|
|
description: "Never runs",
|
|
check: { context in
|
|
[
|
|
PureYAML.Validation.ValidationError(
|
|
severity: .error,
|
|
reason: "Should not appear",
|
|
path: context.path,
|
|
),
|
|
]
|
|
},
|
|
when: { _ in false },
|
|
),
|
|
)
|
|
|
|
let result = validator.collect(value)
|
|
|
|
#expect(result == PureYAML.Validation.Result())
|
|
#expect(result.isValid)
|
|
}
|
|
|
|
@Test("Validation rule predicate filters applied subjects")
|
|
func test_validationRulePredicateFiltersAppliedSubjects() throws {
|
|
let value = try PureYAML.parse(
|
|
"""
|
|
mode: modern
|
|
nested:
|
|
mode: legacy
|
|
label: legacy
|
|
""",
|
|
)
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
|
|
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
|
|
description: "Legacy mode is not allowed",
|
|
check: { context in
|
|
[
|
|
PureYAML.Validation.ValidationError(
|
|
severity: .error,
|
|
reason: "Legacy mode is not allowed",
|
|
path: context.path,
|
|
),
|
|
]
|
|
},
|
|
when: { context in
|
|
context.path.components.last == .key("mode")
|
|
&& context.subject == .string("legacy")
|
|
},
|
|
),
|
|
)
|
|
|
|
let result = validator.collect(value)
|
|
|
|
#expect(result.values == [
|
|
.init(
|
|
severity: .error,
|
|
reason: "Legacy mode is not allowed",
|
|
path: .init([.key("nested"), .key("mode")]),
|
|
),
|
|
])
|
|
#expect(!result.values.contains(.init(
|
|
severity: .error,
|
|
reason: "Legacy mode is not allowed",
|
|
path: .init([.key("nested"), .key("label")]),
|
|
)))
|
|
}
|
|
|
|
@Test("Validation rules can inspect root subject and path deterministically")
|
|
func test_validationRulesCanInspectRootSubjectAndPathDeterministically() throws {
|
|
let value = try PureYAML.parse(
|
|
"""
|
|
required_prefix: prod
|
|
routes:
|
|
- name: prod-users
|
|
- name: test-status
|
|
- label: test-ignored
|
|
""",
|
|
)
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
|
|
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
|
|
description: "Route names use the root prefix",
|
|
check: { context in
|
|
guard case let .mapping(root) = context.document,
|
|
case let .string(prefix)? = root["required_prefix"],
|
|
case let .string(name) = context.subject,
|
|
name.hasPrefix("\(prefix)-")
|
|
else {
|
|
return [
|
|
PureYAML.Validation.ValidationError(
|
|
severity: .error,
|
|
reason: "Route name does not use root prefix",
|
|
path: context.path,
|
|
),
|
|
]
|
|
}
|
|
return []
|
|
},
|
|
when: { context in
|
|
context.path.components.last == .key("name")
|
|
},
|
|
),
|
|
)
|
|
|
|
let result = validator.collect(value)
|
|
|
|
#expect(result.values == [
|
|
.init(
|
|
severity: .error,
|
|
reason: "Route name does not use root prefix",
|
|
path: .init([.key("routes"), .index(1), .key("name")]),
|
|
),
|
|
])
|
|
#expect(!result.values.contains(.init(
|
|
severity: .error,
|
|
reason: "Route name does not use root prefix",
|
|
path: .init([.key("routes"), .index(2), .key("label")]),
|
|
)))
|
|
}
|
|
|
|
@Test("Boolean validation rule reports exact default failure reason")
|
|
func test_booleanValidationRuleReportsExactDefaultFailureReason() throws {
|
|
let value = try PureYAML.parse(
|
|
"""
|
|
title: Example
|
|
state: draft
|
|
""",
|
|
)
|
|
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
|
|
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
|
|
description: "State is published",
|
|
check: { context in context.subject == .string("published") },
|
|
when: { context in context.path.components.last == .key("state") },
|
|
),
|
|
)
|
|
|
|
expectValidationError(value, using: validator) { collection in
|
|
#expect(collection.values == [
|
|
.init(
|
|
severity: .error,
|
|
reason: "Failed to satisfy: State is published",
|
|
path: .init([.key("state")]),
|
|
),
|
|
])
|
|
}
|
|
|
|
let valid = try PureYAML.parse("state: published")
|
|
#expect(try PureYAML.validate(valid, using: validator).isEmpty)
|
|
}
|
|
}
|