PureYAML/Tests/ValidationModeTests.swift
Mihaela M d195dd3fa1 refactor(validation): retire the legacy Rule for one check type
A Rule was exactly a Check<Model.Value, Model.Value>: it fired on every
node, switched on the value itself, and carried the root. It was therefore a
second copy of the one check machinery, the duplication the validation idiom
exists to prevent (principle 10), now also resolved for the tagged layer.

Remove Rule and its Context. The model validator holds only checks; its
default is the type-directed BuiltinCheck.mappingKeysAreUnique; validating
takes a Check. Custom whole-tree rules become Check<Model.Value, Model.Value>
with the former context.root now context.document.

Behaviour is byte-identical: the mapping-key builtin reports the same issues
at the same paths in the same DFS order, so the public validate/collect/blank
surface is unchanged. All 399 tests pass; the coverage gate reconciles to 60
public types.
2026-06-28 16:43:31 +02:00

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.blank.validating(legacyModeRule(severity: .error))
expectValidationError(value, using: validator) { collection in
#expect(collection.issues == [
.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.blank.validating(legacyModeRule(severity: .warning))
expectValidationError(value, using: validator) { collection in
#expect(collection.issues == [
.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.blank.validating(legacyModeRule(severity: .warning))
let warnings = try PureYAML.validate(value, using: validator, strict: false)
#expect(warnings == [
PureYAML.Validation.Issue(
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()
.validating(legacyModeRule(severity: .warning))
expectValidationError(value, using: validator, strict: false) { collection in
#expect(collection.issues == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("title")]),
),
])
#expect(!collection.issues.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.blank
.validating(traceRule(label: "first"))
.validating(traceRule(label: "second"))
.validating(legacyModeRule(severity: .warning))
let result = validator.collect(value)
#expect(result.issues.map(\.description) == [
"warning: first at root at root",
"warning: second at root at root",
"warning: first at $.root at $.root",
"warning: second at $.root at $.root",
"warning: first at $.root.mode at $.root.mode",
"warning: second at $.root.mode at $.root.mode",
"warning: Legacy mode is not allowed at $.root.mode",
"warning: first at $.list at $.list",
"warning: second at $.list at $.list",
"warning: first at $.list[0] at $.list[0]",
"warning: second at $.list[0] at $.list[0]",
"warning: first at $.list[0].mode at $.list[0].mode",
"warning: second at $.list[0].mode at $.list[0].mode",
"warning: Legacy mode is not allowed at $.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.blank.validating(
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
description: "Never runs",
check: { context in
[
PureYAML.Validation.Issue(
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.blank.validating(
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
description: "Legacy mode is not allowed",
check: { context in
[
PureYAML.Validation.Issue(
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.issues == [
.init(
severity: .error,
reason: "Legacy mode is not allowed",
path: .init([.key("nested"), .key("mode")]),
),
])
#expect(!result.issues.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.blank.validating(
PureYAML.Validation.Check<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.Issue(
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.issues == [
.init(
severity: .error,
reason: "Route name does not use root prefix",
path: .init([.key("routes"), .index(1), .key("name")]),
),
])
#expect(!result.issues.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.blank.validating(
PureYAML.Validation.Check<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.issues == [
.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)
}
}