From 10ae4b1e7c7af5e36db4cdc0e1eea697f112ce72 Mon Sep 17 00:00:00 2001 From: Andi Date: Fri, 14 Jul 2023 01:35:30 +0200 Subject: [PATCH] Implement a generic AsyncButton with support for throwing closures (#8) Co-authored-by: Paul Schmiedmayer --- .swiftlint.yml | 6 - .../Environment/DefaultErrorDescription.swift | 3 +- .../ProcessingDebounceDuration.swift | 31 +++ Sources/SpeziViews/Model/ViewState.swift | 35 +-- .../ViewModifier/ProcessingOverlay.swift | 54 +++++ .../SpeziViews/Views/Button/AsyncButton.swift | 213 ++++++++++++++++++ Tests/UITests/TestApp.xctestplan | 9 + Tests/UITests/TestApp/SpeziViewsTests.swift | 10 +- .../ViewsTests/AsyncButtonTestView.swift | 64 ++++++ .../DefaultErrorDescriptionTestView.swift | 2 + .../TestApp/ViewsTests/HTMLViewTestView.swift | 2 + .../ViewsTests/ViewStateTestView.swift | 3 +- .../TestAppUITests/EnvironmentTests.swift | 2 +- Tests/UITests/TestAppUITests/ModelTests.swift | 4 +- Tests/UITests/TestAppUITests/ViewsTests.swift | 35 ++- .../UITests/UITests.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/xcschemes/TestApp.xcscheme | 104 +++++++++ 17 files changed, 549 insertions(+), 34 deletions(-) create mode 100644 Sources/SpeziViews/Environment/ProcessingDebounceDuration.swift create mode 100644 Sources/SpeziViews/ViewModifier/ProcessingOverlay.swift create mode 100644 Sources/SpeziViews/Views/Button/AsyncButton.swift create mode 100644 Tests/UITests/TestApp/ViewsTests/AsyncButtonTestView.swift create mode 100644 Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme diff --git a/.swiftlint.yml b/.swiftlint.yml index 793ccc0..e605657 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -10,8 +10,6 @@ only_rules: # All Images that provide context should have an accessibility label. Purely decorative images can be hidden from accessibility. - accessibility_label_for_image - # Attributes should be on their own lines in functions and types, but on the same line as variables and imports. - - attributes # Prefer using Array(seq) over seq.map { $0 } to convert a sequence into an Array. - array_init # Prefer the new block based KVO API with keypaths when using Swift 3.2 or later. @@ -141,8 +139,6 @@ only_rules: - implicitly_unwrapped_optional # Identifiers should use inclusive language that avoids discrimination against groups of people based on race, gender, or socioeconomic status - inclusive_language - # If defer is at the end of its parent scope, it will be executed right where it is anyway. - - inert_defer # Prefer using Set.isDisjoint(with:) over Set.intersection(_:).isEmpty. - is_disjoint # Discouraged explicit usage of the default separator. @@ -329,8 +325,6 @@ only_rules: - unowned_variable_capture # Catch statements should not declare error variables without type casting. - untyped_error_in_catch - # Unused reference in a capture list should be removed. - - unused_capture_list # Unused parameter in a closure should be replaced with _. - unused_closure_parameter # Unused control flow label should be removed. diff --git a/Sources/SpeziViews/Environment/DefaultErrorDescription.swift b/Sources/SpeziViews/Environment/DefaultErrorDescription.swift index 8b0b200..aa0d454 100644 --- a/Sources/SpeziViews/Environment/DefaultErrorDescription.swift +++ b/Sources/SpeziViews/Environment/DefaultErrorDescription.swift @@ -8,13 +8,14 @@ import SwiftUI + /// An `EnvironmentKey` that provides access to the default, localized error description. /// /// This might be helpful for views that rely on ``AnyLocalizedError``. Outer views can define a /// sensible default for a localized default error description in the case that a sub-view has to display /// an ``AnyLocalizedError`` for a generic error. public struct DefaultErrorDescription: EnvironmentKey { - public static var defaultValue: LocalizedStringResource? + public static let defaultValue: LocalizedStringResource? = nil } extension EnvironmentValues { diff --git a/Sources/SpeziViews/Environment/ProcessingDebounceDuration.swift b/Sources/SpeziViews/Environment/ProcessingDebounceDuration.swift new file mode 100644 index 0000000..0f07d03 --- /dev/null +++ b/Sources/SpeziViews/Environment/ProcessingDebounceDuration.swift @@ -0,0 +1,31 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// An `EnvironmentKey` that provides a generalized configuration for debounce durations for any processing-related operations. +/// +/// This might be helpful to provide extensive customization points without introducing clutter in the initializer of views. +/// The ``AsyncButton`` is one example where this `EnvironmentKey` is used. +public struct ProcessingDebounceDuration: EnvironmentKey { + public static let defaultValue: Duration = .milliseconds(150) +} + + +extension EnvironmentValues { + /// Refer to the documentation of ``ProcessingDebounceDuration``. + public var processingDebounceDuration: Duration { + get { + self[ProcessingDebounceDuration.self] + } + set { + self[ProcessingDebounceDuration.self] = newValue + } + } +} diff --git a/Sources/SpeziViews/Model/ViewState.swift b/Sources/SpeziViews/Model/ViewState.swift index 57b2f1c..73c433f 100644 --- a/Sources/SpeziViews/Model/ViewState.swift +++ b/Sources/SpeziViews/Model/ViewState.swift @@ -1,7 +1,7 @@ // // This source file is part of the Stanford Spezi open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // @@ -10,15 +10,30 @@ import Foundation /// The ``ViewState`` allows SwiftUI views to keep track of their state and possible communicate it to outside views, e.g., using `Binding`s. -public enum ViewState: Equatable { +public enum ViewState { /// The view is idle and displaying content. case idle /// The view is in a processing state, e.g. loading content. case processing /// The view is in an error state, e.g., loading the content failed. case error(LocalizedError) - - +} + +// MARK: - ViewState Extensions + +extension ViewState: Equatable { + public static func == (lhs: ViewState, rhs: ViewState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.processing, .processing), (.error, .error): + return true + default: + return false + } + } +} + +// MARK: - ViewState + Error +extension ViewState { /// The localized error title of the view if it is in an error state. An empty string if it is in an non-error state. public var errorTitle: String { switch self { @@ -37,7 +52,7 @@ public enum ViewState: Equatable { return String(localized: "VIEW_STATE_DEFAULT_ERROR_TITLE", bundle: .module) } } - + /// The localized error description of the view if it is in an error state. An empty string if it is in an non-error state. public var errorDescription: String { switch self { @@ -60,14 +75,4 @@ public enum ViewState: Equatable { return "" } } - - - public static func == (lhs: ViewState, rhs: ViewState) -> Bool { - switch (lhs, rhs) { - case (.idle, .idle), (.processing, .processing), (.error, .error): - return true - default: - return false - } - } } diff --git a/Sources/SpeziViews/ViewModifier/ProcessingOverlay.swift b/Sources/SpeziViews/ViewModifier/ProcessingOverlay.swift new file mode 100644 index 0000000..5ec8f80 --- /dev/null +++ b/Sources/SpeziViews/ViewModifier/ProcessingOverlay.swift @@ -0,0 +1,54 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +private struct ProcessingOverlay: ViewModifier { + fileprivate var isProcessing: Bool + @ViewBuilder fileprivate var overlay: () -> Overlay + + + func body(content: Content) -> some View { + content + .opacity(isProcessing ? 0.0 : 1.0) + .overlay { + if isProcessing { + overlay() + } + } + } +} + + +extension View { + /// Modifies the view to be replaced by an processing indicator based on the supplied condition. + /// - Parameters: + /// - state: The `ViewState` that is used to determine whether the view is replaced by the processing overlay. + /// We consider the view to be processing if the state is ``ViewState/processing``. + /// - overlay: A view which which the modified view is overlayed with when state is processing. + /// - Returns: A view that may render processing state. + public func processingOverlay( + isProcessing state: ViewState, + @ViewBuilder overlay: @escaping () -> Overlay = { ProgressView() } + ) -> some View { + processingOverlay(isProcessing: state == .processing, overlay: overlay) + } + + /// Modifies the view to be replaced by an processing indicator based on the supplied condition. + /// - Parameters: + /// - processing: A Boolean value that determines whether the view is replaced by the processing overlay. + /// - overlay: A view which which the modified view is overlayed with when state is processing. + /// - Returns: A view that may render processing state. + public func processingOverlay( + isProcessing processing: Bool, + @ViewBuilder overlay: @escaping () -> Overlay = { ProgressView() } + ) -> some View { + modifier(ProcessingOverlay(isProcessing: processing, overlay: overlay)) + } +} diff --git a/Sources/SpeziViews/Views/Button/AsyncButton.swift b/Sources/SpeziViews/Views/Button/AsyncButton.swift new file mode 100644 index 0000000..1aa5f58 --- /dev/null +++ b/Sources/SpeziViews/Views/Button/AsyncButton.swift @@ -0,0 +1,213 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +enum AsyncButtonState { + case idle + case disabled + case disabledAndProcessing +} + + +/// A SwiftUI `Button` that initiates an asynchronous (throwing) action. +@MainActor +public struct AsyncButton: View { + private let role: ButtonRole? + private let action: () async throws -> Void + private let label: () -> Label + + @Environment(\.defaultErrorDescription) + var defaultErrorDescription + @Environment(\.processingDebounceDuration) + var processingDebounceDuration + + @State private var actionTask: Task? + + @State private var buttonState: AsyncButtonState = .idle + @Binding private var viewState: ViewState + + // this covers the case where the encapsulating view sets the viewState binding to processing. + // This should also make the button to rendered as processing! + private var externallyProcessing: Bool { + buttonState == .idle && viewState == .processing + } + + public var body: some View { + Button(role: role, action: submitAction) { + label() + .processingOverlay(isProcessing: buttonState == .disabledAndProcessing || externallyProcessing) + } + .disabled(buttonState != .idle || externallyProcessing) + .onDisappear { + actionTask?.cancel() + } + } + + + /// Creates am async button that generates its label from a provided localized string. + /// - Parameters: + /// - title: The localized string used to generate the Label. + /// - role: An optional button role that is passed onto the underlying `Button`. + /// - action: An asynchronous button action. + public init( + _ title: LocalizedStringResource, + role: ButtonRole? = nil, + action: @escaping () async -> Void + ) where Label == Text { + self.init(role: role, action: action) { + Text(title) + } + } + + /// Creates an async button that displays a custom label. + /// - Parameters: + /// - role: An optional button role that is passed onto the underlying `Button`. + /// - action: An asynchronous button action. + /// - label: The Button label. + public init( + role: ButtonRole? = nil, + action: @escaping () async -> Void, + @ViewBuilder label: @escaping () -> Label + ) { + self.role = role + self.action = action + self.label = label + self._viewState = .constant(.idle) + } + + /// Creates an async throwing button that generates its label from a provided localized string. + /// - Parameters: + /// - title: The localized string used to generate the Label. + /// - role: An optional button role that is passed onto the underlying `Button`. + /// - state: A ``ViewState`` binding that it used to propagate any error caught in the button action. + /// It may also be used to externally control or observe the button's processing state. + /// - action: An asynchronous button action. + public init( // swiftlint:disable:this function_default_parameter_at_end + _ title: LocalizedStringResource, + role: ButtonRole? = nil, + state: Binding, + action: @escaping () async throws -> Void + ) where Label == Text { + self.init(role: role, state: state, action: action) { + Text(title) + } + } + + /// Creates an async button that displays a custom label. + /// - Parameters: + /// - role: An optional button role that is passed onto the underlying `Button`. + /// - state: A ``ViewState`` binding that it used to propagate any error caught in the button action. + /// It may also be used to externally control or observe the button's processing state. + /// - action: An asynchronous button action. + /// - label: The Button label. + public init( // swiftlint:disable:this function_default_parameter_at_end + role: ButtonRole? = nil, + state: Binding, + action: @escaping () async throws -> Void, + @ViewBuilder label: @escaping () -> Label + ) { + self.role = role + self._viewState = state + self.action = action + self.label = label + } + + + private func submitAction() { + guard viewState != .processing else { + return + } + + buttonState = .disabled + + withAnimation(.easeOut(duration: 0.2)) { + viewState = .processing + } + + actionTask = Task { + do { + let debounce = Task { + try await debounceProcessingIndicator() + } + + try await action() + debounce.cancel() + + // the button action might set the state back to idle to prevent this animation + if viewState != .idle { + withAnimation(.easeIn(duration: 0.2)) { + viewState = .idle + } + } + } catch { + viewState = .error(AnyLocalizedError( + error: error, + defaultErrorDescription: defaultErrorDescription + )) + } + + buttonState = .idle + actionTask = nil + } + } + + private func debounceProcessingIndicator() async throws { + try await Task.sleep(for: processingDebounceDuration) + + // this is actually important to catch cases where the action runs a tiny bit faster than the debounce timer + guard !Task.isCancelled else { + return + } + + withAnimation(.easeOut(duration: 0.2)) { + buttonState = .disabledAndProcessing + } + } +} + + +#if DEBUG +struct AsyncThrowingButton_Previews: PreviewProvider { + struct PreviewButton: View { + var title: LocalizedStringResource = "Test Button" + var role: ButtonRole? + var duration: Duration = .seconds(1) + var action: () async throws -> Void = {} + + @State var state: ViewState = .idle + + var body: some View { + AsyncButton(title, role: role, state: $state) { + try await Task.sleep(for: duration) + try await action() + } + .viewStateAlert(state: $state) + } + } + + static var previews: some View { + Group { + PreviewButton() + PreviewButton(title: "Test Button with short action", duration: .milliseconds(100)) + /*AsyncThrowingButton(state: $state, action: { print("button pressed") }) { + Text("Test Button!") + }*/ + PreviewButton(title: "Test Button with Error") { + throw CancellationError() + } + + PreviewButton(title: "Processing only Button", state: .processing) + + PreviewButton(title: "Destructive Button", role: .destructive) + } + .buttonStyle(.borderedProminent) + } +} +#endif diff --git a/Tests/UITests/TestApp.xctestplan b/Tests/UITests/TestApp.xctestplan index 3f6fca6..6229a4f 100644 --- a/Tests/UITests/TestApp.xctestplan +++ b/Tests/UITests/TestApp.xctestplan @@ -9,6 +9,15 @@ } ], "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:..\/..", + "identifier" : "SpeziViews", + "name" : "SpeziViews" + } + ] + }, "targetForVariableExpansion" : { "containerPath" : "container:UITests.xcodeproj", "identifier" : "2F6D139128F5F384007C25D6", diff --git a/Tests/UITests/TestApp/SpeziViewsTests.swift b/Tests/UITests/TestApp/SpeziViewsTests.swift index 384fb75..04e8c6b 100644 --- a/Tests/UITests/TestApp/SpeziViewsTests.swift +++ b/Tests/UITests/TestApp/SpeziViewsTests.swift @@ -23,6 +23,7 @@ enum SpeziViewsTests: String, TestAppTests { case viewState = "View State" case defaultErrorOnly = "Default Error Only" case defaultErrorDescription = "Default Error Description" + case asyncButton = "Async Button" @ViewBuilder @@ -44,7 +45,7 @@ enum SpeziViewsTests: String, TestAppTests { UserProfileView( name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), imageLoader: { - try? await Task.sleep(for: .seconds(1)) + try? await Task.sleep(for: .seconds(3)) return Image(systemName: "person.crop.artframe") } ) @@ -117,6 +118,11 @@ enum SpeziViewsTests: String, TestAppTests { private var defaultErrorDescription: some View { DefaultErrorDescriptionTestView() } + + @ViewBuilder + private var asyncButton: some View { + AsyncButtonTestView() + } // swiftlint:disable:next cyclomatic_complexity @@ -144,6 +150,8 @@ enum SpeziViewsTests: String, TestAppTests { defaultErrorOnly case .defaultErrorDescription: defaultErrorDescription + case .asyncButton: + asyncButton } } } diff --git a/Tests/UITests/TestApp/ViewsTests/AsyncButtonTestView.swift b/Tests/UITests/TestApp/ViewsTests/AsyncButtonTestView.swift new file mode 100644 index 0000000..a8e81cc --- /dev/null +++ b/Tests/UITests/TestApp/ViewsTests/AsyncButtonTestView.swift @@ -0,0 +1,64 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +enum CustomError: Error, LocalizedError { + case error + + var errorDescription: String? { + "Custom Error" + } + + var failureReason: String? { + "Error was thrown!" + } +} + + +struct AsyncButtonTestView: View { + @State private var showCompleted = false + @State private var viewState: ViewState = .idle + + var body: some View { + List { + if showCompleted { + Section { + Text("Action executed") + Button("Reset") { + showCompleted = false + } + } + } + Group { + AsyncButton("Hello World") { + try? await Task.sleep(for: .milliseconds(500)) + showCompleted = true + } + + AsyncButton("Hello Throwing World", role: .destructive, state: $viewState) { + try? await Task.sleep(for: .milliseconds(500)) + throw CustomError.error + } + } + .disabled(showCompleted) + .viewStateAlert(state: $viewState) + } + } +} + + +#if DEBUG +struct AsyncButtonTestView_Previews: PreviewProvider { + static var previews: some View { + AsyncButtonTestView() + } +} +#endif diff --git a/Tests/UITests/TestApp/ViewsTests/DefaultErrorDescriptionTestView.swift b/Tests/UITests/TestApp/ViewsTests/DefaultErrorDescriptionTestView.swift index 48c1543..8dc1ef0 100644 --- a/Tests/UITests/TestApp/ViewsTests/DefaultErrorDescriptionTestView.swift +++ b/Tests/UITests/TestApp/ViewsTests/DefaultErrorDescriptionTestView.swift @@ -15,8 +15,10 @@ struct DefaultErrorDescriptionTestView: View { } } +#if DEBUG struct DefaultErrorDescriptionTestView_Previews: PreviewProvider { static var previews: some View { DefaultErrorDescriptionTestView() } } +#endif diff --git a/Tests/UITests/TestApp/ViewsTests/HTMLViewTestView.swift b/Tests/UITests/TestApp/ViewsTests/HTMLViewTestView.swift index 55c6aee..2b0bd57 100644 --- a/Tests/UITests/TestApp/ViewsTests/HTMLViewTestView.swift +++ b/Tests/UITests/TestApp/ViewsTests/HTMLViewTestView.swift @@ -26,8 +26,10 @@ struct HTMLViewTestView: View { } } +#if DEBUG struct HTMLViewTestView_Previews: PreviewProvider { static var previews: some View { HTMLViewTestView() } } +#endif diff --git a/Tests/UITests/TestApp/ViewsTests/ViewStateTestView.swift b/Tests/UITests/TestApp/ViewsTests/ViewStateTestView.swift index d316347..d6415ed 100644 --- a/Tests/UITests/TestApp/ViewsTests/ViewStateTestView.swift +++ b/Tests/UITests/TestApp/ViewsTests/ViewStateTestView.swift @@ -26,7 +26,8 @@ struct ViewStateTestView: View { ) @State var viewState: ViewState = .idle - @Environment(\.defaultErrorDescription) var defaultErrorDescription + @Environment(\.defaultErrorDescription) + var defaultErrorDescription var body: some View { Text("View State: \(String(describing: viewState))") diff --git a/Tests/UITests/TestAppUITests/EnvironmentTests.swift b/Tests/UITests/TestAppUITests/EnvironmentTests.swift index 0eafe98..3e9b194 100644 --- a/Tests/UITests/TestAppUITests/EnvironmentTests.swift +++ b/Tests/UITests/TestAppUITests/EnvironmentTests.swift @@ -17,7 +17,7 @@ final class EnvironmentTests: XCTestCase { app.collectionViews.buttons["Default Error Description"].tap() - XCTAssert(app.staticTexts["View State: processing"].exists) + XCTAssert(app.staticTexts["View State: processing"].waitForExistence(timeout: 1)) sleep(6) diff --git a/Tests/UITests/TestAppUITests/ModelTests.swift b/Tests/UITests/TestAppUITests/ModelTests.swift index bba9525..0a86727 100644 --- a/Tests/UITests/TestAppUITests/ModelTests.swift +++ b/Tests/UITests/TestAppUITests/ModelTests.swift @@ -17,7 +17,7 @@ final class ModelTests: XCTestCase { app.collectionViews.buttons["View State"].tap() - XCTAssert(app.staticTexts["View State: processing"].exists) + XCTAssert(app.staticTexts["View State: processing"].waitForExistence(timeout: 1)) sleep(6) @@ -35,7 +35,7 @@ final class ModelTests: XCTestCase { app.collectionViews.buttons["Default Error Only"].tap() - XCTAssert(app.staticTexts["View State: processing"].exists) + XCTAssert(app.staticTexts["View State: processing"].waitForExistence(timeout: 1)) sleep(6) diff --git a/Tests/UITests/TestAppUITests/ViewsTests.swift b/Tests/UITests/TestAppUITests/ViewsTests.swift index 9a01e29..bf0381a 100644 --- a/Tests/UITests/TestAppUITests/ViewsTests.swift +++ b/Tests/UITests/TestAppUITests/ViewsTests.swift @@ -47,7 +47,7 @@ final class ViewsTests: XCTestCase { app.collectionViews.buttons["Name Fields"].tap() - XCTAssert(app.staticTexts["First Title"].exists) + XCTAssert(app.staticTexts["First Title"].waitForExistence(timeout: 1)) XCTAssert(app.staticTexts["Second Title"].exists) XCTAssert(app.staticTexts["First Name"].exists) XCTAssert(app.staticTexts["Last Name"].exists) @@ -68,10 +68,10 @@ final class ViewsTests: XCTestCase { app.collectionViews.buttons["User Profile"].tap() - XCTAssertTrue(app.staticTexts["PS"].exists) + XCTAssertTrue(app.staticTexts["PS"].waitForExistence(timeout: 1)) XCTAssertTrue(app.staticTexts["LS"].exists) - XCTAssertTrue(app.images["person.crop.artframe"].waitForExistence(timeout: 1.0)) + XCTAssertTrue(app.images["person.crop.artframe"].waitForExistence(timeout: 3.5)) } func testGeometryReader() throws { @@ -89,6 +89,8 @@ final class ViewsTests: XCTestCase { app.launch() app.collectionViews.buttons["Label"].tap() + + sleep(2) // The string value needs to be searched for in the UI. // swiftlint:disable:next line_length @@ -102,7 +104,7 @@ final class ViewsTests: XCTestCase { app.collectionViews.buttons["Lazy Text"].tap() - XCTAssert(app.staticTexts["This is a long text ..."].exists) + XCTAssert(app.staticTexts["This is a long text ..."].waitForExistence(timeout: 1)) XCTAssert(app.staticTexts["And some more lines ..."].exists) XCTAssert(app.staticTexts["And a third line ..."].exists) } @@ -113,8 +115,8 @@ final class ViewsTests: XCTestCase { app.collectionViews.buttons["Markdown View"].tap() - XCTAssert(app.staticTexts["This is a markdown example."].exists) - + XCTAssert(app.staticTexts["This is a markdown example."].waitForExistence(timeout: 1)) + sleep(6) XCTAssert(app.staticTexts["This is a markdown example taking 5 seconds to load."].exists) @@ -129,4 +131,25 @@ final class ViewsTests: XCTestCase { XCTAssert(app.webViews.staticTexts["This is an HTML example."].waitForExistence(timeout: 15)) XCTAssert(app.staticTexts["This is an HTML example taking 5 seconds to load."].waitForExistence(timeout: 10)) } + + func testAsyncButtonView() throws { + let app = XCUIApplication() + app.launch() + + app.collectionViews.buttons["Async Button"].tap() + + XCTAssert(app.collectionViews.buttons["Hello World"].waitForExistence(timeout: 1)) + app.collectionViews.buttons["Hello World"].tap() + + XCTAssert(app.collectionViews.staticTexts["Action executed"].waitForExistence(timeout: 2)) + app.collectionViews.buttons["Reset"].tap() + + XCTAssert(app.collectionViews.buttons["Hello Throwing World"].exists) + app.collectionViews.buttons["Hello Throwing World"].tap() + + let alert = app.alerts.firstMatch.scrollViews.otherElements + XCTAssert(alert.staticTexts["Custom Error"].waitForExistence(timeout: 1)) + XCTAssert(alert.staticTexts["Error was thrown!"].waitForExistence(timeout: 1)) + alert.buttons["OK"].tap() + } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 370c316..aef572b 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ A97880972A4C4E6500150B2F /* ModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97880962A4C4E6500150B2F /* ModelTests.swift */; }; A97880992A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97880982A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift */; }; A978809B2A4C52F100150B2F /* EnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A978809A2A4C52F100150B2F /* EnvironmentTests.swift */; }; + A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998A94E2A609A9E0030624D /* AsyncButtonTestView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -53,6 +54,7 @@ A97880962A4C4E6500150B2F /* ModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTests.swift; sourceTree = ""; }; A97880982A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultErrorDescriptionTestView.swift; sourceTree = ""; }; A978809A2A4C52F100150B2F /* EnvironmentTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentTests.swift; sourceTree = ""; }; + A998A94E2A609A9E0030624D /* AsyncButtonTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncButtonTestView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -135,6 +137,7 @@ 2FA9486329DE90720081C086 /* GeometryReaderTestView.swift */, 2FA9486429DE90720081C086 /* HTMLViewTestView.swift */, A97880982A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift */, + A998A94E2A609A9E0030624D /* AsyncButtonTestView.swift */, ); path = ViewsTests; sourceTree = ""; @@ -252,6 +255,7 @@ 2FA9486B29DE90720081C086 /* HTMLViewTestView.swift in Sources */, 2FA9486529DE90720081C086 /* ViewStateTestView.swift in Sources */, 2FA9486929DE90720081C086 /* NameFieldsTestView.swift in Sources */, + A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */, 2FA9486A29DE90720081C086 /* GeometryReaderTestView.swift in Sources */, 2FA9486729DE90720081C086 /* CanvasTestView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, @@ -652,7 +656,7 @@ repositoryURL = "https://github.com/StanfordSpezi/XCTestExtensions.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.4.5; + minimumVersion = 0.4.6; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme new file mode 100644 index 0000000..a189d1b --- /dev/null +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +