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.
370 lines
14 KiB
Swift
370 lines
14 KiB
Swift
import Foundation
|
|
@testable import PureYAML
|
|
import Testing
|
|
|
|
@Suite("Validation Reports")
|
|
struct ValidationReportTests {
|
|
@Test("Valid YAML returns an empty report")
|
|
func test_validYAMLReturnsEmptyReport() {
|
|
let report = PureYAML.validationReport("title: Ready", file: "valid.yaml")
|
|
|
|
#expect(report.isEmpty)
|
|
#expect(report.isValid)
|
|
#expect(report.description == "")
|
|
}
|
|
|
|
@Test("Parse failures return file-scoped diagnostics")
|
|
func test_parseFailuresReturnFileScopedDiagnostics() {
|
|
let report = PureYAML.validationReport("title: \"open", file: "broken.yaml")
|
|
|
|
#expect(report.diagnostics == [
|
|
.init(
|
|
kind: .parse,
|
|
code: "unterminatedQuotedString",
|
|
severity: .error,
|
|
file: "broken.yaml",
|
|
line: 1,
|
|
reason: "unterminated quoted string at line 1",
|
|
),
|
|
])
|
|
#expect(!report.isValid)
|
|
#expect(report.description == "broken.yaml: line 1: error: parse: unterminated quoted string at line 1")
|
|
}
|
|
|
|
@Test("Duplicate keys return document and path diagnostics")
|
|
func test_duplicateKeysReturnDocumentAndPathDiagnostics() {
|
|
let report = PureYAML.validationReport(
|
|
"""
|
|
title: First
|
|
title: Second
|
|
""",
|
|
file: "duplicates.yaml",
|
|
)
|
|
|
|
#expect(report.diagnostics == [
|
|
.init(
|
|
kind: .validation,
|
|
severity: .error,
|
|
file: "duplicates.yaml",
|
|
documentIndex: 0,
|
|
path: .init([.key("title")]),
|
|
reason: "Duplicate mapping key 'title'",
|
|
),
|
|
])
|
|
#expect(report.description == "duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'")
|
|
}
|
|
|
|
@Test("Warning reports stay visible without making the report invalid")
|
|
func test_warningReportsStayVisibleWithoutMakingReportInvalid() {
|
|
let validator = PureYAML.Validation.Validator.blank.validating(
|
|
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
|
|
description: "Mode is not legacy",
|
|
severity: .warning,
|
|
check: { context in context.subject != .string("legacy") },
|
|
when: { context in context.path.components.last == .key("mode") },
|
|
),
|
|
)
|
|
|
|
let report = PureYAML.validationReport("mode: legacy", file: "warnings.yaml", using: validator)
|
|
|
|
#expect(report.diagnostics == [
|
|
.init(
|
|
kind: .validation,
|
|
severity: .warning,
|
|
file: "warnings.yaml",
|
|
documentIndex: 0,
|
|
path: .init([.key("mode")]),
|
|
reason: "Failed to satisfy: Mode is not legacy",
|
|
),
|
|
])
|
|
#expect(report.isValid)
|
|
#expect(report.warnings.count == 1)
|
|
#expect(report.errors.isEmpty)
|
|
}
|
|
|
|
@Test("Batch reports collect every source without stopping at the first failure")
|
|
func test_batchReportsCollectEverySourceWithoutStoppingAtFirstFailure() {
|
|
let validator = PureYAML.Validation.Validator().validating(
|
|
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
|
|
description: "Mode is not legacy",
|
|
severity: .warning,
|
|
check: { context in context.subject != .string("legacy") },
|
|
when: { context in context.path.components.last == .key("mode") },
|
|
),
|
|
)
|
|
|
|
let report = PureYAML.validationReports(
|
|
[
|
|
.init(name: "valid.yaml", yaml: "title: Ready"),
|
|
.init(name: "broken.yaml", yaml: "title: \"open"),
|
|
.init(
|
|
name: "duplicates.yaml",
|
|
yaml: """
|
|
title: First
|
|
title: Second
|
|
""",
|
|
),
|
|
.init(name: "warnings.yaml", yaml: "mode: legacy"),
|
|
],
|
|
using: validator,
|
|
)
|
|
|
|
#expect(report.fileCount == 4)
|
|
#expect(report.validCount == 2)
|
|
#expect(report.invalidCount == 2)
|
|
#expect(report.diagnosticCount == 3)
|
|
#expect(!report.isValid)
|
|
#expect(report.description == [
|
|
"broken.yaml: line 1: error: parse: unterminated quoted string at line 1",
|
|
"duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'",
|
|
"warnings.yaml: document[0]: $.mode: warning: validation: Failed to satisfy: Mode is not legacy",
|
|
].joined(separator: "\n"))
|
|
#expect(report.sourceReports.map(\.name) == [
|
|
"valid.yaml",
|
|
"broken.yaml",
|
|
"duplicates.yaml",
|
|
"warnings.yaml",
|
|
])
|
|
#expect(report.sourceReports.map(\.invalid) == [false, true, true, false])
|
|
}
|
|
|
|
@Test("Batch reports can treat warnings as failures")
|
|
func test_batchReportsCanTreatWarningsAsFailures() {
|
|
let validator = PureYAML.Validation.Validator.blank.validating(
|
|
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
|
|
description: "Mode is not legacy",
|
|
severity: .warning,
|
|
check: { context in context.subject != .string("legacy") },
|
|
when: { context in context.path.components.last == .key("mode") },
|
|
),
|
|
)
|
|
|
|
let report = PureYAML.validationReports(
|
|
[
|
|
.init(name: "warnings.yaml", yaml: "mode: legacy"),
|
|
],
|
|
using: validator,
|
|
failOnWarnings: true,
|
|
)
|
|
|
|
#expect(report.failOnWarnings)
|
|
#expect(report.fileCount == 1)
|
|
#expect(report.validCount == 0)
|
|
#expect(report.invalidCount == 1)
|
|
#expect(!report.isValid)
|
|
#expect(report.sourceReports == [
|
|
.init(
|
|
name: "warnings.yaml",
|
|
report: .init([
|
|
.init(
|
|
kind: .validation,
|
|
severity: .warning,
|
|
file: "warnings.yaml",
|
|
documentIndex: 0,
|
|
path: .init([.key("mode")]),
|
|
reason: "Failed to satisfy: Mode is not legacy",
|
|
),
|
|
]),
|
|
invalid: true,
|
|
),
|
|
])
|
|
}
|
|
|
|
@Test("Batch reports render exact Markdown file contents")
|
|
func test_batchReportsRenderExactMarkdownFileContents() throws {
|
|
let report = makeMarkdownValidationReport()
|
|
let expected = expectedMarkdownValidationReport
|
|
|
|
#expect(report.markdownDescription(title: "YAML Production Validation") == expected)
|
|
|
|
#expect(try writeAndRead(report: report) == expected)
|
|
}
|
|
|
|
@Test("Batch reports render machine-readable YAML and JSON files")
|
|
func test_batchReportsRenderMachineReadableYAMLAndJSONFiles() throws {
|
|
let report = makeMarkdownValidationReport()
|
|
let title = "YAML Production Validation"
|
|
|
|
#expect(report.modelValue(title: title) == expectedReportModelValue)
|
|
#expect(try PureYAML.parse(report.yamlDescription(title: title)) == expectedReportModelValue)
|
|
#expect(try jsonSummary(report.jsonDescription(title: title)) == [
|
|
"diagnostics": 2,
|
|
"files": 3,
|
|
"invalid": 2,
|
|
"valid": 1,
|
|
])
|
|
|
|
let outputs = try writeAndReadMachineReports(report: report, title: title)
|
|
|
|
#expect(try PureYAML.parse(outputs.yaml) == expectedReportModelValue)
|
|
#expect(outputs.json == report.jsonDescription(title: title))
|
|
#expect(outputs.json.contains(#""reason": "Duplicate mapping key 'title'""#))
|
|
}
|
|
}
|
|
|
|
private func makeMarkdownValidationReport() -> PureYAML.Validation.BatchReport {
|
|
PureYAML.validationReports(
|
|
[
|
|
.init(name: "valid.yaml", yaml: "title: Ready"),
|
|
.init(name: "broken.yaml", yaml: "title: \"open"),
|
|
.init(
|
|
name: "duplicates.yaml",
|
|
yaml: """
|
|
title: First
|
|
title: Second
|
|
""",
|
|
),
|
|
],
|
|
)
|
|
}
|
|
|
|
private func writeAndRead(report: PureYAML.Validation.BatchReport) throws -> String {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
"PureYAMLValidationReport-\(UUID().uuidString)",
|
|
isDirectory: true,
|
|
)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer {
|
|
try? FileManager.default.removeItem(at: directory)
|
|
}
|
|
|
|
let file = directory.appendingPathComponent("validation-report.md")
|
|
try report.markdownDescription(title: "YAML Production Validation")
|
|
.write(to: file, atomically: true, encoding: .utf8)
|
|
return try String(contentsOf: file, encoding: .utf8)
|
|
}
|
|
|
|
private func writeAndReadMachineReports(
|
|
report: PureYAML.Validation.BatchReport,
|
|
title: String,
|
|
) throws -> (yaml: String, json: String) {
|
|
let directory = try temporaryValidationReportDirectory()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: directory)
|
|
}
|
|
|
|
let yamlFile = directory.appendingPathComponent("validation-report.yaml")
|
|
let jsonFile = directory.appendingPathComponent("validation-report.json")
|
|
try report.yamlDescription(title: title).write(to: yamlFile, atomically: true, encoding: .utf8)
|
|
try report.jsonDescription(title: title).write(to: jsonFile, atomically: true, encoding: .utf8)
|
|
return try (
|
|
String(contentsOf: yamlFile, encoding: .utf8),
|
|
String(contentsOf: jsonFile, encoding: .utf8)
|
|
)
|
|
}
|
|
|
|
private func temporaryValidationReportDirectory() throws -> URL {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
"PureYAMLValidationReport-\(UUID().uuidString)",
|
|
isDirectory: true,
|
|
)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
return directory
|
|
}
|
|
|
|
private func jsonSummary(_ json: String) throws -> [String: Int] {
|
|
let data = Data(json.utf8)
|
|
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
|
let summary = try #require(object["summary"] as? [String: Any])
|
|
return try [
|
|
"diagnostics": #require(summary["diagnostics"] as? Int),
|
|
"files": #require(summary["files"] as? Int),
|
|
"invalid": #require(summary["invalid"] as? Int),
|
|
"valid": #require(summary["valid"] as? Int),
|
|
]
|
|
}
|
|
|
|
private let expectedMarkdownValidationReport = """
|
|
# YAML Production Validation
|
|
|
|
- Files: 3
|
|
- Valid: 1
|
|
- Invalid: 2
|
|
- Diagnostics: 2
|
|
- Warnings fail: no
|
|
|
|
## valid.yaml
|
|
|
|
Status: valid
|
|
|
|
No diagnostics.
|
|
|
|
## broken.yaml
|
|
|
|
Status: invalid
|
|
|
|
```text
|
|
broken.yaml: line 1: error: parse: unterminated quoted string at line 1
|
|
```
|
|
|
|
## duplicates.yaml
|
|
|
|
Status: invalid
|
|
|
|
```text
|
|
duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'
|
|
```
|
|
|
|
"""
|
|
|
|
private let expectedReportModelValue = PureYAML.Model.Value.mapping(.init([
|
|
.init(key: "title", value: .string("YAML Production Validation")),
|
|
.init(key: "summary", value: .mapping(.init([
|
|
.init(key: "files", value: .int(3)),
|
|
.init(key: "valid", value: .int(1)),
|
|
.init(key: "invalid", value: .int(2)),
|
|
.init(key: "diagnostics", value: .int(2)),
|
|
.init(key: "warningsFail", value: .bool(false)),
|
|
]))),
|
|
.init(key: "sources", value: .sequence([
|
|
.mapping(.init([
|
|
.init(key: "name", value: .string("valid.yaml")),
|
|
.init(key: "status", value: .string("valid")),
|
|
.init(key: "invalid", value: .bool(false)),
|
|
.init(key: "diagnostics", value: .sequence([])),
|
|
])),
|
|
.mapping(.init([
|
|
.init(key: "name", value: .string("broken.yaml")),
|
|
.init(key: "status", value: .string("invalid")),
|
|
.init(key: "invalid", value: .bool(true)),
|
|
.init(key: "diagnostics", value: .sequence([
|
|
.mapping(.init([
|
|
.init(key: "kind", value: .string("parse")),
|
|
.init(key: "code", value: .string("unterminatedQuotedString")),
|
|
.init(key: "severity", value: .string("error")),
|
|
.init(key: "file", value: .string("broken.yaml")),
|
|
.init(key: "line", value: .int(1)),
|
|
.init(key: "documentIndex", value: .null),
|
|
.init(key: "path", value: .null),
|
|
.init(key: "reason", value: .string("unterminated quoted string at line 1")),
|
|
.init(
|
|
key: "description",
|
|
value: .string("broken.yaml: line 1: error: parse: unterminated quoted string at line 1"),
|
|
),
|
|
])),
|
|
])),
|
|
])),
|
|
.mapping(.init([
|
|
.init(key: "name", value: .string("duplicates.yaml")),
|
|
.init(key: "status", value: .string("invalid")),
|
|
.init(key: "invalid", value: .bool(true)),
|
|
.init(key: "diagnostics", value: .sequence([
|
|
.mapping(.init([
|
|
.init(key: "kind", value: .string("validation")),
|
|
.init(key: "severity", value: .string("error")),
|
|
.init(key: "file", value: .string("duplicates.yaml")),
|
|
.init(key: "documentIndex", value: .int(0)),
|
|
.init(key: "path", value: .string("$.title")),
|
|
.init(key: "reason", value: .string("Duplicate mapping key 'title'")),
|
|
.init(
|
|
key: "description",
|
|
value: .string(
|
|
"duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'",
|
|
),
|
|
),
|
|
])),
|
|
])),
|
|
])),
|
|
])),
|
|
]))
|