From c8c8898a4ee1131e73caf5c881e3b347d13ddc34 Mon Sep 17 00:00:00 2001 From: Daniel Hall Date: Thu, 30 Mar 2023 23:58:06 -0600 Subject: [PATCH] Basic Gherkin and ReqsML Parsing & Testing --- .gitignore | 25 + Package.swift | 33 ++ README.md | 3 + .../RequirementsKit/Export/ReqsMLExport.swift | 296 +++++++++++ Sources/RequirementsKit/LabelExpression.swift | 144 +++++ .../Parsing/GherkinParser.swift | 428 +++++++++++++++ Sources/RequirementsKit/Parsing/Parsing.swift | 356 +++++++++++++ .../Parsing/ReqsMLParser.swift | 499 ++++++++++++++++++ Sources/RequirementsKit/Requirement.swift | 211 ++++++++ .../RequirementsTestRunner.swift | 147 ++++++ .../RequirementsKit/StatementHandler.swift | 91 ++++ .../XCTestCase+Extensions.swift | 99 ++++ .../GherkinParsingTests.swift | 105 ++++ .../ReqsMLExportTests.swift | 38 ++ .../ReqsMLParsingTests.swift | 137 +++++ .../TestResources/Valid.feature | 87 +++ .../TestResources/Valid.requirements | 129 +++++ 17 files changed, 2828 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/RequirementsKit/Export/ReqsMLExport.swift create mode 100644 Sources/RequirementsKit/LabelExpression.swift create mode 100644 Sources/RequirementsKit/Parsing/GherkinParser.swift create mode 100644 Sources/RequirementsKit/Parsing/Parsing.swift create mode 100644 Sources/RequirementsKit/Parsing/ReqsMLParser.swift create mode 100644 Sources/RequirementsKit/Requirement.swift create mode 100644 Sources/RequirementsKit/RequirementsTestRunner.swift create mode 100644 Sources/RequirementsKit/StatementHandler.swift create mode 100644 Sources/RequirementsKit/XCTestCase+Extensions.swift create mode 100644 Tests/RequirementsKitTests/GherkinParsingTests.swift create mode 100644 Tests/RequirementsKitTests/ReqsMLExportTests.swift create mode 100644 Tests/RequirementsKitTests/ReqsMLParsingTests.swift create mode 100644 Tests/RequirementsKitTests/TestResources/Valid.feature create mode 100644 Tests/RequirementsKitTests/TestResources/Valid.requirements diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7289e63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ + +# Xcode + + +## Mac specific +*.DS_Store + + +## User settings +xcuserdata/ + + +# Swift Package Manager + +Packages/ +Package.pins +Package.resolved +*.xcodeproj + + +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project + +.swiftpm +.build/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..46bdc99 --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "RequirementsKit", + platforms: [.iOS(.v16), .macOS(.v13), .macCatalyst(.v16)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "RequirementsKit", + targets: ["RequirementsKit"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + .package(url: "https://github.com/apple/swift-collections", exact: "1.0.4"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "RequirementsKit", + dependencies: [ + .product(name: "OrderedCollections", package: "swift-collections"), + ] + ), + .testTarget( + name: "RequirementsKitTests", + dependencies: ["RequirementsKit"], + resources: [.copy("TestResources")]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..7997a85 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# RequirementsKit + +A library for parsing and testing functional requirements in Swift diff --git a/Sources/RequirementsKit/Export/ReqsMLExport.swift b/Sources/RequirementsKit/Export/ReqsMLExport.swift new file mode 100644 index 0000000..7e5a59e --- /dev/null +++ b/Sources/RequirementsKit/Export/ReqsMLExport.swift @@ -0,0 +1,296 @@ +// +// ReqsMLExport.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import OrderedCollections + + +func consolidateSpacing(_ array: [String]) -> [String] { + if array.isEmpty || (array.count == 1 && array.first?.trimmingCharacters(in: .whitespaces).first == "\n") { return [] } + let leadingSpaces = array.prefix { $0.trimmingCharacters(in: .whitespaces).first == "\n" } + let consolidated = array.trimmingPrefix { $0.trimmingCharacters(in: .whitespaces).first == "\n" } + let head = consolidated.prefix { $0.trimmingCharacters(in: .whitespaces).first != "\n" } + let tail = consolidated.trimmingPrefix { $0.trimmingCharacters(in: .whitespaces).first != "\n" } + return (leadingSpaces.count > 0 ? ["\n"] : []) + Array(head) + consolidateSpacing(Array(tail)) +} + +public extension File { + func asReqsML() -> String { + consolidateSpacing(requirements.asReqsML()).joined(separator: "\n").replacingOccurrences(of: "\n\n\n", with: "\n\n").drop { $0 == "\n" } + "" + } +} + +extension Requirement { + fileprivate func asReqsML() -> [String] { + let comments: [String] = comments.map { ["\n"] + $0.map { "// " + $0 } } ?? [] + let metadata = reqsMLFrom(identifier: identifier, labels: explicitLabels) + let requirementDescription: [String] = ["Requirement: " + description, "\n"] + + var exampleGroups = [[Requirement.Example]]() + var currentGroup = [Requirement.Example]() + + examples.forEach { + switch (currentGroup.first?.exampleSet, $0.exampleSet) { + case (.none, .none): + currentGroup.append($0) + case (.some(let current), .some(let example)) where current == example: + currentGroup.append($0) + default: + if !currentGroup.isEmpty { exampleGroups.append(currentGroup) } + currentGroup = [$0] + } + } + if !currentGroup.isEmpty { + exampleGroups.append(currentGroup) + } + let indentedExamples: [String] + if exampleGroups.count == 1, + let firstGroup = exampleGroups.first, + let exampleSet = firstGroup.first?.exampleSet, + exampleSet.comments == nil, + exampleSet.description == nil, + exampleSet.labels == nil, + exampleSet.identifier == nil + { + let ifs: [Requirement.Example.Statement] = exampleSet.statements.filter { $0.type == .if } + let whens: [Requirement.Example.Statement] = exampleSet.statements.filter { $0.type == .when } + let expects: [Requirement.Example.Statement] = exampleSet.statements.filter { $0.type == .expect } + let statements: [String] = ifs.asReqsML() + whens.asReqsML() + expects.asReqsML() + let indentedStatements: [String] = statements.map { $0 == "\n" ? $0 : " " + $0 } + let examplesKeyword = [" " + "Examples:", "\n"] + var maxDescriptionCount: Int? + var maxMetadataCount: Int? + var maxKeyCounts = OrderedDictionary() + firstGroup.forEach { + if let description = $0.specification?.description { + maxDescriptionCount = (maxDescriptionCount == nil || (maxDescriptionCount ?? 0) < description.count) ? description.count : maxDescriptionCount + } + if let metadata = reqsMLFrom(identifier: $0.specification?.identifier, labels: $0.specification?.labels).first { + maxMetadataCount = (maxMetadataCount == nil || (maxMetadataCount ?? 0) < metadata.count) ? metadata.count : maxMetadataCount + } + $0.specification?.values.forEach { + if maxKeyCounts[$0.key] == nil { + maxKeyCounts[$0.key] = $0.key.count + } + if (maxKeyCounts[$0.key] ?? 0) < $0.value.count { + maxKeyCounts[$0.key] = $0.value.count + } + } + } + let headerRows = [ + (maxMetadataCount == nil ? "" : String(repeating: " ", count: maxMetadataCount! + 1)) + + (maxDescriptionCount == nil ? "" : "| " + String(repeating: " ", count: maxDescriptionCount! + 1)) + + String(maxKeyCounts.map { "| " + $0.key + String(repeating: " ", count: $0.value - $0.key.count) }.joined()) + + " |", + (maxMetadataCount == nil ? "" : String(repeating: " ", count: maxMetadataCount! + 1)) + + (maxDescriptionCount == nil ? "" : "| " + String(repeating: "-", count: maxDescriptionCount!) + " ") + + String(maxKeyCounts.map { "| " + String(repeating: "-", count: $0.value) }.joined()) + + " |" + ] + let valueRows: [String] = firstGroup.map { example in + let comments: [String] = example.specification?.comments.map { ["\n"] + $0.map { "// " + $0 } } ?? [] + let metadata = reqsMLFrom(identifier: example.specification?.identifier, labels: example.specification?.labels).first ?? String(repeating: " ", count: maxMetadataCount ?? 0) + let description = example.specification?.description ?? String(repeating: " ", count: maxDescriptionCount ?? 0) + var metadataString: String = "" + var descriptionString: String = "" + if let maxMetadataCount { + metadataString = metadata + String(repeating: " ", count: maxMetadataCount - metadata.count) + " " + } + if let maxDescriptionCount { + descriptionString = "| " + description + String(repeating: " ", count: maxDescriptionCount - description.count) + " " + } + let values = metadataString + descriptionString + String(maxKeyCounts.map { "| " + example.specification!.values[$0.key]! + String(repeating: " ", count: $0.value - example.specification!.values[$0.key]!.count) }.joined()) + " |" + return comments + [values] + }.joined().map { $0 } + + let indentedValues = (headerRows + valueRows).map { $0 == "\n" ? $0 : " " + " " + $0 } + indentedExamples = indentedStatements + examplesKeyword + indentedValues + ["\n"] + } + else if exampleGroups.count == 1, + let firstGroup = exampleGroups.first, + let firstExample = firstGroup.first, + firstGroup.count == 1 + { + indentedExamples = firstExample.asReqsML().map { $0 == "\n" ? $0 : " " + $0 } + } else { + indentedExamples = exampleGroups.map { $0.asReqsML() }.joined().map { $0 == "\n" ? $0 : " " + $0 } + } + return comments + metadata + requirementDescription + indentedExamples + } +} + +fileprivate func reqsMLFrom(identifier: String?, labels: [String]?) -> [String] { + switch (identifier, labels) { + case (.none, .none): return [] + case (.some(let identifier), .none): return ["#\(identifier)"] + case (.none, .some(let labels)): return ["#(\(labels.joined(separator: ", ")))"] + case (.some(let identifier), .some(let labels)): return ["#\(identifier) (\(labels.joined(separator: ", ")))"] + } +} + +extension [Requirement] { + public func asReqsML() -> [String] { + guard count > 0 else { return [] } + return map { $0.asReqsML() }.joined().map { $0 } + } +} + +extension [Requirement.Example] { + + fileprivate func asReqsML() -> [String] { + guard count > 0 else { return [] } + if let first = first, let exampleSet = first.exampleSet { + let comments: [String] = exampleSet.comments.map { ["\n"] + $0.map { "// " + $0 } } ?? [] + let metadata = reqsMLFrom(identifier: exampleSet.identifier, labels: exampleSet.labels) + let exampleDescription: [String] = ["Example Set: " + (exampleSet.description ?? ""), "\n"] + let ifs: [Requirement.Example.Statement] = exampleSet.statements.filter { $0.type == .if } + let whens: [Requirement.Example.Statement] = exampleSet.statements.filter { $0.type == .when } + let expects: [Requirement.Example.Statement] = exampleSet.statements.filter { $0.type == .expect } + let statements: [String] = ifs.asReqsML() + whens.asReqsML() + expects.asReqsML() + let indentedStatements: [String] = statements.map { $0 == "\n" ? $0 : " " + $0 } + let examplesKeyword = [" " + "Examples:", "\n"] + var maxMetadataCount = -1 + var maxKeyCounts = OrderedDictionary() + self.forEach { + $0.specification?.values.forEach { + if maxKeyCounts[$0.key] == nil { + maxKeyCounts[$0.key] = $0.key.count + } + if (maxKeyCounts[$0.key] ?? 0) < $0.value.count { + maxKeyCounts[$0.key] = $0.value.count + } + } + if let metadata = reqsMLFrom(identifier: $0.specification?.identifier, labels: $0.specification?.labels).first { + if metadata.count > maxMetadataCount { + maxMetadataCount = metadata.count + } + } + } + let headerRows = [ + String(repeating: " ", count: maxMetadataCount + 1) + String(maxKeyCounts.map { "| " + $0.key + String(repeating: " ", count: $0.value - $0.key.count) + " " }.joined()) + "|", + String(repeating: " ", count: maxMetadataCount + 1) + String(maxKeyCounts.map { "| " + String(repeating: "-", count: $0.value) + " " }.joined()) + "|" + ] + let valueRows: [String] = self.map { example in + let comments: [String] = example.specification?.comments.map { ["\n"] + $0.map { "// " + $0 } } ?? [] + let metadata = reqsMLFrom(identifier: example.specification?.identifier, labels: example.specification?.labels).first ?? "" + let values = metadata + String(repeating: " ", count: maxMetadataCount - metadata.count + 1) + String(maxKeyCounts.map { "| " + example.specification!.values[$0.key]! + String(repeating: " ", count: $0.value - example.specification!.values[$0.key]!.count + 1) }.joined()) + "|" + return comments + [values] + }.joined().map { $0 } + + let indentedValues = (headerRows + valueRows).map { $0 == "\n" ? $0 : " " + " " + $0 } + return comments + metadata + exampleDescription + indentedStatements + examplesKeyword + indentedValues + ["\n"] + } + return map { $0.asReqsML() }.joined().map { $0 } + } +} + +extension Requirement.Example { + + fileprivate func asReqsML() -> [String] { + let comments: [String] = comments.map { ["\n"] + $0.map { "// " + $0 } } ?? [] + let metadata = reqsMLFrom(identifier: identifier, labels: explicitLabels) + let exampleDescription: [String] = ["Example: " + (description ?? ""), "\n"] + let ifs: [Requirement.Example.Statement] = statements.filter { $0.type == .if } + let whens: [Requirement.Example.Statement] = statements.filter { $0.type == .when } + let expects: [Requirement.Example.Statement] = statements.filter { $0.type == .expect } + let statements: [String] = ifs.asReqsML() + whens.asReqsML() + expects.asReqsML() + if self.comments == nil, identifier == nil, labels == nil, description == nil { + return statements + } + let indentedStatements: [String] = statements.map { $0 == "\n" ? $0 : " " + $0 } + return comments + metadata + exampleDescription + indentedStatements + ["\n"] + } +} + +extension [Requirement.Example.Statement] { + fileprivate func asReqsML() -> [String] { + guard count > 0 else { return [] } + if let single = first, count == 1 { + let comments = single.comments.map { ["\n"] + $0.map { "// " + $0 } } ?? [] + let description = [single.type.rawValue.capitalized + ": " + single.description] + let data = (single.data?.asReqsML() ?? []).map { $0 == "\n" ? $0 : " " + $0 } + return comments + description + data + ["\n"] + } + let firstLine: [String] = [first!.type.rawValue.capitalized + ":"] + let remaining: [String] = self.map { statement in + let comments: [String] = statement.comments.map { ["\n"] + $0.map { "// " + $0 } } ?? [] + let data: [String] = statement.data?.asReqsML().map { $0 == "\n" ? $0 : (" " + $0) } ?? [] + return comments + ["- " + statement.description] + data + }.joined().map { $0 } + return firstLine + remaining + ["\n"] + } +} + +extension Requirement.Example.Statement.Data { + fileprivate func asReqsML() -> [String] { + switch self { + case .list(let list): + let maxCount = list.map { $0.count }.max()! + return list.map { "| " + $0 + String(repeating: " ", count: maxCount - $0.count) + " |" } + ["\n"] + case .keyValues(let keyValues): + let maxCount = keyValues.map { $0.key.count + $0.value.count }.max()! + return keyValues.map { "| " + $0.key + ": " + $0.value + String(repeating: " ", count: (maxCount - $0.key.count - $0.value.count)) + " |" } + ["\n"] + case .text(let text): return ["```"] + text.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) } + ["```"] + ["\n"] + case .table(let table): + var maxCounts = OrderedDictionary() + table.forEach { + $0.forEach { + if maxCounts[$0.key] == nil { + maxCounts[$0.key] = $0.key.count + } + if (maxCounts[$0.key] ?? 0) < $0.value.count { + maxCounts[$0.key] = $0.value.count + } + } + } + var lines = [maxCounts.map { "| " + $0.key + String(repeating: " ", count: maxCounts[$0.key]! - $0.key.count) + " " }.joined() + "|"] + lines += [maxCounts.map { "| " + String(repeating: "-", count: maxCounts[$0.key]!) + " " }.joined() + "|"] + lines += table.map { values in + maxCounts.map { "| " + values[$0.key]! + String(repeating: " ", count: $0.value - values[$0.key]!.count) + " " }.joined() + "|" + } + return lines + ["\n"] + case .matrix(let matrix): + var maxKeyCount = 0 + var maxCounts = OrderedDictionary() + matrix.forEach { + maxKeyCount = max($0.key.count, maxKeyCount) + $0.value.forEach { + if maxCounts[$0.key] == nil { + maxCounts[$0.key] = $0.key.count + } + if (maxCounts[$0.key] ?? 0) < $0.value.count { + maxCounts[$0.key] = $0.value.count + } + } + } + var lines = ["| " + String(repeating: " ", count: maxKeyCount) + " " + maxCounts.map { "| " + $0.key + String(repeating: " ", count: maxCounts[$0.key]! - $0.key.count) + " " }.joined() + "|"] + lines += ["| " + String(repeating: "-", count: maxKeyCount) + " " + maxCounts.map { "| " + String(repeating: "-", count: maxCounts[$0.key]!) + " " }.joined() + "|"] + lines += matrix.map { row in + "| " + row.key + String(repeating: " ", count: maxKeyCount - row.key.count) + " " + maxCounts.map { "| " + row.value[$0.key]! + String(repeating: " ", count: $0.value - row.value[$0.key]!.count) + " " }.joined() + "|" + } + return lines + ["\n"] + } + } +} diff --git a/Sources/RequirementsKit/LabelExpression.swift b/Sources/RequirementsKit/LabelExpression.swift new file mode 100644 index 0000000..5313055 --- /dev/null +++ b/Sources/RequirementsKit/LabelExpression.swift @@ -0,0 +1,144 @@ +// +// LabelExpression.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +public indirect enum LabelExpression { + case label(String) + case not(String) + case orLabel(LabelExpression, String) + case andLabel(LabelExpression, String) + case orNotLabel(LabelExpression, String) + case andNotLabel(LabelExpression, String) + case orExpression(LabelExpression, LabelExpression) + case andExpression(LabelExpression, LabelExpression) + case orNotExpression(LabelExpression, LabelExpression) + case andNotExpression(LabelExpression, LabelExpression) + + public func or(_ string: String) -> LabelExpression { + return .orLabel(self, string) + } + + public func and(_ string: String) -> LabelExpression { + return .andLabel(self, string) + } + + public func orNot(_ string: String) -> LabelExpression { + return .orNotLabel(self, string) + } + + public func andNot(_ string: String) -> LabelExpression { + return .andNotLabel(self, string) + } + + public func or(_ expression: LabelExpression) -> LabelExpression { + return .orExpression(self, expression) + } + + public func and(_ expression: LabelExpression) -> LabelExpression { + return .andExpression(self, expression) + } + + public func orNot(_ expression: LabelExpression) -> LabelExpression { + return .orNotExpression(self, expression) + } + + public func andNot(_ expression: LabelExpression) -> LabelExpression { + return .andNotExpression(self, expression) + } + + private enum MatchResult { + case success + case notIncluded + case excluded + case notIncludedAndExcluded + case failure + } + + private func matchResult(_ labels: [String]) -> MatchResult { + switch self { + + case .label(let string): + return labels.contains(string) ? .success : .notIncluded + + case .not(let string): + return !labels.contains(string) ? .success : .excluded + + case .andLabel(let expression, let string): + switch expression.matchResult(labels) { + case .success: return labels.contains(string) ? .success : .notIncluded + case .excluded: return labels.contains(string) ? .excluded : .notIncludedAndExcluded + case .notIncluded: return .notIncluded + case .notIncludedAndExcluded: return .notIncludedAndExcluded + case .failure: return .failure + } + + case .orLabel(let expression, let string): + switch expression.matchResult(labels) { + case .success: return .success + case .excluded: return labels.contains(string) ? .excluded : .notIncludedAndExcluded + case .notIncluded: return labels.contains(string) ? .success : .notIncluded + case .notIncludedAndExcluded: return labels.contains(string) ? .excluded : .notIncludedAndExcluded + case .failure: return .failure + } + + case .orNotLabel(let expression, let string): + switch expression.matchResult(labels) { + case .success: return .success + case .excluded: return !labels.contains(string) ? .success : .excluded + case .notIncluded: return .notIncluded + case .notIncludedAndExcluded: return !labels.contains(string) ? .notIncluded : .notIncludedAndExcluded + case .failure: return .failure + } + + case .andNotLabel(let expression, let string): + switch expression.matchResult(labels) { + case .success: return !labels.contains(string) ? .success : .excluded + case .excluded: return .excluded + case .notIncluded: return !labels.contains(string) ? .notIncluded : .notIncludedAndExcluded + case .notIncludedAndExcluded: return .notIncludedAndExcluded + case .failure: return .failure + } + + case .orExpression(let first, let second): + return first.matchResult(labels) == .success || second.matchResult(labels) == .success ? .success : .failure + + case .andExpression(let first, let second): + return first.matchResult(labels) == .success && second.matchResult(labels) == .success ? .success : .failure + + case .orNotExpression(let first, let second): + return first.matchResult(labels) == .success || second.matchResult(labels) != .success ? .success : .failure + + case .andNotExpression(let first, let second): + return first.matchResult(labels) == .success && second.matchResult(labels) == .success ? .success : .failure + } + } + + public func matches(_ labels: [String]?) -> Bool { + return matchResult(labels ?? []) == .success + } + + public func matches(_ label: String) -> Bool { + return matches([label]) + } +} diff --git a/Sources/RequirementsKit/Parsing/GherkinParser.swift b/Sources/RequirementsKit/Parsing/GherkinParser.swift new file mode 100644 index 0000000..3449268 --- /dev/null +++ b/Sources/RequirementsKit/Parsing/GherkinParser.swift @@ -0,0 +1,428 @@ +// +// GherkinParser.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import OrderedCollections + + +private struct CommentsAndTags { + let comments: [String]? + let tags: [String]? +} + +private struct ExampleRow { + let comments: [String]? + let tags: [String]? + let values: [String] +} + +public func parseGherkin(from url: URL) throws -> File { + let data = try Data(contentsOf: url) + guard let string = String(data: data, encoding: .ascii) else { + throw RequirementsKitError(errorDescription: "Couldn't decode text from file at url \(url)") + } + var lines = string.split(separator: "\n", omittingEmptySubsequences: false).enumerated().map { Line(number: $0.offset + 1, text: String($0.element)) } + let feature = try parseFeature(&lines) + return File(url: url, comments: feature.comments, labels: feature.labels, description: feature.description, syntax: feature.syntax, requirements: feature.requirements) +} + +private let parseFeature = parseOptionalCommentsAndTags + .then(parseFeatureDescription) + .then(parseExtendedDescription) + .then(.oneOrMore(parseRule, until: .end)) + .flattened() + .map { commentsAndTags, description, rules in + let rules = rules.map { rule in + let combinedRuleLabels = commentsAndTags?.tags.combinedWith(rule.labels) + let examples = rule.examples.map { example in + let combinedExampleLabels = combinedRuleLabels.combinedWith(example.labels) + return Requirement.Example(comments: example.comments, identifier: example.identifier, labels: combinedExampleLabels, explicitLabels: example.labels, description: example.description, statements: example.statements) + } + return Requirement(comments: rule.comments, identifier: rule.identifier, labels: combinedRuleLabels, explicitLabels: rule.labels, description: rule.description, examples: examples) + } + return File(url: .init(string: "requirements.kit")!, comments: commentsAndTags?.comments, labels: commentsAndTags?.tags, description: description, syntax: .gherkin, requirements: rules) + } + .or( + parseOptionalCommentsAndTags + .then(parseFeatureDescription) + .then(parseExtendedDescription) + .then(parseExamples) + .flattened() + .map { commentsAndTags, description, examples in + let examples = examples.map { example in + let combinedLabels = commentsAndTags?.tags.combinedWith(example.labels) + return Requirement.Example(comments: example.comments, identifier: example.identifier, labels: combinedLabels, explicitLabels: example.labels, description: example.description, statements: example.statements) + } + let requirement = Requirement(comments: nil, identifier: nil, labels: commentsAndTags?.tags, description: description, examples: examples) + return File(url: .init(string: "requirements.kit")!, comments: commentsAndTags?.comments, labels: commentsAndTags?.tags, description: description, syntax: .gherkin, requirements: [requirement]) + } + ) + +private let parseRule = parseOptionalCommentsAndTags + .then(parseRuleDescription) + .then(parseExtendedDescription) + .then(parseExamples) + .flattened() + .map { commentsAndTags, description, examples in + let examples = examples.map { example in + let combinedLabels = commentsAndTags?.tags.combinedWith(example.labels) + return Requirement.Example(comments: example.comments, identifier: example.identifier, labels: combinedLabels, explicitLabels: example.labels, description: example.description, statements: example.statements) + } + return Requirement(comments: commentsAndTags?.comments, identifier: nil, labels: commentsAndTags?.tags, description: description, examples: examples) + } + +private let parseComment = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("#") else { + throw "Can't parse comment because line doesn't start with #" + } + return trimmed.drop { $0 == "#" }.trimmingCharacters(in: .whitespaces) +} + +private let parseOptionalComments: Parser<[String]?> = Parser.zeroOrMore(parseComment, until: .end.or(.not(parseComment))) + .map { $0.isEmpty ? nil : $0 } + +private let parseTags = Parser<[String]> { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + let tags = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + guard tags.reduce(true, { $0 && $1.first == "@" && !$1.dropFirst().trimmingCharacters(in: .whitespaces).isEmpty ? true : false }) else { + throw "Tags must all be prefixed with @" + } + return tags.map { $0.dropFirst().trimmingCharacters(in: .whitespaces) } +} + +private let parseOptionalTags: Parser<[String]?> = parseTags + .map { .some($0) } + .or( + Parser<[String]?>(consumeLine: false) { _ in nil } + ) + +private let parseOptionalCommentsAndTags: Parser = parseOptionalComments + .then(parseOptionalTags) + .then(parseOptionalComments) + .flattened() + .map { firstComments, tags, secondComments in + if firstComments == nil && secondComments == nil && tags == nil { + return nil + } + let combinedComments = firstComments.map { secondComments == nil ? $0 : $0 + secondComments! } ?? secondComments + return CommentsAndTags(comments: combinedComments, tags:tags) + } + +private let parseExtendedDescription: Parser = +Parser.zeroOrMore(parseString, until: parseRuleDescription.map { _ in ()} + .or(parseScenarioOutlineDescription.map {_ in () }) + .or(parseExampleDescription.map { _ in () }) + .or(parseStatement.map { _ in () }) + .or(parseComment.map { _ in () }) + .or(parseTags.map { _ in () })) +.map { _ in () } + +private let parseFeatureDescription = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("Feature:") == true else { + throw "Line doesn't begin with \"Feature:\"" + } + let description = trimmed.drop { $0 != ":" }.dropFirst().trimmingCharacters(in: .whitespaces) + guard !description.isEmpty else { + throw "There must be a non-empty description of the Rule after the the \"Feature:\" keyword" + } + return description +} + +private let parseRuleDescription = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("Rule:") == true else { + throw "Line doesn't begin with \"Rule:\"" + } + let description = trimmed.drop { $0 != ":" }.dropFirst().trimmingCharacters(in: .whitespaces) + guard !description.isEmpty else { + throw "There must be a non-empty description of the Rule after the the \"Rule:\" keyword" + } + return description +} + +private let parseExampleDescription = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("Example:") == true || trimmed.hasPrefix("Scenario:") == true else { + throw "Line doesn't begin with \"Example:\" or \"Scenario:\"" + } + let description = trimmed.drop { $0 != ":" }.dropFirst().trimmingCharacters(in: .whitespaces) + guard !description.isEmpty else { + throw "There must be a non-empty description after the the \"Example:\" or \"Scenario:\" keyword" + } + return description +} + +private let parseStatementKeyword = Parser<(Requirement.Example.StatementType, String)> { + let type: Requirement.Example.StatementType + let trimmed = $0.trimmingCharacters(in: .whitespaces) + switch trimmed.prefix(upTo: trimmed.firstIndex(of: " ") ?? trimmed.startIndex) { + case "Given": + type = .if + case "When": + type = .when + case "Then": + type = .expect + default: + throw "No Given, When, or Then keyword found" + } + let description = trimmed.drop { $0 != " " }.dropFirst().trimmingCharacters(in: .whitespaces) + guard !description.isEmpty else { + throw "A Statement must have a description after the Given, When or Then keyword" + } + return (type, description) +} + +private let parseAndButOrListItem = parseOptionalComments + .then( + Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + let description: String + if trimmed.hasPrefix("*") { + description = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) + } else if trimmed.hasPrefix("And") { + description = trimmed.dropFirst(3).trimmingCharacters(in: .whitespaces) + } else if trimmed.hasPrefix("But") { + description = trimmed.dropFirst(3).trimmingCharacters(in: .whitespaces) + } else { + throw "Not a Statement that starts with And, But, or *" + } + guard !description.isEmpty else { + throw "A Statement must have a description after the And, But or * keyword" + } + return description + } + ) + .then(parseOptionalData) + .flattened() + +private let parseStatement = parseOptionalComments + .then(parseStatementKeyword).then(parseOptionalData) + .flattened() + .map { + Requirement.Example.Statement(comments: $0, type: $1.0, description: $1.1, data: $2) + } + +private let parseEitherKindOfStatement = parseStatement + .then(.oneOrMore(parseAndButOrListItem, until: .end.or(.not( parseAndButOrListItem)))) + .map { statement, items in + return [statement] + items.map { + Requirement.Example.Statement(comments: $0.0, type: statement.type, description: $0.1, data: $0.2) + } + } + .or(parseStatement.map { [$0] }) + +private let parseStatements = Parser.oneOrMore(parseEitherKindOfStatement, until: .end.or(.not(parseEitherKindOfStatement))) + .map { Array($0.joined()) } + +private let parseExample = parseOptionalCommentsAndTags + .then(parseExampleDescription) + .then(parseExtendedDescription) + .then(parseStatements) + .flattened() + .map { commentsAndTags, description, statements in + guard !statements.isEmpty else { + throw "An Example must contain one or more Statements" + } + return Requirement.Example(comments: commentsAndTags?.comments, identifier: nil, labels: commentsAndTags?.tags, description: description, statements: statements) + } + +private let parseScenarioOutlineDescription = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("Scenario Outline:") == true || trimmed.hasPrefix("Scenario Template:") == true else { + throw "Line doesn't begin with \"Scenario Outline:\" or \"Scenario Template:\"" + } + let description = trimmed.drop { $0 != ":" }.dropFirst().trimmingCharacters(in: .whitespaces) + guard !description.isEmpty else { + throw "There must be a non-empty description after the the \"Scenario Outline:\" or \"Scenario Template:\" keyword" + } + return description +} + +let parseScenarioOutline = parseOptionalCommentsAndTags + .then(parseScenarioOutlineDescription) + .then(parseExtendedDescription) + .then(parseStatements) + .flattened() + .map { commentsAndTags, description, statements in + guard !statements.isEmpty else { + throw "A Scenario Outline / Scenario Template must contain one or more Statements" + } + return Requirement.Example(comments: commentsAndTags?.comments, identifier: nil, labels: commentsAndTags?.tags, description: description, statements: statements) + } + +private let parseExamplesOrScenariosKeyword = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("Examples:") || trimmed.hasPrefix("Scenarios:") else { + throw "No Examples: or Scenarios: keyword found" + } + guard trimmed.replacingOccurrences(of: "Examples:", with: "").isEmpty || trimmed.replacingOccurrences(of: "Scenarios:", with: "").isEmpty else { + throw "The Examples: or Scenarios: keyword should not have any description after it and should be on a line by itself" + } + return () +} + +private let parseExampleRow: Parser = parseOptionalCommentsAndTags + .then(parseTableRow) + .map { ExampleRow(comments: $0.0?.comments, tags: $0.0?.tags, values: $0.1) } + +private let parseExampleTemplate: Parser<[Requirement.Example]> = parseScenarioOutline + .then(parseExamplesOrScenariosKeyword) + .then(.oneOrMore(parseExampleRow, until: .end.or(.not(parseExampleRow)))) + .map { example, examples in + guard !example.tokens.isEmpty else { + throw "There are no template tokens present in the Example template" + } + guard examples.count >= 2 else { + throw "Examples should be a table containing at least 2 rows: a headers row and at least one row of values" + } + let headers = examples.first! + guard headers.comments == nil, headers.tags == nil else { + throw "The Examples / Scenarios header row can't have comments or tags" + } + guard examples.reduce(true, { $0 && $1.values.count == headers.values.count }) else { + throw "Every row in the Examples table must have the same number of colums" + } + guard Set(example.tokens.map { $0.trimmingCharacters(in: .init(charactersIn: "<>")) }) == Set(headers.values.drop { $0.isEmpty }) else { + throw "Every unique template variable must have exactly one matching column in the Examples table" + } + return examples.dropFirst().map { + var description: String? + var statements = example.statements + $0.values.enumerated().forEach { enumerated in + if enumerated.offset == 0, headers.values.first!.isEmpty { + description = enumerated.element + } else { + statements = statements.map { $0.replacing(token: "<" + headers.values[enumerated.offset] + ">", with: enumerated.element) } + } + } + return Requirement.Example(comments: $0.comments, identifier: nil, labels: $0.tags, description: description, statements: statements) + } + } + +private let parseExamples: Parser<[Requirement.Example]> = Parser<[[Requirement.Example]]>.oneOrMore( + parseExampleTemplate.or( parseExample.map { [$0] }), + until: parseRuleDescription.then(parseExtendedDescription).map { _ in () }.or(.end).or(.not(parseExampleTemplate.or(parseExample.map { [$0] }))) +).map { + let joined = Array($0.joined()) + guard !joined.isEmpty else { + throw "No Examples were parsed" + } + return joined +} + +private let parseTextData: Parser = parseTextDelimiter + .then(.oneOrMore(parseString, until: parseTextDelimiter)) + .then(parseTextDelimiter) + .map { $0.joined(separator: "\n") } + .map { Requirement.Example.Statement.Data.text($0) } + +private let parseOptionalData: Parser = parseTextData + .or(parseMatrixData) + .or(parseKeyValueData) + .or(parseListData) + .or(parseTableData).map { .some($0) } + .or(Parser.end.map { Optional.none }) + .or(Parser(consumeLine: false) { _ in () }.map { Optional.none }) + +private let parseString = Parser { + return $0.trimmingCharacters(in: .whitespaces) +} + +private let parseTextDelimiter = Parser { + guard $0.trimmingCharacters(in: .whitespaces) == "\"\"\"" else { + throw "Text data must start and end with the \"\"\" delimiter on a single line of its own" + } + return () +} + +private let parseKeyValueData = Parser.oneOrMore(parseTableRow, until: .end.or(.not(parseTableRow))) + .map { + guard $0.reduce(true, { $1.count == 1 && $0 ? true : false }) else { + throw "Key Value Data must be formatted as a single column table" + } + return try Requirement.Example.Statement.Data.keyValues(OrderedDictionary($0.map { + let keyValue = $0.first!.split(separator: ":").map { $0.trimmingCharacters(in: .whitespaces) } + guard keyValue.count == 2 else { + throw "Each row in Key Value data must have the format '| key: value |'" + } + return (keyValue[0], keyValue[1]) + }, uniquingKeysWith: { $1 })) + } + +private let parseListData = Parser.oneOrMore(parseTableRow, until: .end.or(.not(parseTableRow))) + .map { + guard $0.reduce(true, { $1.count == 1 && $0 ? true : false }) else { + throw "List Data must be formatted as a single column table" + } + return Requirement.Example.Statement.Data.list($0.map { $0.first!.trimmingCharacters(in: .whitespaces) }) + } + +private let parseTableData = Parser.oneOrMore(parseTableRow, until: .end.or(.not(parseTableRow))) + .map { + let columns = $0.first! + guard columns.count >= 1 && $0.count >= 2 else { + throw "Table Data must have at least one column and at least three rows" + } + guard $0.reduce(true, { $1.count == columns.count && $0 ? true : false }) else { + throw "All rows must have the same number of columns for valid Table Data" + } + return Requirement.Example.Statement.Data.table($0.dropFirst().map { row in + return OrderedDictionary(uniqueKeysWithValues: columns.enumerated().map { ($0.element, row[$0.offset]) }) + }) + } + +private let parseMatrixData = Parser.oneOrMore(parseTableRow, until: .end.or(.not(parseTableRow))) + .map { + let columns = $0.first! + guard columns.count >= 2 && $0.count >= 3 else { + throw "Matrix Data must have at least two columns and at least three rows" + } + guard $0.reduce(true, { $1.count == columns.count && $0 ? true : false }) else { + throw "All rows must have the same number of columns for valid Matrix Data" + } + guard columns.first?.trimmingCharacters(in: .whitespaces).isEmpty == true else { + throw "The first column header of Matrix Data should be empty" + } + + return Requirement.Example.Statement.Data.matrix(OrderedDictionary(uniqueKeysWithValues: $0.dropFirst().map { row in + return (row[0].trimmingCharacters(in: .whitespaces), OrderedDictionary(uniqueKeysWithValues: Array(columns.dropFirst()).enumerated().map { + return ($0.element, Array(row.dropFirst())[$0.offset]) + })) + })) + } + +private let parseTableRow = Parser<[String]> { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "|" && trimmed.last == "|" else { + throw "Table rows must be contained inside pipe | characters" + } + let array = trimmed.trimmingCharacters(in: ["|"]).split(separator: "|").map({ $0.trimmingCharacters(in: .whitespaces) }) + guard !array.isEmpty else { + throw "Line is not a table row because it doesn't contain data in between | characters" + } + return array +} diff --git a/Sources/RequirementsKit/Parsing/Parsing.swift b/Sources/RequirementsKit/Parsing/Parsing.swift new file mode 100644 index 0000000..7fae513 --- /dev/null +++ b/Sources/RequirementsKit/Parsing/Parsing.swift @@ -0,0 +1,356 @@ +// +// Parsing.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import OrderedCollections + + +struct Line { + let number: Int + let text: String +} + +struct Parser { + private let closure: (inout [Line]) throws -> T + + private init(closure: @escaping (inout [Line]) throws -> T) { + self.closure = closure + } + + @_disfavoredOverload + init(consumeLine: Bool = true, _ closure: @escaping (String) throws -> T) { + self.closure = { lines in + lines = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + guard let first = lines.first else { + throw ParsingError(line: 0, description: "Can't parse an empty array of lines") + } + do { + let result = try closure(first.text) + if consumeLine { + lines = Array(lines.dropFirst()) + } + return result + } catch { + throw ParsingError(line: first.number, description: error.localizedDescription) + } + } + } + + init(consumeLine: Bool = true, _ closure: @escaping (String) throws -> T) where T == U? { + self.closure = { lines in + lines = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + guard let first = lines.first else { + throw ParsingError(line: 0, description: "Can't parse an empty array of lines") + } + do { + let result = try closure(first.text) + if consumeLine && result != nil { + lines = Array(lines.dropFirst()) + } + return result + } catch { + throw ParsingError(line: first.number, description: error.localizedDescription) + } + } + } + + func callAsFunction(_ lines: inout [Line]) throws -> T { + try closure(&lines) + } + + func or(_ parser: Parser) -> Parser { + return .init { lines in + var temporary = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + do { + let result = try self(&temporary) + lines = temporary + return result + } catch { + let firstError = error + do { + let result = try parser.closure(&temporary) + lines = temporary + return result + } catch { + switch (firstError as? ParsingError, error as? ParsingError) { + case (.none, .none): throw error + case (.none, .some(let error)), (.some(let error), .none): throw error + case (.some(let first), .some(let second)): + if first.line > second.line { + throw first + } + throw second + } + } + } + } + } + + @_disfavoredOverload + func then(_ parser: Parser) -> Parser<(T, U)> { + return .init { lines in + var temporary = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + let firstResult = try self(&temporary) + let secondResult = try parser(&temporary) + lines = temporary + return (firstResult, secondResult) + } + } + + func then(_ parser: Parser) -> Parser { + return .init { lines in + var temporary = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + let result = try self(&temporary) + _ = try parser(&temporary) + lines = temporary + return result + } + } + + + func map(_ transform: @escaping (T) throws -> U) -> Parser { + .init { lines in + var temporary = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + let result = try self(&temporary) + do { + let mapped = try transform(result) + lines = temporary + return mapped + } catch { + throw ParsingError(line: lines.first?.number ?? 0, description: error.localizedDescription) + } + } + } +} + +extension Parser where T == Void { + func then(_ parser: Parser) -> Parser<(U)> { + return .init { lines in + var temporary = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + _ = try self(&temporary) + let result = try parser(&temporary) + lines = temporary + return result + } + } + +} + +extension Parser where T == Void { + + static var end: Self { + .init { lines in + lines = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + guard lines.isEmpty else { + throw "Expected end of file but there are still lines remaining" + } + return () + } + } + + static func not(_ parser: Parser) -> Self { + return .init { lines in + var temporary = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + do { + _ = try parser(&temporary) + } catch { + return () + } + throw ParsingError(line: lines.first?.number ?? 0, description: "Parser was expected not to succeed and to not return a \(U.self), but it succeeded") + } + } +} + +extension Parser { + + static func zeroOrMore(_ parser: Parser, until: Parser) -> Self where T == [U] { + let until = AnyParser(until) + return .init { lines in + var temporary = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + var checkRemaining = lines + var accumulated = [U]() + var proceed = true + while proceed { + do { + _ = try until(&checkRemaining) + proceed = false + } catch { + accumulated.append(try parser(&temporary)) + checkRemaining = temporary + } + } + if !accumulated.isEmpty { + lines = temporary + } + return accumulated + } + } + + static func oneOrMore(_ parser: Parser, until: Parser) -> Self where T == [U] { + let until = AnyParser(until) + return .init { lines in + var temporary = Array(lines.drop { $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) + var checkRemaining = lines + var accumulated = [U]() + var proceed = true + while proceed { + do { + _ = try until(&checkRemaining) + proceed = false + } catch { + accumulated.append(try parser(&temporary)) + checkRemaining = temporary + } + } + if accumulated.isEmpty { + throw ParsingError(line: temporary.first?.number ?? 0, description: "No instances of \(U.self) accumulated when at least one was expected") + } + lines = temporary + return accumulated + } + } +} + +extension Parser { + + func flattened() -> Parser<(A, B, C)> where T == ((A, B), C) { + map { ($0.0, $0.1, $1) } + } + + func flattened() -> Parser<(A, B, C, D)> where T == (((A, B), C), D) { + map { ($0.0.0, $0.0.1, $0.1, $1) } + } + + func flattened() -> Parser<(A, B, C, D, E)> where T == ((((A, B), C), D), E) { + map { ($0.0.0.0, $0.0.0.1, $0.0.1, $0.1, $1) } + } + + func flattened() -> Parser<(A, B, C, D, E, F)> where T == (((((A, B), C), D), E), F) { + map { ($0.0.0.0.0, $0.0.0.0.1, $0.0.0.1, $0.0.1, $0.1, $1) } + } + + func flattened() -> Parser<(A, B, C, D, E, F, G)> where T == ((((((A, B), C), D), E), F), G) { + map { ($0.0.0.0.0.0, $0.0.0.0.0.1, $0.0.0.0.1, $0.0.0.1, $0.0.1, $0.1, $1) } + } +} + +private struct AnyParser { + private let closure: (inout [Line]) throws -> Any + init(_ parser: Parser) { + self.closure = { try parser(&$0) } + } + func callAsFunction(_ lines: inout [Line]) throws -> Any { + try closure(&lines) + } +} + +struct ParsingError: Error { + let line: Int + let description: String +} + +extension String: LocalizedError { + public var errorDescription: String? { self } +} + +extension Requirement.Example._ExampleSet { + var tokens: Set { + return (description?.tokens ?? []).union(statements.map { $0.tokens }.joined()) + } + + func replacing(token: String, with value: String) -> Self { + .init(comments: comments, identifier: identifier, labels: labels, description: description?.replacingOccurrences(of: token, with: value), statements: statements.map { $0.replacing(token: token, with: value) }) + } +} + +extension Requirement.Example { + var tokens: Set { + return (description?.tokens ?? []).union(statements.map { $0.tokens }.joined()) + } + + func replacing(token: String, with value: String) -> Self { + .init(comments: comments, identifier: identifier, labels: labels, description: description?.replacingOccurrences(of: token, with: value), statements: statements.map { $0.replacing(token: token, with: value) }) + } +} + +extension Requirement.Example.Statement { + var tokens: Set { + description.tokens.union(data?.tokens ?? []) + } + + func replacing(token: String, with value: String) -> Self { + return .init(comments: comments, type: type, description: description.replacingOccurrences(of: token, with: value), data: data?.replacing(token: token, with: value)) + } +} + +extension Requirement.Example.Statement.Data { + var tokens: Set { + switch self { + case .text(let text): return text.tokens + case .list(let list): return Set(list.map { $0.tokens }.joined()) + case .keyValues(let keyValues): return Set(keyValues.map { $0.key.tokens.union($0.value.tokens) }.joined()) + case .table(let table): return Set(table.map { $0.map { $0.key.tokens.union($0.value.tokens) }.joined() }.joined()) + case .matrix(let matrix): return Set(matrix.map { $0.key.tokens.union(Set($0.value.map { $0.key.tokens.union($0.value.tokens) }.joined())) }.joined()) + } + } + + func replacing(token: String, with value: String) -> Self { + switch self { + case .text(let text): return .text(text.replacingOccurrences(of: token, with: value)) + case .list(let list): return .list(list.map { $0.replacingOccurrences(of: token, with: value) }) + case .keyValues(let keyValues): + return .keyValues(OrderedDictionary(uniqueKeysWithValues: keyValues.map { ($0.key.replacingOccurrences(of: token, with: value), $0.value.replacingOccurrences(of: token, with: value)) })) + case .table(let table): + return .table(table.map { OrderedDictionary(uniqueKeysWithValues: $0.map { ($0.key.replacingOccurrences(of: token, with: value), $0.value.replacingOccurrences(of: token, with: value)) }) }) + case .matrix(let matrix): + return .matrix(OrderedDictionary(uniqueKeysWithValues: matrix.map { ($0.key.replacingOccurrences(of: token, with: value), OrderedDictionary(uniqueKeysWithValues: $0.value.map { ($0.key.replacingOccurrences(of: token, with: value), $0.value.replacingOccurrences(of: token, with: value)) })) })) + } + } +} + +extension String { + var tokens: Set { + let regex = try! NSRegularExpression(pattern: "<.+?>") + let matches = regex.matches(in: self, range: .init(location: 0, length: self.count)) + return Set(matches.map { + String(self[Range($0.range, in: self)!]) + }) + } +} + +internal extension [String]? { + func combinedWith(_ array: [String]?) -> [String]? { + switch (self, array) { + case (.none, .none): return nil + case (.none, .some(let existing)), (.some(let existing), .none): return existing + case (.some(let first), .some(let second)): + var combined = second + first.forEach { + if !combined.contains($0) { combined.append($0) } + } + return combined + } + } +} diff --git a/Sources/RequirementsKit/Parsing/ReqsMLParser.swift b/Sources/RequirementsKit/Parsing/ReqsMLParser.swift new file mode 100644 index 0000000..d8752da --- /dev/null +++ b/Sources/RequirementsKit/Parsing/ReqsMLParser.swift @@ -0,0 +1,499 @@ +// +// ReqsMLParser.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import OrderedCollections + + +struct Metadata { + let identifier: String? + let labels: [String]? +} + +struct CommentsAndMetadata { + let comments: [String]? + let metadata: Metadata? +} + +private struct ExampleRow { + let comments: [String]? + let metadata: Metadata? + let values: [String] +} + +func parseReqsML(from url: URL) throws -> File { + let data = try Data(contentsOf: url) + guard let string = String(data: data, encoding: .ascii) else { + throw RequirementsKitError(errorDescription: "Couldn't decode text from file at url \(url)") + } + var lines = string.split(separator: "\n", omittingEmptySubsequences: false).enumerated().map { Line(number: $0.offset + 1, text: String($0.element)) } + return File(url: url, comments: nil, labels: nil, description: nil, syntax: .reqsML, requirements: try Parser.oneOrMore(parseRequirement, until: .end)(&lines)) +} + +private let parseRequirement = parseOptionalCommentsAndMetadata.then(parseRequirementDescription).then(parseSingleExample).flattened().map { commentsAndMetadata, description, example in + guard example.description?.tokens.isEmpty != false && example.statements.reduce(Set(), { $0.union($1.tokens) }).isEmpty else { + throw "A single example requirement shouldn't have template tokens without an Examples: table" + } + let combinedLabels = commentsAndMetadata?.metadata?.labels.combinedWith(example.labels) + let example = Requirement.Example(comments: example.comments, identifier: example.identifier, labels: combinedLabels, explicitLabels: example.labels, description: example.description, statements: example.statements) + return Requirement(comments: commentsAndMetadata?.comments, identifier: commentsAndMetadata?.metadata?.identifier, labels: commentsAndMetadata?.metadata?.labels, description: description, examples: [example]) +} + .or ( + parseOptionalCommentsAndMetadata.then(parseRequirementDescription).then(parseExamples).flattened().map { commentsAndMetadata, description, examples in + let examples = examples.map { example in + let combinedLabels = commentsAndMetadata?.metadata?.labels.combinedWith(example.labels) + return Requirement.Example(comments: example.comments, identifier: example.identifier, labels: combinedLabels, explicitLabels: example.labels, description: example.description, statements: example.statements, exampleSet: example.exampleSet, specification: example.specification) + } + return Requirement(comments: commentsAndMetadata?.comments, identifier: commentsAndMetadata?.metadata?.identifier, labels: commentsAndMetadata?.metadata?.labels, description: description, examples: examples) + } + ).or ( + parseOptionalCommentsAndMetadata.then(parseRequirementDescription).then(parseExampleTemplate).flattened().map { commentsAndMetadata, description, examples in + let examples = examples.map { example in + let combinedLabels = commentsAndMetadata?.metadata?.labels.combinedWith(example.labels) + return Requirement.Example(comments: example.comments, identifier: example.identifier, labels: combinedLabels, explicitLabels: example.labels, description: example.description, statements: example.statements, exampleSet: example.exampleSet, specification: example.specification) + } + return Requirement(comments: commentsAndMetadata?.comments, identifier: commentsAndMetadata?.metadata?.identifier, labels: commentsAndMetadata?.metadata?.labels, description: description, examples: examples) + } + ) + + +private let parseComment = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("//") else { + throw "Can't parse comment because line doesn't start with //" + } + return trimmed.drop { $0 == "/" }.trimmingCharacters(in: .whitespaces) +} + +private let parseOptionalComments: Parser<[String]?> = Parser.zeroOrMore(parseComment, until: .end.or(.not( parseComment))).map { $0.isEmpty ? nil : $0 } + +private let parseMetadata = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "#" else { + throw "Identifier or labels must be preceded by #" + } + let identifier = trimmed.dropFirst().prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces) + let labelString = trimmed.dropFirst().drop { $0 != "(" }.trimmingCharacters(in: .whitespaces) + if labelString.isEmpty && identifier.isEmpty { + throw "Can't have a # that isn't followed by either an identifier, labels in parentheses, or both" + } + if labelString.isEmpty { + return Metadata(identifier: identifier, labels: nil) + } + guard labelString.last == ")" else { + throw "Missing closing parenthesis on labels" + } + let labels = labelString.dropFirst().dropLast().trimmingCharacters(in: .whitespaces).split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + guard labels.first?.isEmpty == false else { + throw "There must be at least one label specified inside parentheses ()" + } + return Metadata(identifier: identifier.isEmpty ? nil : identifier, labels: labels) +} + +private let parseOptionalMetadata: Parser = parseMetadata.map { .some($0) }.or (Parser(consumeLine: false) { _ in nil }) + +private let parseOptionalCommentsAndMetadata: Parser = +parseOptionalComments.then(parseOptionalMetadata).map { + if $0.0 == nil && $0.1 == nil { + return nil + } + return CommentsAndMetadata(comments: $0.0, metadata: $0.1) +} + +private let parseRequirementDescription = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("Requirement:") == true else { + throw "Line doesn't begin with \"Requirement:\"" + } + let description = trimmed.drop { $0 != ":" }.dropFirst().trimmingCharacters(in: .whitespaces) + guard !description.isEmpty else { + throw "There must be a non-empty description of the Requirement after the the \"Requirement:\" keyword" + } + return description +} + +private let parseExampleDescription = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("Example:") == true else { + throw "Line doesn't begin with \"Example:\"" + } + let description = trimmed.drop { $0 != ":" }.dropFirst().trimmingCharacters(in: .whitespaces) + guard !description.isEmpty else { + throw "There must be a non-empty description of the Example after the the \"Example:\" keyword" + } + return description +} + +private let parseExampleSetDescription = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("ExampleSet:") == true || trimmed.hasPrefix("Example Set:") == true else { + throw "Line doesn't begin with \"ExampleSet:\" or \"Example Set:\"" + } + let description = trimmed.drop { $0 != ":" }.dropFirst().trimmingCharacters(in: .whitespaces) + guard !description.isEmpty else { + throw "There must be a non-empty description of the Example Set after the the \"ExampleSet:\" or \"Example Set:\" keyword" + } + return description +} + +private let parseStatementKeyword = Parser<(Requirement.Example.StatementType, String)> { + let type: Requirement.Example.StatementType + let trimmed = $0.trimmingCharacters(in: .whitespaces) + switch trimmed.prefix(upTo: trimmed.firstIndex(of: ":") ?? trimmed.startIndex) { + case "If": type = .if + case "When": type = .when + case "Expect": type = .expect + default: throw "No If, When, or Expect keyword found" + } + let description = trimmed.drop { $0 != ":" }.dropFirst().trimmingCharacters(in: .whitespaces) + return (type, description) +} + +private let parseListItem = parseOptionalComments.then( + Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard ["-", "*", "•"].contains(trimmed.first) else { + throw "Not a statement list item because the line doesn't start with -, * or •" + } + let description = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) + guard !description.isEmpty else { + throw "A statement list item must have a description and cannot be empty" + } + return description + } +).then(parseOptionalData).flattened() + +private let parseStatement = parseOptionalComments.then(parseStatementKeyword).then(parseOptionalData).flattened().map { + Requirement.Example.Statement(comments: $0, type: $1.0, description: $1.1, data: $2) +} + +private let parseEitherKindOfStatement = parseStatement.map { + guard !$0.description.isEmpty else { + throw "A Statement must have a description following the If:, When:, or Expect:" + } + return [$0] +}.or ( + parseStatement.then(.oneOrMore(parseListItem, until: .end.or(.not(parseListItem)) ) ).map { statement, items in + guard statement.comments == nil else { + throw "Comments for a statement list must be placed above each list item, not above the statement keyword" + } + guard statement.description.isEmpty else { + throw "Can't have a Statement that contains both a description and a list. Remove any text after the \(statement.type.rawValue.capitalized): keyword" + } + return items.map { + Requirement.Example.Statement(comments: $0.0, type: statement.type, description: $0.1, data: $0.2) + } + } +) + +private let parseStatements = Parser.oneOrMore(parseEitherKindOfStatement, until: .end.or(.not( parseEitherKindOfStatement))).map { Array($0.joined()) } + +private let parseExample = parseOptionalCommentsAndMetadata.then(parseExampleDescription).then(parseStatements ).flattened().map { commentsAndMetadata, description, statements in + guard !statements.isEmpty else { + throw "An Example must contain one or more Statements" + } + return Requirement.Example(comments: commentsAndMetadata?.comments, identifier: commentsAndMetadata?.metadata?.identifier, labels: commentsAndMetadata?.metadata?.labels, description: description, statements: statements) +} + +private let parseSingleExample = parseOptionalCommentsAndMetadata.then(parseStatements).map { + guard $0 == nil else { + throw "When a Requirement has a single in-line Example that Example can't include separate comments, labels or identifiers" + } + return Requirement.Example(comments: nil, identifier: nil, labels: nil, description: nil, statements: $1) +} + +private let parseExamplesKeyword = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("Examples:") else { + throw "No Examples: keyword found" + } + guard trimmed.replacingOccurrences(of: "Examples:", with: "").isEmpty else { + throw "The Examples: keyword should not have any description after it and should be on a line by itself" + } + return () +} + +private let parseExampleRowWithoutComments = Parser { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + let prefix = trimmed.prefix { $0 != "|" }.trimmingCharacters(in: .whitespaces) + let metadata: Metadata? +HandlePrefix: + if !prefix.isEmpty { + guard prefix.first == "#" else { + throw "Examples can only contain a valid identifier and/or labels before the table, e.g. '#identifier (labelOne, labelTwo) | some value | another value |'" + } + let identifier = prefix.dropFirst().prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces) + let labelString = prefix.dropFirst().drop { $0 != "(" }.trimmingCharacters(in: .whitespaces) + if labelString.isEmpty && identifier.isEmpty { + throw "Can't have a # that isn't followed by either an identifier, labels in parentheses, or both" + } + if labelString.isEmpty { + metadata = .init(identifier: identifier, labels: nil) + break HandlePrefix + } + guard labelString.last == ")" else { + throw "Missing closing parenthesis on labels" + } + let labels = labelString.dropFirst().dropLast().trimmingCharacters(in: .whitespaces).split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + guard labels.first?.isEmpty == false else { + throw "There must be at least one label specified inside parentheses ()" + } + metadata = .init(identifier: identifier.isEmpty ? nil : identifier, labels: labels) + break HandlePrefix + } else { + metadata = nil + } + let row = trimmed.drop { $0 != "|" } + guard row.first == "|" && row.last == "|" else { + throw "Examples rows must be in table format inside pipe | characters" + } + let array = row.trimmingCharacters(in: ["|"]).split(separator: "|").map({ $0.trimmingCharacters(in: .whitespaces) }) + guard !array.isEmpty else { + throw "Line is not an Examples row because it doesn't contain data in between | characters" + } + return ExampleRow(comments: nil, metadata: metadata, values: array) +} + +private let parseExampleRow: Parser = parseOptionalComments + .then(parseExampleRowWithoutComments) + .map { ExampleRow(comments: $0.0, metadata: $0.1.metadata, values: $0.1.values) } + +private let parseExampleSet = parseOptionalCommentsAndMetadata.then(parseExampleSetDescription).then(parseStatements).then(parseExamplesKeyword).then(parseExampleRows).flattened().map { commentsAndMetadata, description, statements, exampleRows in + + let exampleSet = Requirement.Example._ExampleSet(comments: commentsAndMetadata?.comments, identifier: commentsAndMetadata?.metadata?.identifier, labels: commentsAndMetadata?.metadata?.labels, description: description, statements: statements) + + guard statements.reduce(false, { ($0 || !$1.tokens.isEmpty) ? true : false }) else { + throw "There are no tokens present in the Example Set statements" + } + guard exampleRows.count >= 3 else { + throw "Examples should be a table containing at least 3 rows: a headers row, a separator row, and at least one row of values" + } + let headers = exampleRows.first! + if headers.values.count == 1, headers.values.first!.isEmpty { + throw "Examples table can't have a single column with no header value" + } + guard headers.values.dropFirst().reduce(true, { !$1.isEmpty && $0 == true ? true : false }) else { + throw "Only the first column of an Examples table can have an empty header, which signifies that the column will be used for each Example's description" + } + if headers.values.first!.isEmpty { + guard exampleRows.dropFirst(2).reduce(true, { !$1.values.first!.trimmingCharacters(in: .whitespaces).isEmpty && $0 == true ? true : false }) else { + throw "If the Examples table includes a first column containing descriptions, there must be a non-empty description provided for each row" + } + } + let separators = exampleRows.dropFirst().first! + guard headers.comments == nil && headers.metadata == nil else { + throw "The Examples header row can't have comments, identifiers or labels" + } + guard separators.values.reduce(true, { + let array = $1.trimmingCharacters(in: .whitespaces).split(separator: "-", omittingEmptySubsequences: false) + return array.count >= 4 && array.joined().isEmpty + }) else { + throw "Examples must start with a header row, followed by a row with '----' (three or more hyphens) for each column" + } + + guard exampleRows.reduce(true, { $0 && $1.values.count == headers.values.count }) else { + throw "Every row in the Examples table must have the same number of colums" + } + + guard Set(exampleSet.tokens.map { $0.trimmingCharacters(in: .init(charactersIn: "<>")) }) == Set(headers.values.drop { $0.isEmpty }) else { + throw "Every unique token must have exactly one matching column in the Examples table" + } + + return exampleRows.dropFirst(2).map { + var description: String? + var statements = exampleSet.statements + let specification = Requirement.Example._ExampleSpecification(comments: $0.comments, identifier: $0.metadata?.identifier, description: headers.values.first!.isEmpty ? $0.values.first : nil, labels: $0.metadata?.labels, values: .init(uniqueKeysWithValues: zip(headers.values, $0.values))) + $0.values.enumerated().forEach { enumerated in + if enumerated.offset == 0 { + if headers.values.first!.isEmpty { + description = enumerated.element + } else { + description = exampleSet.description + } + } + description = description?.replacingOccurrences(of: "<" + headers.values[enumerated.offset] + ">", with: enumerated.element) + statements = statements.map { $0.replacing(token: "<" + headers.values[enumerated.offset] + ">", with: enumerated.element) } + } + return Requirement.Example(comments: $0.comments, identifier: $0.metadata?.identifier, labels: $0.metadata?.labels, explicitLabels: $0.metadata?.labels, description: description, statements: statements, exampleSet: exampleSet, specification: specification) + } +} + +private let parseExampleRows = Parser.oneOrMore(parseExampleRow, until: .end.or(.not(parseExampleRow))) + +private let parseExampleTemplate = parseSingleExample.then(parseExamplesKeyword).then(parseExampleRows).map { example, examples in + guard !example.tokens.isEmpty else { + throw "There are no tokens present in the Example Set" + } + guard examples.count >= 3 else { + throw "Examples should be a table containing at least 3 rows: a headers row, a separator row, and at least one row of values" + } + let headers = examples.first! + if headers.values.count == 1, headers.values.first!.isEmpty { + throw "Examples table can't have a single column with no header value" + } + guard headers.values.dropFirst().reduce(true, { !$1.isEmpty && $0 == true ? true : false }) else { + throw "Only the first column of an Examples table can have an empty header, which signifies that the column will be used for each Example's description" + } + if headers.values.first!.isEmpty { + guard examples.dropFirst(2).reduce(true, { !$1.values.first!.trimmingCharacters(in: .whitespaces).isEmpty && $0 == true ? true : false }) else { + throw "If the Examples table includes a first column containing descriptions, there must be a non-empty description provided for each row" + } + } + let separators = examples.dropFirst().first! + guard headers.comments == nil && headers.metadata == nil else { + throw "The Examples header row can't have comments, identifiers or labels" + } + guard separators.values.reduce(true, { + let array = $1.trimmingCharacters(in: .whitespaces).split(separator: "-", omittingEmptySubsequences: false) + return array.count >= 4 && array.joined().isEmpty + }) else { + throw "Examples must start with a header row, followed by a row with '----' (three or more hyphens) for each column" + } + guard examples.reduce(true, { $0 && $1.values.count == headers.values.count }) else { + throw "Every row in the Examples table must have the same number of colums" + } + guard Set(example.tokens.map { $0.trimmingCharacters(in: .init(charactersIn: "<>")) }) == Set(headers.values.drop { $0.isEmpty }) else { + throw "Every unique template variable must have exactly one matching column in the Examples table" + } + + let exampleSet = Requirement.Example._ExampleSet(comments: nil, identifier: nil, labels: nil, description: nil, statements: example.statements) + + return examples.dropFirst(2).map { + var description: String? + var statements = example.statements + $0.values.enumerated().forEach { enumerated in + if enumerated.offset == 0, headers.values.first!.isEmpty { + description = enumerated.element + } else { + description = description?.replacingOccurrences(of: "<" + headers.values[enumerated.offset] + ">", with: enumerated.element) + statements = statements.map { $0.replacing(token: "<" + headers.values[enumerated.offset] + ">", with: enumerated.element) } + } + } + let headerValues = (headers.values.first?.isEmpty == true) ? Array(headers.values.dropFirst()) : headers.values + let values = (headers.values.first?.isEmpty == true) ? Array($0.values.dropFirst()) : $0.values + let specification = Requirement.Example._ExampleSpecification(comments: $0.comments, identifier: $0.metadata?.identifier, description: description, labels: $0.metadata?.labels, values: .init(uniqueKeysWithValues: zip(headerValues, values))) + return Requirement.Example(comments: $0.comments, identifier: $0.metadata?.identifier, labels: $0.metadata?.labels, explicitLabels: $0.metadata?.labels, description: description, statements: statements, exampleSet: exampleSet, specification: specification) + } +} + +private let parseTextData: Parser = parseTextDelimiter.then( .oneOrMore(parseString, until: parseTextDelimiter)).then(parseTextDelimiter).map { $0.joined(separator: "\n") }.map { Requirement.Example.Statement.Data.text($0) } + +private let parseOptionalData: Parser = +parseTextData + .or(parseMatrixData) + .or(parseTableData) + .or(parseKeyValueData) + .or(parseListData).map { .some($0) } + .or(Parser.end.map { Optional.none }) + .or(Parser(consumeLine: false) { _ in () }.map { Optional.none }) + +private let parseString = Parser { + return $0.trimmingCharacters(in: .whitespaces) +} + +private let parseTextDelimiter = Parser { + guard $0.trimmingCharacters(in: .whitespaces) == "```" else { + throw "Text data must start and end with the ``` delimiter on a single line of its own" + } + return () +} + +private let parseKeyValueData = Parser.oneOrMore(parseTableRow, until: .end.or(.not(parseTableRow))).map { + guard $0.reduce(true, { $1.count == 1 && $0 ? true : false }) else { + throw "Key Value Data must be formatted as a single column table" + } + return try Requirement.Example.Statement.Data.keyValues(OrderedDictionary($0.map { + let keyValue = $0.first!.split(separator: ":").map { $0.trimmingCharacters(in: .whitespaces) } + guard keyValue.count == 2 else { + throw "Each row in Key Value data must have the format '| key: value |'" + } + return (keyValue[0], keyValue[1]) + }, uniquingKeysWith: { $1 })) +} + +private let parseListData = Parser.oneOrMore(parseTableRow, until: .end.or(.not(parseTableRow))).map { + guard $0.reduce(true, { $1.count == 1 && $0 ? true : false }) else { + throw "List Data must be formatted as a single column table" + } + return Requirement.Example.Statement.Data.list($0.map { $0.first!.trimmingCharacters(in: .whitespaces) }) +} + +private let parseTableData = Parser.oneOrMore(parseTableRow, until: .end.or(.not(parseTableRow))).map { + let columns = $0.first! + guard columns.count >= 1 && $0.count >= 3 else { + throw "Table Data must have at least one column and at least three rows" + } + guard $0.dropFirst().first!.reduce(true, { + let array = $1.trimmingCharacters(in: .whitespaces).split(separator: "-", omittingEmptySubsequences: false) + return array.count >= 4 && array.joined().isEmpty + }) else { + throw "Table Data must start with a header row, followed by a row with '----' (three or more hyphens) for each column" + } + guard $0.reduce(true, { $1.count == columns.count && $0 ? true : false }) else { + throw "All rows must have the same number of columns for valid Table Data" + } + return Requirement.Example.Statement.Data.table($0.dropFirst(2).map { row in + return OrderedDictionary(uniqueKeysWithValues: columns.enumerated().map { ($0.element, row[$0.offset]) }) + }) +} + +private let parseMatrixData = Parser.oneOrMore(parseTableRow, until: .end.or(.not(parseTableRow))).map { + let columns = $0.first! + guard columns.count >= 2 && $0.count >= 3 else { + throw "Matrix Data must have at least two columns and at least three rows" + } + guard $0.dropFirst().first!.reduce(true, { + let array = $1.trimmingCharacters(in: .whitespaces).split(separator: "-", omittingEmptySubsequences: false) + return array.count >= 4 && array.joined().isEmpty + }) else { + throw "Matrix Data must start with a header row, followed by a row with '----' (three or more hyphens) for each column" + } + guard $0.reduce(true, { $1.count == columns.count && $0 ? true : false }) else { + throw "All rows must have the same number of columns for valid Matrix Data" + } + guard columns.first?.trimmingCharacters(in: .whitespaces).isEmpty == true else { + throw "The first column header of Matrix Data should be empty" + } + + return Requirement.Example.Statement.Data.matrix(OrderedDictionary(uniqueKeysWithValues: $0.dropFirst(2).map { row in + return (row[0].trimmingCharacters(in: .whitespaces), OrderedDictionary(uniqueKeysWithValues: Array(columns.dropFirst()).enumerated().map { + return ($0.element, Array(row.dropFirst())[$0.offset]) + })) + })) +} + +private let parseTableRow = Parser<[String]> { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "|" && trimmed.last == "|" else { + throw "Table rows must be contained inside pipe | characters" + } + let array = trimmed.trimmingCharacters(in: ["|"]).split(separator: "|").map({ $0.trimmingCharacters(in: .whitespaces) }) + guard !array.isEmpty else { + throw "Line is not a table row because it doesn't contain data in between | characters" + } + return array +} + +private let parseExamples: Parser<[Requirement.Example]> = Parser.oneOrMore(parseExampleSet.or(parseExample.map { [$0] }), until: .end.or(.not(parseExampleSet.or(parseExample.map { [$0] })))).map { Array($0.joined()) } diff --git a/Sources/RequirementsKit/Requirement.swift b/Sources/RequirementsKit/Requirement.swift new file mode 100644 index 0000000..8787b83 --- /dev/null +++ b/Sources/RequirementsKit/Requirement.swift @@ -0,0 +1,211 @@ +// +// Requirement.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import OrderedCollections + + +public struct File: Equatable { + public let url: URL + public let comments: [String]? + public let labels: [String]? + public let description: String? + public let syntax: Syntax + public let requirements: [Requirement] + public var name: String { + String(url.lastPathComponent.prefix(upTo: url.lastPathComponent.lastIndex(of: ".") ?? url.lastPathComponent.endIndex)) + } + + public init(url: URL, comments: [String]?, labels: [String]?, description: String?, syntax: Syntax, requirements: [Requirement]) { + self.url = url + self.comments = comments + self.labels = labels + self.description = description + self.syntax = syntax + self.requirements = requirements + } + + public static func parseFrom(url: URL) throws -> File { + let fileExtension = String(url.lastPathComponent.drop { $0 != "." }.dropFirst()) + switch fileExtension { + case "feature": return try parseGherkin(from: url) + case "requirements": return try parseReqsML(from: url) + default: throw RequirementsKitError(errorDescription: "Requirements files must have the extension .feature (for Gherkin) or .requirements (for ReqsML)") + } + } +} + +public extension File { + enum Syntax: Equatable { + case gherkin, reqsML + } +} + +public struct Requirement: Hashable { + public let comments: [String]? + public let identifier: String? + public let labels: [String]? + internal let explicitLabels: [String]? + public let description: String + public let examples: [Example] + + public init(comments: [String]? = nil, identifier: String? = nil, labels: [String]? = nil, description: String, examples: [Requirement.Example]) { + self.comments = comments + self.identifier = identifier + self.labels = labels + self.explicitLabels = labels + self.description = description + self.examples = examples + } + + internal init(comments: [String]? = nil, identifier: String? = nil, labels: [String]? = nil, explicitLabels: [String]? = nil, description: String, examples: [Requirement.Example]) { + self.comments = comments + self.identifier = identifier + self.labels = labels + self.explicitLabels = labels + self.description = description + self.examples = examples + } + + public static func ==(lhs: Requirement, rhs: Requirement) -> Bool { + return lhs.comments == rhs.comments + && lhs.identifier == rhs.identifier + && Set(arrayLiteral: lhs.labels) == Set(arrayLiteral: rhs.labels) + && lhs.description == rhs.description + && lhs.examples == rhs.examples + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(comments) + hasher.combine(identifier) + hasher.combine(labels) + hasher.combine(description) + hasher.combine(examples) + } +} + +extension Requirement { + + public struct Example: Hashable { + public let comments: [String]? + public let identifier: String? + public let labels: [String]? + internal let explicitLabels: [String]? + public let description: String? + public let statements: [Statement] + internal let exampleSet: _ExampleSet? + internal let specification: _ExampleSpecification? + + public init(comments: [String]? = nil, identifier: String? = nil, labels: [String]? = nil, description: String? = nil, statements: [Requirement.Example.Statement]) { + self.comments = comments + self.identifier = identifier + self.labels = labels + self.explicitLabels = labels + self.description = description + self.statements = statements + self.exampleSet = nil + self.specification = nil + } + + internal init(comments: [String]? = nil, identifier: String? = nil, labels: [String]? = nil, explicitLabels: [String]? = nil, description: String? = nil, statements: [Requirement.Example.Statement], exampleSet: _ExampleSet? = nil, specification: _ExampleSpecification? = nil) { + self.comments = comments + self.identifier = identifier + self.labels = labels + self.explicitLabels = explicitLabels + self.description = description + self.statements = statements + self.exampleSet = exampleSet + self.specification = specification + } + + public static func ==(lhs: Example, rhs: Example) -> Bool { + return lhs.comments == rhs.comments + && lhs.identifier == rhs.identifier + && Set(arrayLiteral: lhs.labels) == Set(arrayLiteral: rhs.labels) + && lhs.description == rhs.description + && lhs.statements == rhs.statements + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(comments) + hasher.combine(identifier) + hasher.combine(labels) + hasher.combine(description) + hasher.combine(statements) + } + } +} + +extension Requirement.Example { + + internal struct _ExampleSet: Hashable { + let comments: [String]? + let identifier: String? + let labels: [String]? + let description: String? + let statements: [Statement] + } + + internal struct _ExampleSpecification: Hashable { + let comments: [String]? + let identifier: String? + let description: String? + let labels: [String]? + let values: OrderedDictionary + } + + public enum StatementType: String, Hashable { + case `if`, when, expect + } + + public struct Statement: Hashable { + public let comments: [String]? + public let type: StatementType + public let description: String + public let data: Data? + + public init(comments: [String]? = nil, type: Requirement.Example.StatementType, description: String, data: Requirement.Example.Statement.Data? = nil) { + self.comments = comments + self.type = type + self.description = description + self.data = data + } + } +} + +extension Requirement.Example.Statement { + public enum Data: Hashable { + case text(String) + case keyValues(OrderedDictionary) + case list([String]) + case table([OrderedDictionary]) + case matrix(OrderedDictionary>) + } +} + + +struct RequirementsKitError: LocalizedError { + let errorDescription: String? +} diff --git a/Sources/RequirementsKit/RequirementsTestRunner.swift b/Sources/RequirementsKit/RequirementsTestRunner.swift new file mode 100644 index 0000000..931fcb5 --- /dev/null +++ b/Sources/RequirementsKit/RequirementsTestRunner.swift @@ -0,0 +1,147 @@ +// +// RequirementsTestRunner.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import XCTest + + +class RequirementsTestRunner: NSObject, XCTestObservation { + private static var current: RequirementsTestRunner? + public var timeout: TimeInterval = 180 + public var files: [File] + public var hasFailed = false + public var continueAfterFailure = false + public let labels: LabelExpression? + private let statementHandlers: [StatementHandler] + private var beforeEachExample: ((Requirement.Example) -> Void)? + private var timeoutDispatchWorkItem: DispatchWorkItem? + + init(file: File, statementHandlers: [StatementHandler], matching: LabelExpression? = nil) { + self.files = [file] + self.statementHandlers = statementHandlers + self.labels = matching + } + + init(files: [File], statementHandlers: [StatementHandler], matching: LabelExpression? = nil) { + self.files = files + self.statementHandlers = statementHandlers + self.labels = matching + } + + func testCase(_ testCase: XCTestCase, didFailWithDescription description: String, inFile filePath: String?, atLine lineNumber: Int) { + if !continueAfterFailure { + hasFailed = true + } + } + + func run(timeout: TimeInterval = 180, continueAfterFailure: Bool = false, beforeEachExample: (Requirement.Example) -> Void) { + guard Self.current == nil else { fatalError("Can't start running a RequirementsTestRunner when there is already one running") } + Self.current = self + + self.timeout = timeout + self.hasFailed = false + self.continueAfterFailure = true + + XCTestObservationCenter.shared.addTestObserver(self) + + files.forEach { + let file = $0 + XCTContext.runActivity(named: file.activity) { _ in + for requirement in file.requirements { + guard !self.hasFailed || continueAfterFailure else { break } + XCTContext.runActivity(named: requirement.activity(syntax: file.syntax)) { _ in + for example in requirement.examples { + guard !self.hasFailed || continueAfterFailure else { break } + if let labels { + if !labels.matches(example.labels) { + XCTContext.runActivity(named: "[SKIPPED] " + example.activity(syntax: file.syntax)) { _ in } + continue + } + } + XCTContext.runActivity(named: example.activity(syntax: file.syntax)) { _ in + for statement in example.statements { + guard !self.hasFailed || continueAfterFailure else { break } + XCTContext.runActivity(named: statement.activity(syntax: file.syntax)) { _ in + let matches = self.statementHandlers.filter { $0.type == statement.type && $0.getMatch(statement) != nil } + if matches.isEmpty { + XCTFail("No StatementHandler provided for the statement '\(statement.activity(syntax: file.syntax))'") + } else if matches.count > 1 { + XCTFail("Multiple matching StatementHandlers provided for the statement '\(statement.activity(syntax: file.syntax))'") + } else { + do { + let timeoutWorkItem = DispatchWorkItem { + XCTFail("Statement timed out after \(matches.first?.timeout ?? self.timeout) seconds") + } + self.timeoutDispatchWorkItem?.cancel() + self.timeoutDispatchWorkItem = timeoutWorkItem + DispatchQueue.global().asyncAfter(deadline: .now() + (matches.first?.timeout ?? self.timeout), execute: timeoutWorkItem) + try matches.first?.action(matches.first?.getMatch(statement)) + } catch { + XCTFail(error.localizedDescription) + } + } + } + } + } + } + } + } + } + } + } +} + +fileprivate extension File { + var activity: String { + syntax == .gherkin ? "Feature: \(description ?? name)" : "File: \(name)" + } +} + +fileprivate extension Requirement { + func activity(syntax: File.Syntax) -> String { + (syntax == .gherkin ? "Rule: " : "Requirement: ") + description + } +} + +fileprivate extension Requirement.Example { + func activity(syntax: File.Syntax) -> String { + "Example: \(description ?? "")" + } +} + +fileprivate extension Requirement.Example.Statement { + func activity(syntax: File.Syntax) -> String { + type.activity(syntax: syntax) + description + } +} + +fileprivate extension Requirement.Example.StatementType { + func activity(syntax: File.Syntax) -> String { + switch self { + case .if: return syntax == .gherkin ? "Given " : "If: " + case .when: return syntax == .gherkin ? "When " : "When: " + case .expect: return syntax == .gherkin ? "Then " : "Expect: " + } + } +} diff --git a/Sources/RequirementsKit/StatementHandler.swift b/Sources/RequirementsKit/StatementHandler.swift new file mode 100644 index 0000000..e95c5aa --- /dev/null +++ b/Sources/RequirementsKit/StatementHandler.swift @@ -0,0 +1,91 @@ +// +// StatementHandler.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + + +public struct StatementHandler { + + public struct Input { + public let statement: Requirement.Example.Statement + public let match: Match + } + + let type: Requirement.Example.StatementType + let timeout: TimeInterval? + let getMatch: (Requirement.Example.Statement) -> Any? + let action: (Any?) throws -> Void + + fileprivate init(statementType: Requirement.Example.StatementType, statement: Regex, timeout: TimeInterval?, handler: @escaping (Input) throws -> Void) { + type = statementType + getMatch = { exampleStatement in + exampleStatement.description.wholeMatch(of: statement).map { Input(statement: exampleStatement, match: $0.output) } + } + self.action = { try handler($0 as! Input) } + self.timeout = timeout + } + + fileprivate init(statementType: Requirement.Example.StatementType, statement: String, timeout: TimeInterval?, handler: @escaping (Input) throws -> Void) { + type = statementType + getMatch = { exampleStatement in + exampleStatement.description.wholeMatch(of: Regex(verbatim: statement)).map { Input(statement: exampleStatement, match: $0.output) } + } + self.action = { try handler($0 as! Input) } + self.timeout = timeout + } +} + +public extension StatementHandler { + static func `if`(_ statement: Regex, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .init(statementType: .if, statement: statement, timeout: timeout, handler: handler) + } + static func `if`(_ statement: String, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .init(statementType: .if, statement: statement, timeout: timeout, handler: handler) + } + static func given(_ statement: Regex, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .if(statement, timeout: timeout, handler: handler) + } + static func given(_ statement: String, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .if(statement, timeout: timeout, handler: handler) + } + static func when(_ statement: Regex, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .init(statementType: .when, statement: statement, timeout: timeout, handler: handler) + } + static func when(_ statement: String, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .init(statementType: .when, statement: statement, timeout: timeout, handler: handler) + } + static func expect(_ statement: Regex, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .init(statementType: .expect, statement: statement, timeout: timeout, handler: handler) + } + static func expect(_ statement: String, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .init(statementType: .expect, statement: statement, timeout: timeout, handler: handler) + } + static func then(_ statement: Regex, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .expect(statement, timeout: timeout, handler: handler) + } + static func then(_ statement: String, timeout: TimeInterval? = nil, handler: @escaping (Input) -> Void) -> StatementHandler { + .expect(statement, timeout: timeout, handler: handler) + } +} diff --git a/Sources/RequirementsKit/XCTestCase+Extensions.swift b/Sources/RequirementsKit/XCTestCase+Extensions.swift new file mode 100644 index 0000000..1c58ce3 --- /dev/null +++ b/Sources/RequirementsKit/XCTestCase+Extensions.swift @@ -0,0 +1,99 @@ +// +// XCTestCase+Extensions.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import XCTest + + +public extension XCTestCase { + + func testRequirements(from file: File, statementHandlers: [StatementHandler], matching: LabelExpression? = nil, continueAfterFailure: Bool = false, timeout: TimeInterval = 180, beforeEachExample: @escaping (Requirement.Example) -> Void) { + let runner = RequirementsTestRunner(file: file, statementHandlers: statementHandlers, matching: matching) + if Thread.isMainThread { + runner.run(timeout: timeout, continueAfterFailure: continueAfterFailure, beforeEachExample: beforeEachExample) + } else { + DispatchQueue.main.async { + runner.run(timeout: timeout, continueAfterFailure: continueAfterFailure, beforeEachExample: beforeEachExample) + } + } + } + + func testRequirements(from url: URL, statementHandlers: [StatementHandler], matching: LabelExpression? = nil, continueAfterFailure: Bool = false, timeout: TimeInterval = 180, beforeEachExample: @escaping (Requirement.Example) -> Void) throws { + let file = try File.parseFrom(url: url) + testRequirements(from: file, statementHandlers: statementHandlers, beforeEachExample: beforeEachExample) + } + + func testRequirements(from urls: [URL], statementHandlers: [StatementHandler], matching: LabelExpression? = nil, continueAfterFailure: Bool = false, timeout: TimeInterval = 180, beforeEachExample: @escaping (Requirement.Example) -> Void) throws { + let files = try urls.map { try File.parseFrom(url: $0) } + let runner = RequirementsTestRunner(files: files, statementHandlers: statementHandlers, matching: matching) + if Thread.isMainThread { + runner.run(timeout: timeout, continueAfterFailure: continueAfterFailure, beforeEachExample: beforeEachExample) + } else { + DispatchQueue.main.async { + runner.run(timeout: timeout, continueAfterFailure: continueAfterFailure, beforeEachExample: beforeEachExample) + } + } + } + + func testRequirements(in directory: String?, recursively: Bool = true, statementHandlers: [StatementHandler], matching: LabelExpression? = nil, continueAfterFailure: Bool = false, timeout: TimeInterval = 180, beforeEachExample: @escaping (Requirement.Example) -> Void) throws { + let bundle = Bundle(for: type(of: self)) + let subdirectoryPath = directory.map { "/\($0)" } ?? "" + let urls: [URL] + if !recursively { + let directoryContents = try FileManager.default.contentsOfDirectory(atPath: bundle.bundlePath.appending(subdirectoryPath)) + urls = directoryContents.compactMap { path in + if URL(fileURLWithPath: path).pathExtension == "feature" || URL(fileURLWithPath: path).pathExtension == "requirements" { + return bundle.bundleURL.appendingPathComponent(subdirectoryPath + "/" + path) + } + return nil + } + } else { + let enumerator = FileManager.default.enumerator(atPath: bundle.bundlePath.appending(subdirectoryPath)) + var recursiveURLs: [URL] = [] + while let path = enumerator?.nextObject() as? String { + if URL(fileURLWithPath: path).pathExtension == "feature" || URL(fileURLWithPath: path).pathExtension == "requirements" { + recursiveURLs.append(bundle.bundleURL.appendingPathComponent(subdirectoryPath + "/" + path)) + } + } + urls = recursiveURLs + } + try testRequirements(from: urls, statementHandlers: statementHandlers, beforeEachExample: beforeEachExample) + } +} + + +public func waitForAsync(timeout: TimeInterval = 10, closure: @escaping () async throws -> Void) rethrows { + let expectation = XCTestExpectation(description: "Waiting for async closure") + Task { + defer { + expectation.fulfill() + } + do { + try await closure() + } catch { + XCTFail(error.localizedDescription) + } + } + XCTWaiter().wait(for: [expectation], timeout: timeout) +} diff --git a/Tests/RequirementsKitTests/GherkinParsingTests.swift b/Tests/RequirementsKitTests/GherkinParsingTests.swift new file mode 100644 index 0000000..34072ee --- /dev/null +++ b/Tests/RequirementsKitTests/GherkinParsingTests.swift @@ -0,0 +1,105 @@ +// +// GherkinParsingTests.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import RequirementsKit +import XCTest + + +class GherkinParsingTests: XCTestCase { + + func testValidExample() throws { + let url = Bundle.module.url(forResource: "Valid", withExtension: "feature", subdirectory: "TestResources")! + + let expectedResult = File(url: url, + comments: ["Auth stuff"], + labels: ["featureLabel"], + description: "Auth and Notifications", + syntax: .gherkin, + requirements: [ + Requirement(comments: ["1.1"], identifier: nil, labels: ["labelOne", "labelTwo", "featureLabel"], description: "The user can log in with a valid username and password", examples: [ + .init(comments: ["1.1.1"], identifier: nil, labels: ["labelThree", "labelOne", "labelTwo", "featureLabel"], description: "Valid username and password", statements: [ + .init(comments: nil, type: .if, description: "the user is on the log in screen", data: nil), + .init(comments: nil, type: .if, description: "the user has entered a valid username and password", data: .keyValues([ + "username": "Snaffle", + "password": "guest" + ])), + .init(comments: nil, type: .when, description: "the user taps the submit button", data: nil), + .init(comments: nil, type: .expect, description: "the user should arrive on the home screen with expected elements visible", data: .list([ + "logOutButton", + "accountTab", + "settingsButton" + ])) + ]), + .init(comments: ["1.1.2"], identifier: nil, labels: ["labelFour", "labelOne", "labelTwo", "featureLabel"], description: "Valid username with invalid password", statements: [ + .init(comments: nil, type: .if, description: "the user is on the log in screen", data: nil), + .init(comments: ["Less than 8 characters is invalid"], type: .if, description: "the user has entered an INVALID username and password", data: .keyValues([ + "username": "Snaffle", + "password": "admin" + ])), + .init(comments: nil, type: .when, description: "the user taps the submit button", data: nil), + .init(comments: nil, type: .expect, description: "the user should still be on the login screen", data: nil), + .init(comments: nil, type: .expect, description: "an error should be displayed", data: .text("Error\nInvalid Username or password")) + ]) + ]), + Requirement(comments: ["1.2"], identifier: nil, labels: ["labelFive", "labelSix", "featureLabel"], description: "The user can log out", examples: [ + .init(comments: ["1.2.1"], identifier: nil, labels: ["labelFive", "labelSix", "featureLabel"], description: nil, statements: [ + .init(comments: nil, type: .if, description: "the user is on the home screen", data: nil), + .init(comments: nil, type: .when, description: "the user taps the log out button", data: nil), + .init(comments: nil, type: .expect, description: "the user should arrive on the login screen", data: nil), + .init(comments: nil, type: .expect, description: "the user should not be on the home screen", data: nil) + ]), + .init(comments: ["1.2.2", "Account screen not supported on Android yet"], identifier: nil, labels: ["ios", "labelFive", "labelSix", "featureLabel"], description: nil, statements: [ + .init(comments: nil, type: .if, description: "the user is on the account screen", data: nil), + .init(comments: nil, type: .when, description: "the user taps the log out button", data: nil), + .init(comments: nil, type: .expect, description: "the user should arrive on the login screen", data: nil), + .init(comments: nil, type: .expect, description: "the user should not be on the account screen", data: nil) + ]), + .init(comments: ["1.2.3"], identifier: nil, labels: ["labelFive", "labelSix", "featureLabel"], description: nil, statements: [ + .init(comments: nil, type: .if, description: "the user is on the settings screen", data: nil), + .init(comments: nil, type: .when, description: "the user taps the log out button", data: nil), + .init(comments: nil, type: .expect, description: "the user should arrive on the login screen", data: nil), + .init(comments: nil, type: .expect, description: "the user should not be on the settings screen", data: nil) + ]) + ]), + Requirement(comments: ["Notification Stuff", "1.3"], identifier: nil, labels: ["featureLabel"], description: "Welcome notifications display correctly", examples: [ + .init(comments: ["No premium subscription requirements defined yet", "1.3.1"], identifier: nil, labels: ["basic", "featureLabel"], description: "Basic subscription", statements: [ + .init(comments: nil, type: .if, description: "the notification payloads are", data: .matrix([ + "first": ["title": "Welcome", "body": "Thanks for subscribing, Pat!"], + "second": ["title": "Let's get started", "body": "Tap here to set up your preferences"] + ])), + .init(comments: nil, type: .when, description: "the notifications are received", data: .list(["first", "second"])), + .init(comments: nil, type: .expect, description: "two notification banners are on the lock screen", data: .table([ + ["title": "Welcome", "body": "Thanks for subscribing, Pat!"], + ["title": "Let's get started", "body": "Tap here to set up your preferences"], + ])), + .init(comments: nil, type: .expect, description: "the application icon has a badge", data: nil) + ]) + ]) + ]) + + let file = try parseGherkin(from: url) + XCTAssertEqual(file, expectedResult) + } +} diff --git a/Tests/RequirementsKitTests/ReqsMLExportTests.swift b/Tests/RequirementsKitTests/ReqsMLExportTests.swift new file mode 100644 index 0000000..e125c03 --- /dev/null +++ b/Tests/RequirementsKitTests/ReqsMLExportTests.swift @@ -0,0 +1,38 @@ +// +// ReqsMLExportTests.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import RequirementsKit +import XCTest + + +class ReqsMLExportTests: XCTestCase { + + func testValidExample() throws { + let url = Bundle.module.url(forResource: "Valid", withExtension: "requirements", subdirectory: "TestResources")! + let expected = try String(data: Data(contentsOf: url), encoding: .ascii) + let file = try File.parseFrom(url: url) + XCTAssertEqual(expected, file.asReqsML()) + } +} diff --git a/Tests/RequirementsKitTests/ReqsMLParsingTests.swift b/Tests/RequirementsKitTests/ReqsMLParsingTests.swift new file mode 100644 index 0000000..260369c --- /dev/null +++ b/Tests/RequirementsKitTests/ReqsMLParsingTests.swift @@ -0,0 +1,137 @@ +// +// ReqsMLParsingTests.swift +// RequirementsKit +// +// Copyright (c) 2022 - 2023 Daniel Hall (https://danielhall.io) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import RequirementsKit +import XCTest + + +class ReqsMLParsingTests: XCTestCase { + + func testValidExample() throws { + + let url = Bundle.module.url(forResource: "Valid", withExtension: "requirements", subdirectory: "TestResources")! + + let expectedResult = File(url: url, + comments: nil, + labels: nil, + description: nil, + syntax: .reqsML, + requirements: [ + Requirement(comments: ["Auth stuff"], identifier: "1.1", labels: ["labelOne", "labelTwo"], description: "The user can log in with a valid username and password", examples: [ + .init(comments: nil, identifier: "1.1.1", labels: ["labelThree", "labelOne", "labelTwo"], description: "Valid username and password", statements: [ + .init(comments: nil, type: .if, description: "the user is on the log in screen", data: nil), + .init(comments: nil, type: .if, description: "the user has entered a valid username and password", data: .keyValues([ + "username": "Snaffle", + "password": "guest" + ])), + .init(comments: nil, type: .when, description: "the user taps the submit button", data: nil), + .init(comments: nil, type: .expect, description: "the user should arrive on the home screen with expected elements visible", data: .list([ + "logOutButton", + "accountTab", + "settingsButton" + ])) + ]), + .init(comments: nil, identifier: "1.1.2", labels: ["labelFour", "labelOne", "labelTwo"], description: "Valid username with invalid password", statements: [ + .init(comments: nil, type: .if, description: "the user is on the log in screen", data: nil), + .init(comments: ["Less than 8 characters is invalid"], type: .if, description: "the user has entered an INVALID username and password", data: .keyValues([ + "username": "Snaffle", + "password": "admin" + ])), + .init(comments: nil, type: .when, description: "the user taps the submit button", data: nil), + .init(comments: nil, type: .expect, description: "the user should still be on the login screen", data: nil), + .init(comments: nil, type: .expect, description: "an error should be displayed", data: .text("Error\nInvalid Username or password")) + ]) + ]), + Requirement(comments: nil, identifier: "1.2", labels: ["labelFive", "labelSix"], description: "The user can log out", examples: [ + .init(comments: nil, identifier: "1.2.1", labels: ["labelFive", "labelSix"], description: "Log out from home screen", statements: [ + .init(comments: nil, type: .if, description: "the user is on the home screen", data: nil), + .init(comments: nil, type: .when, description: "the user taps the log out button", data: nil), + .init(comments: nil, type: .expect, description: "the user should arrive on the login screen", data: nil), + .init(comments: nil, type: .expect, description: "the user should not be on the home screen", data: nil) + ]), + .init(comments: ["Account screen not supported on Android yet"], identifier: "1.2.2", labels: ["ios", "labelFive", "labelSix"], description: "Log out from account screen", statements: [ + .init(comments: nil, type: .if, description: "the user is on the account screen", data: nil), + .init(comments: nil, type: .when, description: "the user taps the log out button", data: nil), + .init(comments: nil, type: .expect, description: "the user should arrive on the login screen", data: nil), + .init(comments: nil, type: .expect, description: "the user should not be on the account screen", data: nil) + ]), + .init(comments: nil, identifier: "1.2.3", labels: ["labelFive", "labelSix"], description: "Log out from settings screen", statements: [ + .init(comments: nil, type: .if, description: "the user is on the settings screen", data: nil), + .init(comments: nil, type: .when, description: "the user taps the log out button", data: nil), + .init(comments: nil, type: .expect, description: "the user should arrive on the login screen", data: nil), + .init(comments: nil, type: .expect, description: "the user should not be on the settings screen", data: nil) + ]) + ]), + Requirement(comments: ["Notification Stuff"], identifier: "1.3", labels: nil, description: "Welcome notifications display correctly", examples: [ + .init(comments: ["No premium subscription requirements defined yet"], identifier: "1.3.1", labels: ["basic"], description: "Basic subscription", statements: [ + .init(comments: nil, type: .if, description: "the notification payloads are", data: .matrix([ + "first": ["title": "Welcome", "body": "Thanks for subscribing, Pat!"], + "second": ["title": "Let's get started", "body": "Tap here to set up your preferences"] + ])), + .init(comments: nil, type: .when, description: "the notifications are received", data: .list(["first", "second"])), + .init(comments: nil, type: .expect, description: "two notification banners are on the lock screen", data: .table([ + ["title": "Welcome", "body": "Thanks for subscribing, Pat!"], + ["title": "Let's get started", "body": "Tap here to set up your preferences"], + ])), + .init(comments: nil, type: .expect, description: "the application icon has a badge", data: nil) + ]) + ]), + Requirement(identifier: "1.4", description: "If the user isn't logged in, certain buttons should be disabled", examples: [ + .init(description: "User can't proceed past splash screen", statements: [ + .init(type: .if, description: "The user is not logged in"), + .init(type: .if, description: "The user is on the splash screen"), + .init(type: .expect, description: "The proceed button is disabled") + ]), + .init(description: "If the user isn't logged in on the home screen, disable the settings button", statements: [ + .init(type: .if, description: "The user is not logged in"), + .init(type: .if, description: "The user is on the home screen"), + .init(type: .expect, description: "The settings button should be disabled") + + ]), + .init(description: "If the user isn't logged in on the home screen, disable the account button", statements: [ + .init(type: .if, description: "The user is not logged in"), + .init(type: .if, description: "The user is on the home screen"), + .init(type: .expect, description: "The account button should be disabled") + ]), + .init(description: "If the user isn't logged in on the notifications screen, disable the more button", statements: [ + .init(type: .if, description: "The user is not logged in"), + .init(type: .if, description: "The user is on the notifications screen"), + .init(type: .expect, description: "The more button should be disabled") + ]), + .init(description: "User is already on the Settings screen and logged out automatically", statements: [ + .init(type: .if, description: "The user is logged in"), + .init(type: .if, description: "The user is on the settings screen"), + .init(type: .when, description: "The app is backgrounded"), + .init(type: .when, description: "The user is logged out in the background"), + .init(type: .when, description: "The app is foregrounded"), + .init(type: .expect, description: "The user should be on the log in screen") + ]) + ]) + ]) + + let file = try File.parseFrom(url: url) + XCTAssertEqual(file, expectedResult) + } +} diff --git a/Tests/RequirementsKitTests/TestResources/Valid.feature b/Tests/RequirementsKitTests/TestResources/Valid.feature new file mode 100644 index 0000000..e4a1fea --- /dev/null +++ b/Tests/RequirementsKitTests/TestResources/Valid.feature @@ -0,0 +1,87 @@ +# Auth stuff +@featureLabel +Feature: Auth and Notifications + +# 1.1 +@labelOne, @labelTwo +Rule: The user can log in with a valid username and password + + # 1.1.1 + @labelThree + Example: Valid username and password + Given the user is on the log in screen + And the user has entered a valid username and password + | username: Snaffle | + | password: guest | + + When the user taps the submit button + + Then the user should arrive on the home screen with expected elements visible + | logOutButton | + | accountTab | + | settingsButton | + + # 1.1.2 + @labelFour + Scenario: Valid username with invalid password + Given the user is on the log in screen + + # Less than 8 characters is invalid + And the user has entered an INVALID username and password + | username: Snaffle | + | password: admin | + + When the user taps the submit button + + Then the user should still be on the login screen + And an error should be displayed + """ + Error + Invalid Username or password + """ + +# 1.2 +@labelFive, @labelSix +Rule: The user can log out + + Scenario Outline: Logging out + Given the user is on the screen + When the user taps the log out button + Then the user should arrive on the login screen + And the user should not be on the screen + + Examples: + | screen-name | + # 1.2.1 + | home | + # 1.2.2 + # Account screen not supported on Android yet + @ios + | account | + # 1.2.3 + | settings | + +# Notification Stuff +# 1.3 +Rule: Welcome notifications display correctly + + # No premium subscription requirements defined yet + # 1.3.1 + @basic + Example: Basic subscription + + Given the notification payloads are + | | title | body | + | first | Welcome | Thanks for subscribing, Pat! | + | second | Let's get started | Tap here to set up your preferences | + + When the notifications are received + | first | + | second | + + Then two notification banners are on the lock screen + | title | body | + | Welcome | Thanks for subscribing, Pat! | + | Let's get started | Tap here to set up your preferences | + + And the application icon has a badge diff --git a/Tests/RequirementsKitTests/TestResources/Valid.requirements b/Tests/RequirementsKitTests/TestResources/Valid.requirements new file mode 100644 index 0000000..469fe04 --- /dev/null +++ b/Tests/RequirementsKitTests/TestResources/Valid.requirements @@ -0,0 +1,129 @@ +// Auth stuff +#1.1 (labelOne, labelTwo) +Requirement: The user can log in with a valid username and password + + #1.1.1 (labelThree) + Example: Valid username and password + + If: + - the user is on the log in screen + - the user has entered a valid username and password + | username: Snaffle | + | password: guest | + + When: the user taps the submit button + + Expect: the user should arrive on the home screen with expected elements visible + | logOutButton | + | accountTab | + | settingsButton | + + #1.1.2 (labelFour) + Example: Valid username with invalid password + + If: + - the user is on the log in screen + + // Less than 8 characters is invalid + - the user has entered an INVALID username and password + | username: Snaffle | + | password: admin | + + When: the user taps the submit button + + Expect: + - the user should still be on the login screen + - an error should be displayed + ``` + Error + Invalid Username or password + ``` + +#1.2 (labelFive, labelSix) +Requirement: The user can log out + + If: the user is on the screen + + When: the user taps the log out button + + Expect: + - the user should arrive on the login screen + - the user should not be on the screen + + Examples: + + | | screen-name | + | ---------------------------- | ----------- | + #1.2.1 | Log out from home screen | home | + + // Account screen not supported on Android yet + #1.2.2 (ios) | Log out from account screen | account | + #1.2.3 | Log out from settings screen | settings | + +// Notification Stuff +#1.3 +Requirement: Welcome notifications display correctly + + // No premium subscription requirements defined yet + #1.3.1 (basic) + Example: Basic subscription + + If: the notification payloads are + | | title | body | + | ------ | ----------------- | ----------------------------------- | + | first | Welcome | Thanks for subscribing, Pat! | + | second | Let's get started | Tap here to set up your preferences | + + When: the notifications are received + | first | + | second | + + Expect: + - two notification banners are on the lock screen + | title | body | + | ----------------- | ----------------------------------- | + | Welcome | Thanks for subscribing, Pat! | + | Let's get started | Tap here to set up your preferences | + + - the application icon has a badge + +#1.4 +Requirement: If the user isn't logged in, certain buttons should be disabled + + Example: User can't proceed past splash screen + + If: + - The user is not logged in + - The user is on the splash screen + + Expect: The proceed button is disabled + + Example Set: If the user isn't logged in on the screen, disable the button + + If: + - The user is not logged in + - The user is on the screen + + Expect: The button should be disabled + + Examples: + + | screen-name | button-name | + | ------------- | ----------- | + | home | settings | + | home | account | + | notifications | more | + + Example: User is already on the Settings screen and logged out automatically + + If: + - The user is logged in + - The user is on the settings screen + + When: + - The app is backgrounded + - The user is logged out in the background + - The app is foregrounded + + Expect: The user should be on the log in screen +