Skip to content

Commit

Permalink
Validate Modifier for Predicates (#42)
Browse files Browse the repository at this point in the history
# Validate Modifier for Predicates

## ♻️ Current situation & Problem
SpeziValidation currently provides the `validate(input:rules:)` modifier
to validate `String`-based inputs using SpeziValidation. This PR extends
this functionality to `Bool` expressions. The new `validate(_:message:)`
modifier can be used to validate all sorts of boolean conditions and
supply a validation failure reason with it.

## ⚙️ Release Notes 
* New `validate(_:message:)` modifier to validate bool expressions.


## 📚 Documentation
Documentation was updated to highlight the new modifier.


## ✅ Testing
_TBA_


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Aug 13, 2024
1 parent 4a8245e commit 427f4f3
Show file tree
Hide file tree
Showing 14 changed files with 216 additions and 59 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func swiftLintPlugin() -> [Target.PluginUsage] {

func swiftLintPackage() -> [PackageDescription.Package.Dependency] {
if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil {
[.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))]
[.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")]
} else {
[]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ var body: some View {
- ``ValidationRule``
- ``SwiftUI/View/validate(input:rules:)-5dac4``
- ``SwiftUI/View/validate(input:rules:)-9vks0``
- ``SwiftUI/View/validate(_:message:)``

### Managing Validation

Expand Down
55 changes: 55 additions & 0 deletions Sources/SpeziValidation/ValidationModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,59 @@ extension View {
public func validate(input value: String, rules: ValidationRule...) -> some View {
validate(input: value, rules: rules)
}


/// Validate a `Bool` expression.
///
/// This modifier can be used to validate a `Bool` predicate. If the expression doesn't evaluate to `true`, the `message`
/// is shown as a validation error.
///
/// Validation is managed through a ``ValidationEngine`` instance that is injected as an `Observable` into the
/// environment.
///
/// Below is an example that uses the `validate(_:message:)` modifier to validate the selection of a `Picker`.
/// - Note: The example uses the ``receiveValidation(in:)`` modifier to retrieve the validation results of the subview.
/// The ``ValidationResultsView`` is used to render the failure reason in red text below the picker.
///
/// ```swift
/// struct MyView: View {
/// enum Selection: String, CaseIterable, Hashable {
/// case none = "Nothing selected"
/// case accept = "Accept"
/// case deny = "Deny"
/// }
///
/// @State private var selection: Selection = .none
/// @ValidationState private var validationState
///
/// var body: some View {
/// VStack(alignment: .leading) {
/// Picker(selection: $selection) {
/// ForEach(Selection.allCases, id: \.rawValue) { selection in
/// Text(selection.rawValue)
/// .tag(selection)
/// }
/// } label: {
/// Text("Cookies")
/// }
/// ValidationResultsView(results: validationState.allDisplayedValidationResults)
/// }
/// .validate(selection != .none, message: "This field must be selected")
/// .receiveValidation(in: $validationState)
/// }
/// }
/// ```
///
/// - Important: You shouldn't place multiple validate modifiers into the same view hierarchy branch. This creates
/// visibility problems in both direction. Both displaying validation results in the child view and receiving
/// validation state from the parent view.
///
/// - Parameters:
/// - predicate: The predicate to validate.
/// - message: The validation message that is used as an failure reason, if the predicate evaluates to `false`.
/// - Returns: The modified view.
public func validate(_ predicate: Bool, message: LocalizedStringResource) -> some View {
let rule = ValidationRule(rule: { $0.isEmpty }, message: message)
return validate(input: predicate ? "" : "FALSE", rules: [rule])
}
}
6 changes: 6 additions & 0 deletions Tests/UITests/TestApp/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
},
"Condition present" : {

},
"Cookies" : {

},
"Credentials" : {

Expand Down Expand Up @@ -153,6 +156,9 @@
},
"Targets" : {

},
"This field must be selected." : {

},
"This is a default error description!" : {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@
// SPDX-License-Identifier: MIT
//

import SpeziPersonalInfo
import SwiftUI
import XCTestApp


enum SpeziValidationTests: String, TestAppTests {
case validation = "Validation"
case validationRules = "ValidationRules"
case validationPredicate = "Validation Picker"

func view(withNavigationPath path: Binding<NavigationPath>) -> some View {
switch self {
case .validation:
FocusedValidationTests()
case .validationRules:
DefaultValidationRules()
case .validationPredicate:
ValidationPredicateTests()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SpeziValidation
import SwiftUI


struct ValidationPredicateTests: View {
enum Selection: String, CaseIterable, Hashable {
case none = "Nothing selected"
case accept = "Accept"
case deny = "Deny"
}

@State private var selection: Selection = .none
@ValidationState private var validationState

var body: some View {
List {
VStack(alignment: .leading) {
Picker(selection: $selection) {
ForEach(Selection.allCases, id: \.rawValue) { selection in
Text(selection.rawValue)
.tag(selection)
}
} label: {
Text("Cookies")
}
ValidationResultsView(results: validationState.allDisplayedValidationResults)
}
.validate(selection != .none, message: "This field must be selected.")
.receiveValidation(in: $validationState)
}
}
}
4 changes: 2 additions & 2 deletions Tests/UITests/TestApp/ViewsTests/MarkdownViewTestView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ struct MarkdownViewTestView: View {
var body: some View {
MarkdownView(
asyncMarkdown: {
try? await Task.sleep(for: .seconds(5))
return Data("This is a *markdown* **example** taking 5 seconds to load.".utf8)
try? await Task.sleep(for: .seconds(2))
return Data("This is a *markdown* **example** taking 2 seconds to load.".utf8)
}
)
MarkdownView(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,18 @@ import XCTestExtensions


final class PersonalInfoViewsTests: XCTestCase {
@MainActor
override func setUpWithError() throws {
try super.setUpWithError()

continueAfterFailure = false

let app = XCUIApplication()
app.launch()

app.open(target: "SpeziPersonalInfo")
}

@MainActor
func testNameFields() throws {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziPersonalInfo")

XCTAssert(app.buttons["Name Fields"].waitForExistence(timeout: 2))
app.buttons["Name Fields"].tap()
Expand All @@ -43,6 +40,9 @@ final class PersonalInfoViewsTests: XCTestCase {
@MainActor
func testUserProfile() throws {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziPersonalInfo")

XCTAssert(app.buttons["User Profile"].waitForExistence(timeout: 2))
app.buttons["User Profile"].tap()
Expand Down
41 changes: 32 additions & 9 deletions Tests/UITests/TestAppUITests/SpeziValidation/ValidationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,18 @@ import XCTestExtensions


final class ValidationTests: XCTestCase {
@MainActor
override func setUpWithError() throws {
try super.setUpWithError()

continueAfterFailure = false

let app = XCUIApplication()
app.launch()

app.open(target: "SpeziValidation")
}

@MainActor
func testDefaultRules() {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziValidation")

XCTAssert(app.buttons["ValidationRules"].waitForExistence(timeout: 2))
app.buttons["ValidationRules"].tap()
Expand All @@ -34,6 +31,8 @@ final class ValidationTests: XCTestCase {
@MainActor
func testValidationWithFocus() throws {
let app = XCUIApplication()
app.launch()
app.open(target: "SpeziValidation")

let passwordMessage = "Your password must be at least 8 characters long."
let emptyMessage = "This field cannot be empty."
Expand All @@ -60,9 +59,7 @@ final class ValidationTests: XCTestCase {
XCTAssertTrue(app.staticTexts[emptyMessage].exists)

#if os(visionOS)
throw XCTSkip(
"This test is flakey on visionOS as the keyboard might be in front of the application and taps below will trigger keyboard buttons!"
)
throw XCTSkip("Test is flakey on visionOS as the keyboard might be in front of the application and taps below will trigger keyboard buttons!")
#endif

#if os(macOS)
Expand Down Expand Up @@ -100,4 +97,30 @@ final class ValidationTests: XCTestCase {
XCTAssertTrue(app.textFields["Hello World!"].exists)
XCTAssertTrue(app.textFields["Word"].exists)
}

@MainActor
func testValidationPredicate() throws {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziValidation")

XCTAssert(app.buttons["Validation Picker"].waitForExistence(timeout: 2))
app.buttons["Validation Picker"].tap()

XCTAssertTrue(app.buttons["Cookies, Nothing selected"].waitForExistence(timeout: 2.0))
app.buttons["Cookies, Nothing selected"].tap()

XCTAssertTrue(app.buttons["Accept"].waitForExistence(timeout: 2.0))
app.buttons["Accept"].tap()

XCTAssertTrue(app.buttons["Cookies, Accept"].waitForExistence(timeout: 2.0))
app.buttons["Cookies, Accept"].tap()

XCTAssertTrue(app.buttons["Nothing selected"].waitForExistence(timeout: 2.0))
app.buttons["Nothing selected"].tap()

XCTAssertTrue(app.buttons["Cookies, Nothing selected"].waitForExistence(timeout: 2.0))
XCTAssertTrue(app.staticTexts["This field must be selected."].waitForExistence(timeout: 2.0))
}
}
14 changes: 5 additions & 9 deletions Tests/UITests/TestAppUITests/SpeziViews/EnvironmentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,19 @@ import XCTestExtensions


final class EnvironmentTests: XCTestCase {
@MainActor
override func setUpWithError() throws {
try super.setUpWithError()

continueAfterFailure = false
}

@MainActor
func testDefaultErrorDescription() throws {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziViews")
}

@MainActor
func testDefaultErrorDescription() throws {
let app = XCUIApplication()

#if os(visionOS)
app.buttons["View State"].swipeUp()
#endif
Expand All @@ -36,15 +33,14 @@ final class EnvironmentTests: XCTestCase {

XCTAssert(app.staticTexts["View State: processing"].waitForExistence(timeout: 2))

sleep(12)

#if os(macOS)
let alerts = app.sheets
#else
let alerts = app.alerts
#endif
XCTAssert(alerts.staticTexts["This is a default error description!"].exists)
XCTAssert(alerts.staticTexts["This is a default error description!"].waitForExistence(timeout: 6.0))
XCTAssert(alerts.staticTexts["Failure Reason\n\nHelp Anchor\n\nRecovery Suggestion"].exists)
XCTAssertTrue(alerts.buttons["OK"].exists)
alerts.buttons["OK"].tap()

XCTAssert(app.staticTexts["View State: idle"].waitForExistence(timeout: 2))
Expand Down
21 changes: 15 additions & 6 deletions Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,18 @@ import XCTestExtensions


final class ModelTests: XCTestCase {
@MainActor
override func setUpWithError() throws {
try super.setUpWithError()

continueAfterFailure = false

let app = XCUIApplication()
app.launch()

app.open(target: "SpeziViews")
}

@MainActor
func testViewState() throws {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziViews")

XCTAssert(app.buttons["View State"].waitForExistence(timeout: 2))
app.buttons["View State"].tap()
Expand All @@ -50,6 +47,9 @@ final class ModelTests: XCTestCase {
@MainActor
func testOperationState() throws {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziViews")

XCTAssert(app.buttons["Operation State"].waitForExistence(timeout: 2))
app.buttons["Operation State"].tap()
Expand Down Expand Up @@ -81,6 +81,9 @@ final class ModelTests: XCTestCase {
@MainActor
func testViewStateMapper() throws {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziViews")

XCTAssert(app.buttons["View State Mapper"].waitForExistence(timeout: 2))
app.buttons["View State Mapper"].tap()
Expand Down Expand Up @@ -116,6 +119,9 @@ final class ModelTests: XCTestCase {
@MainActor
func testConditionalModifier() throws {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziViews")

XCTAssert(app.buttons["Conditional Modifier"].waitForExistence(timeout: 2))
app.buttons["Conditional Modifier"].tap()
Expand Down Expand Up @@ -149,6 +155,9 @@ final class ModelTests: XCTestCase {
@MainActor
func testDefaultErrorDescription() throws {
let app = XCUIApplication()
app.launch()

app.open(target: "SpeziViews")

XCTAssert(app.buttons["Default Error Only"].waitForExistence(timeout: 2))
app.buttons["Default Error Only"].tap()
Expand Down
Loading

0 comments on commit 427f4f3

Please sign in to comment.