Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate Modifier for Predicates #42

Merged
merged 5 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Supereg marked this conversation as resolved.
Show resolved Hide resolved
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
Loading