Skip to content

Commit

Permalink
Improve docs + Write tests
Browse files Browse the repository at this point in the history
  • Loading branch information
philippzagar committed Nov 28, 2023
1 parent e23dc48 commit 135d7dd
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 40 deletions.
1 change: 1 addition & 0 deletions Sources/SpeziViews/SpeziViews.docc/SpeziViews.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ SPDX-License-Identifier: MIT
### Managing State

- ``ViewState``
- ``OperationState``
- ``SwiftUI/View/viewStateAlert(state:)``
- ``SwiftUI/View/map(state:to:)``
- ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-5xplv``
Expand Down
51 changes: 28 additions & 23 deletions Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,57 +7,62 @@
//


/// The ``OperationState`` protocol is used to map the state of an operation, for example a specific action or a more complex task being performed,
/// to a SpeziView ``ViewState``, describing the current state of a SwiftUI `View`.
/// The ``OperationState`` protocol provides a broad meta model for the current state of a specific action or task conducted within the Spezi ecosystem.
/// An ``OperationState`` is based upon a state of a typical finite automata which has a well-defined start and end state, such as an error state or a result state.
///
/// Combined with the ``SwiftUI/View/map(state:to:)`` view modifier, the ``OperationState`` provides an easy-to-use
/// bridging mechanism between the state of an operation and a SpeziViews ``ViewState``. One should highlight that this bridging is only done
/// in one direction, so the data flow from the ``OperationState`` towards the ``ViewState``, not the other way around, the reason being that
/// the state of a SwiftUI `View` doesn't influence the state of some arbitrary operation.
///
/// # Usage
/// An example conformance to the ``OperationState`` protocol is showcased in the code snippet below which presents the state of a download task.
///
/// ```swift
/// public enum DownloadState {
/// public enum DownloadState: OperationState {
/// case ready
/// case downloading(progress: Double)
/// case error(LocalizedError)
/// ...
/// }
/// ```
///
/// ### Representation as a ViewState
///
/// // Map the `DownloadState` to an `ViewState`
/// extension DownloadState: OperationState {
/// public var viewState: ViewState {
/// The ``OperationState`` encapsulates the core state of an application's behavior, which directly impacts the user interface and interaction.
/// To effectively manage the UI's state in the Spezi framework, the ``OperationState`` can be represented as a ``ViewState``.
/// This bridging mechanism allows Spezi to monitor and respond to changes in the view's state, for example via the ``SwiftUI/View/viewStateAlert(state:)`` view modifier.
///
/// - Note: It's important to note that this conversion is a lossy process, where a potentially intricate ``OperationState`` is
/// distilled into a simpler ``ViewState``.
/// One should highlight that this bridging is only done in one direction, so from the ``OperationState`` towards the ``ViewState``,
/// the reason being that the state of a SwiftUI `View` doesn't influence the state of some arbitrary operation.
///
/// ```swift
/// extension DownloadState {
/// public var representation: ViewState {
/// switch self {
/// case .ready:
/// .idle
/// case .downloading:
/// .processing
/// case .error(let error):
/// .error(error)
/// ...
/// // ...
/// }
/// }
/// }
///
/// struct StateTestView: View {
/// struct OperationStateTestView: View {
/// @State private var downloadState: DownloadState = .ready
/// @State private var viewState: ViewState = .idle
///
/// var body: some View {
/// EmptyView()
/// // Map the `DownloadState` to the `ViewState`
/// .map(state: downloadState, to: $viewState)
/// // Show alerts based on the `ViewState`
/// .viewStateAlert(state: $viewState)
/// Text("Operation State: \(String(describing: operationState))")
/// .map(state: downloadState, to: $viewState) // Map the `DownloadState` to the `ViewState`
/// .viewStateAlert(state: $viewState) // Show alerts based on the derived `ViewState`
/// .task {
/// // Changes to the `DownloadState`
/// ...
/// // Changes to the `DownloadState` which are automatically mapped to the `ViewState`
/// // ...
/// }
/// }
/// }
/// ```
public protocol OperationState {
/// Requires the implementation of mapping logic from the ``OperationState`` to the ``ViewState``.
var viewState: ViewState { get }
/// Defines the lossy abstraction logic from the possibly complex ``OperationState`` to the simple ``ViewState``.
var representation: ViewState { get }
}
28 changes: 13 additions & 15 deletions Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,38 +22,36 @@ private struct ViewStateMapper<T: OperationState>: ViewModifier {

func body(content: Content) -> some View {
content
.onChange(of: operationState.viewState) {
viewState = operationState.viewState
.onChange(of: operationState.representation) {
viewState = operationState.representation
}
}
}


extension View {
/// Maps a state conforming to the ``OperationState`` protocol to a SpeziViews ``ViewState``.
/// Continuously maps a state conforming to the ``OperationState`` protocol to a ``ViewState``.
/// Used to propagate the ``ViewState`` representation of the ``OperationState`` to a ``ViewState`` that lives within a SwiftUI `View`.
///
/// # Usage
/// ### Usage
/// ```swift
/// struct StateTestView: View {
/// @State private var downloadState: DownloadState = .ready // Conforms to the ``OperationState`` protocol
/// struct OperationStateTestView: View {
/// @State private var downloadState: DownloadState = .ready // `DownloadState` conforms to `OperationState`
/// @State private var viewState: ViewState = .idle
///
/// var body: some View {
/// EmptyView()
/// // Map the `DownloadState` to the `ViewState`
/// .map(state: downloadState, to: $viewState)
/// // Show alerts based on the `ViewState`
/// .viewStateAlert(state: $viewState)
/// Text("Operation State: \(String(describing: operationState))")
/// .map(state: downloadState, to: $viewState) // Map the `DownloadState` to the `ViewState`
/// .viewStateAlert(state: $viewState) // Show alerts based on the derived `ViewState`
/// .task {
/// // Changes to the `DownloadState`
/// ...
/// // Changes to the `DownloadState` which are automatically mapped to the `ViewState`
/// // ...
/// }
/// }
/// }
/// ```
///
/// # Note
/// The ``OperationState`` documentation contains a complete example using the ``SwiftUI/View/map(state:to:)`` view modifier.
/// - Note: The ``OperationState`` documentation contains a complete example using the ``SwiftUI/View/map(state:to:)`` view modifier.
///
/// - Parameters:
/// - operationState: The source ``OperationState`` that should be mapped to the SpeziViews ``ViewState``.
Expand Down
3 changes: 3 additions & 0 deletions Tests/UITests/TestApp/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
}
}
}
},
"Operation State: %@" : {

},
"Reset" : {

Expand Down
8 changes: 8 additions & 0 deletions Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ enum SpeziViewsTests: String, TestAppTests {
case lazyText = "Lazy Text"
case markdownView = "Markdown View"
case viewState = "View State"
case viewStateMapper = "View State Mapper"
case defaultErrorOnly = "Default Error Only"
case defaultErrorDescription = "Default Error Description"
case asyncButton = "Async Button"
Expand Down Expand Up @@ -82,6 +83,11 @@ enum SpeziViewsTests: String, TestAppTests {
private var defaultErrorOnly: some View {
ViewStateTestView(testError: .init(errorDescription: "Some error occurred!"))
}

@ViewBuilder
private var viewStateMapper: some View {
ViewStateMapperTestView()
}

@ViewBuilder
private var defaultErrorDescription: some View {
Expand All @@ -108,6 +114,8 @@ enum SpeziViewsTests: String, TestAppTests {
markdownView
case .viewState:
viewState
case .viewStateMapper:
viewStateMapper
case .defaultErrorOnly:
defaultErrorOnly
case .defaultErrorDescription:
Expand Down
76 changes: 76 additions & 0 deletions Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// 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


struct ViewStateMapperTestView: View {
enum OperationStateTest: OperationState {
case ready
case someOperationStep
case error(LocalizedError)


var representation: ViewState {
switch self {
case .ready:
.idle
case .someOperationStep:
.processing
case .error(let localizedError):
.error(localizedError)
}
}
}

struct TestError: LocalizedError {
var errorDescription: String?
var failureReason: String?
var helpAnchor: String?
var recoverySuggestion: String?
}

var testError = TestError(
errorDescription: nil,
failureReason: "Failure Reason",
helpAnchor: "Help Anchor",
recoverySuggestion: "Recovery Suggestion"
)

@State var operationState: OperationStateTest = .ready
@State var viewState: ViewState = .idle


var body: some View {
VStack {
Text("View State: \(String(describing: viewState))")
.padding(.bottom, 12)

Text("Operation State: \(String(describing: operationState))")
.accessibilityIdentifier("operationState")
}
.task {
operationState = .someOperationStep
try? await Task.sleep(for: .seconds(10))
operationState = .error(
AnyLocalizedError(
error: testError,
defaultErrorDescription: "Error Description"
)
)
}
.map(state: operationState, to: $viewState)
.viewStateAlert(state: $viewState)
}
}


#Preview {
ViewStateMapperTestView()
}
26 changes: 24 additions & 2 deletions Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,30 @@ final class ModelTests: XCTestCase {
alert.buttons["OK"].tap()

XCTAssert(app.staticTexts["View State: idle"].waitForExistence(timeout: 2))
app.staticTexts["View State: idle"].tap()
}

func testViewStateMapper() throws {
let app = XCUIApplication()

XCTAssert(app.collectionViews.buttons["View State Mapper"].waitForExistence(timeout: 2))
app.collectionViews.buttons["View State Mapper"].tap()

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

sleep(12)

let alert = app.alerts.firstMatch.scrollViews.otherElements
XCTAssert(alert.staticTexts["Error Description"].exists)
XCTAssert(alert.staticTexts["Failure Reason\n\nHelp Anchor\n\nRecovery Suggestion"].exists)
alert.buttons["OK"].tap()

sleep(2)

XCTAssert(app.staticTexts["View State: idle"].waitForExistence(timeout: 2))
// Operation state must stay in the old state as it is not influenced by the dismissal
// of the error alert (which moves the ViewState back to idle)
XCTAssert(app.staticTexts["operationState"].label.contains("Operation State: error"))
}

func testDefaultErrorDescription() throws {
Expand All @@ -57,6 +80,5 @@ final class ModelTests: XCTestCase {
alert.buttons["OK"].tap()

XCTAssert(app.staticTexts["View State: idle"].waitForExistence(timeout: 2))
app.staticTexts["View State: idle"].tap()
}
}
4 changes: 4 additions & 0 deletions Tests/UITests/UITests.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
2FA9486D29DE91130081C086 /* ViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA9486C29DE91130081C086 /* ViewsTests.swift */; };
2FA9486F29DE91A30081C086 /* SpeziViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2FA9486E29DE91A30081C086 /* SpeziViews */; };
2FB099B82A8AD25300B20952 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */; };
9731B58F2B167053007676C0 /* ViewStateMapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9731B58E2B167053007676C0 /* ViewStateMapperView.swift */; };
977CF55C2AD2B92C006D9B54 /* XCTestApp in Frameworks */ = {isa = PBXBuildFile; productRef = 977CF55B2AD2B92C006D9B54 /* XCTestApp */; };
A95B6E652AF4298500919504 /* SpeziPersonalInfo in Frameworks */ = {isa = PBXBuildFile; productRef = A95B6E642AF4298500919504 /* SpeziPersonalInfo */; };
A963ACAC2AF4683A00D745F2 /* SpeziValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963ACAB2AF4683A00D745F2 /* SpeziValidationTests.swift */; };
Expand Down Expand Up @@ -61,6 +62,7 @@
2FA9486C29DE91130081C086 /* ViewsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewsTests.swift; sourceTree = "<group>"; };
2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = "<group>"; };
2FB099B72A8AD25100B20952 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
9731B58E2B167053007676C0 /* ViewStateMapperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewStateMapperView.swift; sourceTree = "<group>"; };
A963ACAB2AF4683A00D745F2 /* SpeziValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeziValidationTests.swift; sourceTree = "<group>"; };
A963ACB12AF4709400D745F2 /* XCUIApplication+Targets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Targets.swift"; sourceTree = "<group>"; };
A97880962A4C4E6500150B2F /* ModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -156,6 +158,7 @@
children = (
2F2D338629DE52EA00081B1D /* SpeziViewsTests.swift */,
2FA9485E29DE90720081C086 /* ViewStateTestView.swift */,
9731B58E2B167053007676C0 /* ViewStateMapperView.swift */,
2FA9485F29DE90720081C086 /* MarkdownViewTestView.swift */,
2FA9486029DE90720081C086 /* CanvasTestView.swift */,
2FA9486329DE90720081C086 /* GeometryReaderTestView.swift */,
Expand Down Expand Up @@ -327,6 +330,7 @@
A9FBAE952AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift in Sources */,
2FA9486929DE90720081C086 /* NameFieldsTestView.swift in Sources */,
A99A65122AF57CA200E63582 /* FocusedValidationTests.swift in Sources */,
9731B58F2B167053007676C0 /* ViewStateMapperView.swift in Sources */,
A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */,
2FA9486A29DE90720081C086 /* GeometryReaderTestView.swift in Sources */,
2FA9486729DE90720081C086 /* CanvasTestView.swift in Sources */,
Expand Down

0 comments on commit 135d7dd

Please sign in to comment.