diff --git a/.github/.jazzy.yaml b/.github/.jazzy.yaml index bfc48f954..3af299f1e 100644 --- a/.github/.jazzy.yaml +++ b/.github/.jazzy.yaml @@ -6,5 +6,5 @@ readme: README.md module: SwiftCurrent author_url: https://github.com/wwt/SwiftCurrent github_url: https://github.com/wwt/SwiftCurrent -sourcekitten_sourcefile: swiftcurrent-docs.json,swiftcurrentuikit-docs.json +sourcekitten_sourcefile: swiftcurrent-docs.json,swiftcurrentuikit-docs.json,swiftcurrent-swiftui-docs.json author: WWT and Tyler Thompson \ No newline at end of file diff --git a/.github/document.sh b/.github/document.sh index d29cca157..124fb5d02 100755 --- a/.github/document.sh +++ b/.github/document.sh @@ -1,7 +1,9 @@ SDK_PATH=`xcrun --sdk iphonesimulator --show-sdk-path` sourcekitten doc --spm --module-name SwiftCurrent -- -Xswiftc "-sdk" -Xswiftc "$SDK_PATH" -Xswiftc "-target" -Xswiftc "x86_64-apple-ios14.0-simulator" > swiftcurrent-docs.json sourcekitten doc --module-name SwiftCurrent_UIKit -- -workspace ./SwiftCurrent.xcworkspace -scheme SwiftCurrent_UIKit -destination "platform=iOS Simulator,name=iPhone 12" > swiftcurrentuikit-docs.json -jazzy --config .github/.jazzy.yaml --podspec SwiftCurrent.podspec --sourcekitten-sourcefile swiftcurrent-docs.json,swiftcurrentuikit-docs.json +sourcekitten doc --spm --module-name SwiftCurrent_SwiftUI -- -Xswiftc "-sdk" -Xswiftc "$SDK_PATH" -Xswiftc "-target" -Xswiftc "x86_64-apple-ios14.0-simulator" > swiftcurrent-swiftui-docs.json +jazzy --config .github/.jazzy.yaml --podspec SwiftCurrent.podspec --sourcekitten-sourcefile swiftcurrent-docs.json,swiftcurrentuikit-docs.json,swiftcurrent-swiftui-docs.json rm swiftcurrent-docs.json rm swiftcurrentuikit-docs.json +rm swiftcurrent-swiftui-docs.json open docs/index.html \ No newline at end of file diff --git a/Package.swift b/Package.swift index 382611b7f..613625627 100644 --- a/Package.swift +++ b/Package.swift @@ -13,12 +13,16 @@ let package = Package( .library( name: "SwiftCurrent_UIKit", targets: ["SwiftCurrent_UIKit"]), + .library( + name: "BETA_SwiftCurrent_SwiftUI", + targets: ["SwiftCurrent_SwiftUI"]) ], dependencies: [ .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting.git", from: Version("2.0.0-beta.1")), .package(url: "https://github.com/mattgallagher/CwlCatchException.git", from: Version("2.0.0-beta.1")), .package(url: "https://github.com/apple/swift-algorithms", .upToNextMajor(from: "0.0.1")), .package(url: "https://github.com/sindresorhus/ExceptionCatcher", from: "2.0.0"), + .package(url: "https://github.com/nalexn/ViewInspector.git", from: "0.8.1") ], targets: [ .target( @@ -27,6 +31,9 @@ let package = Package( .target( name: "SwiftCurrent_UIKit", dependencies: ["SwiftCurrent"]), + .target( + name: "SwiftCurrent_SwiftUI", + dependencies: ["SwiftCurrent"]), .testTarget( name: "SwiftCurrentTests", dependencies: [ @@ -37,5 +44,15 @@ let package = Package( .product(name: "Algorithms", package: "swift-algorithms") ], exclude: ["Info.plist", "SwiftCurrent.xctestplan"]), + .testTarget( + name: "SwiftCurrent-SwiftUITests", + dependencies: [ + "SwiftCurrent", + "SwiftCurrent_SwiftUI", + "CwlPreconditionTesting", + "CwlCatchException", + "ViewInspector" + ], + path: "Tests/SwiftCurrent_SwiftUITests"), ] ) diff --git a/Sources/SwiftCurrent/Models/FlowRepresentableMetadata.swift b/Sources/SwiftCurrent/Models/FlowRepresentableMetadata.swift index 81ba383fd..549395ac2 100644 --- a/Sources/SwiftCurrent/Models/FlowRepresentableMetadata.swift +++ b/Sources/SwiftCurrent/Models/FlowRepresentableMetadata.swift @@ -29,14 +29,31 @@ public class FlowRepresentableMetadata { - Parameter launchStyle: the style to use when launching the `FlowRepresentable`. - Parameter flowPersistence: a closure passing arguments to the caller and returning the preferred `FlowPersistence`. */ - public init(_ flowRepresentableType: FR.Type, - launchStyle: LaunchStyle = .default, - flowPersistence:@escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) { - flowRepresentableFactory = { args in + public convenience init(_ flowRepresentableType: FR.Type, + launchStyle: LaunchStyle = .default, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) { + self.init(flowRepresentableType, + launchStyle: launchStyle, + flowPersistence: flowPersistence) { args in AnyFlowRepresentable(FR.self, args: args) } - self.flowPersistence = flowPersistence + } + + /** + Creates an instance that holds onto metadata associated with the `FlowRepresentable`. + + - Parameter flowRepresentableType: specific type of the associated `FlowRepresentable`. + - Parameter launchStyle: the style to use when launching the `FlowRepresentable`. + - Parameter flowPersistence: a closure passing arguments to the caller and returning the preferred `FlowPersistence`. + - Parameter flowRepresentableFactory: a closure used to generate an `AnyFlowRepresentable` from the `FlowRepresentable` type. + */ + public init(_ flowRepresentableType: FR.Type, + launchStyle: LaunchStyle = .default, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence, + flowRepresentableFactory: @escaping (AnyWorkflow.PassedArgs) -> AnyFlowRepresentable) { self.launchStyle = launchStyle + self.flowPersistence = flowPersistence + self.flowRepresentableFactory = flowRepresentableFactory } func setPersistence(_ args: AnyWorkflow.PassedArgs) -> FlowPersistence { diff --git a/Sources/SwiftCurrent/Protocols/FlowRepresentable.swift b/Sources/SwiftCurrent/Protocols/FlowRepresentable.swift index 0ee94a277..5557da3ff 100644 --- a/Sources/SwiftCurrent/Protocols/FlowRepresentable.swift +++ b/Sources/SwiftCurrent/Protocols/FlowRepresentable.swift @@ -147,6 +147,13 @@ extension FlowRepresentable where WorkflowOutput == Never { } } +extension FlowRepresentable where WorkflowOutput == AnyWorkflow.PassedArgs { + /// Moves forward while passing arguments forward in the `Workflow`; if at the end, calls the `onFinish` closure used when launching the workflow. + public func proceedInWorkflow(_ args: WorkflowOutput) { + _workflowPointer?.proceedInWorkflowStorage?(args) + } +} + extension FlowRepresentable { /// Moves forward while passing arguments forward in the `Workflow`; if at the end, calls the `onFinish` closure used when launching the workflow. public func proceedInWorkflow(_ args: WorkflowOutput) { diff --git a/Sources/SwiftCurrent/TestOnly/TestOnly.swift b/Sources/SwiftCurrent/TestOnly/TestOnly.swift index d33631d50..920096416 100644 --- a/Sources/SwiftCurrent/TestOnly/TestOnly.swift +++ b/Sources/SwiftCurrent/TestOnly/TestOnly.swift @@ -21,11 +21,7 @@ extension Notification.Name { extension FlowRepresentable { /// :nodoc: Your tests may want to manually set the closure so they can make assertions it was called, this is simply a convenience available for that. public var proceedInWorkflowStorage: ((AnyWorkflow.PassedArgs) -> Void)? { - get { - { - _workflowPointer?.proceedInWorkflowStorage?($0) - } - } + get { { _workflowPointer?.proceedInWorkflowStorage?($0) } } set { _workflowPointer?.proceedInWorkflowStorage = { args in newValue?(args) @@ -35,11 +31,7 @@ extension FlowRepresentable { /// :nodoc: Designed for V1 and V2 people who used to assign to proceedInWorkflow for tests. This auto extracts args. public var _proceedInWorkflow: ((Any?) -> Void)? { - get { - { - _workflowPointer?.proceedInWorkflowStorage?(.args($0)) - } - } + get { { _workflowPointer?.proceedInWorkflowStorage?(.args($0)) } } set { _workflowPointer?.proceedInWorkflowStorage = { args in newValue?(args.extractArgs(defaultValue: nil)) diff --git a/Sources/SwiftCurrent/TypeErased/AnyFlowRepresentable.swift b/Sources/SwiftCurrent/TypeErased/AnyFlowRepresentable.swift index 62649b8e5..00305ec86 100644 --- a/Sources/SwiftCurrent/TypeErased/AnyFlowRepresentable.swift +++ b/Sources/SwiftCurrent/TypeErased/AnyFlowRepresentable.swift @@ -10,7 +10,7 @@ import Foundation /// A type erased `FlowRepresentable`. -public class AnyFlowRepresentable { +open class AnyFlowRepresentable { typealias WorkflowInput = Any typealias WorkflowOutput = Any diff --git a/Sources/SwiftCurrent/TypeErased/AnyWorkflow.swift b/Sources/SwiftCurrent/TypeErased/AnyWorkflow.swift index d68a48730..b255aaeed 100644 --- a/Sources/SwiftCurrent/TypeErased/AnyWorkflow.swift +++ b/Sources/SwiftCurrent/TypeErased/AnyWorkflow.swift @@ -35,6 +35,34 @@ public class AnyWorkflow { // swiftlint:disable:next missing_docs public func _abandon() { storageBase._abandon() } + + /// Appends `FlowRepresentableMetadata` to the `Workflow`. + public func append(_ metadata: FlowRepresentableMetadata) { + storageBase.append(metadata) + } + + /** + Launches the `Workflow`. + + ### Discussion + passedArgs are passed to the first instance, it has the opportunity to load, not load and transform them, or just not load. + In the event an instance does not load and does not transform args, they are passed unmodified to the next instance in the `Workflow` until one loads. + + - Parameter orchestrationResponder: the `OrchestrationResponder` to notify when the `Workflow` proceeds or backs up. + - Parameter passedArgs: the arguments to pass to the first instance(s). + - Parameter launchStyle: the launch style to use. + - Parameter onFinish: the closure to call when the last element in the workflow proceeds; called with the `AnyWorkflow.PassedArgs` the workflow finished with. + - Returns: the first loaded instance or nil, if none was loaded. + */ + @discardableResult public func launch(withOrchestrationResponder orchestrationResponder: OrchestrationResponder, + passedArgs: AnyWorkflow.PassedArgs, + launchStyle: LaunchStyle = .default, + onFinish: ((AnyWorkflow.PassedArgs) -> Void)? = nil) -> AnyWorkflow.Element? { + storageBase.launch(withOrchestrationResponder: orchestrationResponder, + passedArgs: passedArgs, + launchStyle: launchStyle, + onFinish: onFinish) + } } extension AnyWorkflow: Sequence { @@ -88,6 +116,21 @@ fileprivate class AnyWorkflowStorageBase { func last(where _: (LinkedList<_WorkflowItem>.Element) throws -> Bool) rethrows -> LinkedList<_WorkflowItem>.Element? { fatalError("last(where:) not overridden by AnyWorkflowStorage") } + + // https://github.com/wwt/SwiftCurrent/blob/main/.github/STYLEGUIDE.md#type-erasure + // swiftlint:disable:next unavailable_function + func append(_ metadata: FlowRepresentableMetadata) { + fatalError("append(:) not overridden by AnyWorkflowStorage") + } + + // https://github.com/wwt/SwiftCurrent/blob/main/.github/STYLEGUIDE.md#type-erasure + // swiftlint:disable:next unavailable_function + @discardableResult func launch(withOrchestrationResponder orchestrationResponder: OrchestrationResponder, + passedArgs: AnyWorkflow.PassedArgs, + launchStyle: LaunchStyle = .default, + onFinish: ((AnyWorkflow.PassedArgs) -> Void)? = nil) -> AnyWorkflow.Element? { + fatalError("launch(orchestrationResponder:passedArgs:launchStyle:onFinish) not overridden by AnyWorkflowStorage") + } } fileprivate final class AnyWorkflowStorage: AnyWorkflowStorageBase { @@ -119,4 +162,18 @@ fileprivate final class AnyWorkflowStorage: AnyWorkflowSto override func last(where predicate: (LinkedList<_WorkflowItem>.Element) throws -> Bool) rethrows -> LinkedList<_WorkflowItem>.Element? { try workflow.last(where: predicate) } + + override func append(_ metadata: FlowRepresentableMetadata) { + workflow.append(metadata) + } + + override func launch(withOrchestrationResponder orchestrationResponder: OrchestrationResponder, + passedArgs: AnyWorkflow.PassedArgs, + launchStyle: LaunchStyle = .default, + onFinish: ((AnyWorkflow.PassedArgs) -> Void)? = nil) -> AnyWorkflow.Element? { + workflow.launch(withOrchestrationResponder: orchestrationResponder, + passedArgs: passedArgs, + launchStyle: launchStyle, + onFinish: onFinish) + } } diff --git a/Sources/SwiftCurrent_SwiftUI/Extensions/AnyWorkflowExtensions.swift b/Sources/SwiftCurrent_SwiftUI/Extensions/AnyWorkflowExtensions.swift new file mode 100644 index 000000000..7dd1df3d4 --- /dev/null +++ b/Sources/SwiftCurrent_SwiftUI/Extensions/AnyWorkflowExtensions.swift @@ -0,0 +1,16 @@ +// swiftlint:disable:this file_name +// AnyWorkflowExtensions.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 7/12/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftCurrent + +extension AnyWorkflow { + /// Called when the workflow should be terminated, and the app should return to the point before the workflow was launched. + public func abandon() { + orchestrationResponder?.abandon(self, onFinish: nil) + } +} diff --git a/Sources/SwiftCurrent_SwiftUI/Extensions/WorkflowExtensions.swift b/Sources/SwiftCurrent_SwiftUI/Extensions/WorkflowExtensions.swift new file mode 100644 index 000000000..347fb4506 --- /dev/null +++ b/Sources/SwiftCurrent_SwiftUI/Extensions/WorkflowExtensions.swift @@ -0,0 +1,18 @@ +// swiftlint:disable:this file_name +// WorkflowExtensions.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 7/13/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftCurrent +import SwiftUI + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension Workflow where F: FlowRepresentable & View { + /// Called when the workflow should be terminated, and the app should return to the point before the workflow was launched. + public func abandon() { + AnyWorkflow(self).abandon() + } +} diff --git a/Sources/SwiftCurrent_SwiftUI/Models/WorkflowViewModel.swift b/Sources/SwiftCurrent_SwiftUI/Models/WorkflowViewModel.swift new file mode 100644 index 000000000..099451a8c --- /dev/null +++ b/Sources/SwiftCurrent_SwiftUI/Models/WorkflowViewModel.swift @@ -0,0 +1,67 @@ +// +// WorkflowViewModel.swift +// SwiftCurrent_SwiftUI +// +// Created by Megan Wiemer on 7/13/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftCurrent +import SwiftUI + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class WorkflowViewModel: ObservableObject { + @Published var body = AnyView(EmptyView()) + var isLaunched: Binding? + var onAbandon = [() -> Void]() +} + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension WorkflowViewModel: OrchestrationResponder { + func launch(to destination: AnyWorkflow.Element) { + extractView(from: destination).model = self + } + + func proceed(to destination: AnyWorkflow.Element, from source: AnyWorkflow.Element) { + extractView(from: destination).model = self + } + + func backUp(from source: AnyWorkflow.Element, to destination: AnyWorkflow.Element) { + extractView(from: destination).model = self + } + + func abandon(_ workflow: AnyWorkflow, onFinish: (() -> Void)?) { + isLaunched?.wrappedValue = false + onAbandon.forEach { $0() } + } + + func complete(_ workflow: AnyWorkflow, passedArgs: AnyWorkflow.PassedArgs, onFinish: ((AnyWorkflow.PassedArgs) -> Void)?) { + if workflow.lastLoadedItem?.value.metadata.persistence == .removedAfterProceeding { + if let lastPresentableItem = workflow.lastPresentableItem { + extractView(from: lastPresentableItem).model = self + } else { + isLaunched?.wrappedValue = false + } + } + onFinish?(passedArgs) + } + + private func extractView(from element: AnyWorkflow.Element) -> AnyFlowRepresentableView { + guard let instance = element.value.instance as? AnyFlowRepresentableView else { + fatalError("Could not cast \(String(describing: element.value.instance)) to expected type: AnyFlowRepresentableView") + } + return instance + } +} + +extension AnyWorkflow { + fileprivate var lastLoadedItem: AnyWorkflow.Element? { + last { $0.value.instance != nil } + } + + fileprivate var lastPresentableItem: AnyWorkflow.Element? { + last { + $0.value.instance != nil && $0.value.metadata.persistence != .removedAfterProceeding + } + } +} diff --git a/Sources/SwiftCurrent_SwiftUI/TypeErased/AnyFlowRepresentableView.swift b/Sources/SwiftCurrent_SwiftUI/TypeErased/AnyFlowRepresentableView.swift new file mode 100644 index 000000000..05949fa95 --- /dev/null +++ b/Sources/SwiftCurrent_SwiftUI/TypeErased/AnyFlowRepresentableView.swift @@ -0,0 +1,36 @@ +// +// AnyFlowRepresentableView.swift +// SwiftCurrent_SwiftUI +// +// Created by Megan Wiemer on 7/13/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftCurrent +import SwiftUI + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class AnyFlowRepresentableView: AnyFlowRepresentable { + var model: WorkflowViewModel? { + didSet { + setViewOnModel() + } + } + private var setViewOnModel = { } + + init(type: FR.Type, args: AnyWorkflow.PassedArgs) { + super.init(type, args: args) + guard let instance = underlyingInstance as? FR else { + fatalError("Could not cast \(String(describing: underlyingInstance)) to expected type: \(FR.self)") + } + setViewOnModel = { [weak self] in + self?.model?.body = AnyView(instance) + } + } + + func changeUnderlyingView(to view: V) { + setViewOnModel = { [weak self] in + self?.model?.body = AnyView(view) + } + } +} diff --git a/Sources/SwiftCurrent_SwiftUI/ViewInspector/Inspection.swift b/Sources/SwiftCurrent_SwiftUI/ViewInspector/Inspection.swift new file mode 100644 index 000000000..920fe43f1 --- /dev/null +++ b/Sources/SwiftCurrent_SwiftUI/ViewInspector/Inspection.swift @@ -0,0 +1,22 @@ +// +// Inspection.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 7/12/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftUI +import Combine + +// Necessary for ViewInspector tests +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class Inspection where V: View { + let notice = PassthroughSubject() + var callbacks = [UInt: (V) -> Void]() + func visit(_ view: V, _ line: UInt) { + if let callback = callbacks.removeValue(forKey: line) { + callback(view) + } + } +} diff --git a/Sources/SwiftCurrent_SwiftUI/Views/WorkflowView.swift b/Sources/SwiftCurrent_SwiftUI/Views/WorkflowView.swift new file mode 100644 index 000000000..176aadad4 --- /dev/null +++ b/Sources/SwiftCurrent_SwiftUI/Views/WorkflowView.swift @@ -0,0 +1,243 @@ +// +// WorkflowView.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 7/12/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftUI +import SwiftCurrent + +/** + A view used to build a `Workflow` in SwiftUI. + + ### Discussion + The preferred method for creating a `Workflow` with SwiftUI is a combination of `WorkflowView` and `WorkflowItem`. Initialize with arguments if your first `FlowRepresentable` has an input type. + + #### Example + */ +/// ```swift +/// WorkflowView(isLaunched: $isLaunched.animation(), args: "String in") +/// .thenProceed(with: WorkflowItem(FirstView.self) +/// .applyModifiers { +/// if true { // Enabling transition animation +/// $0.background(Color.gray) +/// .transition(.slide) +/// .animation(.spring()) +/// } +/// }) +/// .thenProceed(with: WorkflowItem(SecondView.self) +/// .persistence(.removedAfterProceeding) +/// .applyModifiers { +/// if true { +/// $0.SecondViewSpecificModifier() +/// .padding(10) +/// .background(Color.purple) +/// .transition(.opacity) +/// .animation(.easeInOut) +/// } +/// }) +/// .onAbandon { print("presentingWorkflowView is now false") } +/// .onFinish { args in print("Finished 1: \(args)") } +/// .onFinish { print("Finished 2: \($0)") } +/// .background(Color.green) +/// ``` +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +public struct WorkflowView: View { + @Binding private var isLaunched: Bool + @StateObject private var model = WorkflowViewModel() + @State private var didLoad = false + + let inspection = Inspection() // Needed for ViewInspector + private var workflow: AnyWorkflow? + private var onFinish = [(AnyWorkflow.PassedArgs) -> Void]() + private var onAbandon = [() -> Void]() + private var passedArgs = AnyWorkflow.PassedArgs.none + + /** + Creates a `WorkflowView` that displays a `FlowRepresentable` when presented. + - Parameter isLaunched: binding that controls launching the underlying `Workflow`. + */ + public init(isLaunched: Binding) where Args == Never { + _isLaunched = isLaunched + } + + /** + Creates a `WorkflowView` that displays a `FlowRepresentable` when presented. + - Parameter isLaunched: binding that controls launching the underlying `Workflow`. + - Parameter startingArgs: arguments passed to the first `FlowRepresentable` in the underlying `Workflow`. + */ + public init(isLaunched: Binding, startingArgs args: Args) { + _isLaunched = isLaunched + if let args = args as? AnyWorkflow.PassedArgs { + passedArgs = args + } else { + passedArgs = .args(args) + } + } + + private init(isLaunched: Binding, + workflow: AnyWorkflow?, + onFinish: [(AnyWorkflow.PassedArgs) -> Void], + onAbandon: [() -> Void], + passedArgs: AnyWorkflow.PassedArgs) { + _isLaunched = isLaunched + self.workflow = workflow + self.onFinish = onFinish + self.onAbandon = onAbandon + self.passedArgs = passedArgs + } + + public var body: some View { + if isLaunched { + VStack { + model.body + } + .onAppear { + guard !didLoad else { return } + didLoad = true + model.isLaunched = $isLaunched + model.onAbandon = onAbandon + workflow?.launch(withOrchestrationResponder: model, + passedArgs: passedArgs, + launchStyle: .new) { passedArgs in + onFinish.forEach { $0(passedArgs) } + } + } + .onDisappear { + if !isLaunched { + didLoad = false + model.body = AnyView(EmptyView()) + } + } + .onReceive(inspection.notice) { inspection.visit(self, $0) } // Needed for ViewInspector + } + } + + /// Adds an action to perform when this `Workflow` has finished. + public func onFinish(closure: @escaping (AnyWorkflow.PassedArgs) -> Void) -> Self { + var onFinish = self.onFinish + onFinish.append(closure) + return WorkflowView(isLaunched: $isLaunched, + workflow: workflow, + onFinish: onFinish, + onAbandon: onAbandon, + passedArgs: passedArgs) + } + + /// Adds an action to perform when this `Workflow` has abandoned. + public func onAbandon(closure: @escaping () -> Void) -> Self { + var onAbandon = self.onAbandon + onAbandon.append(closure) + return WorkflowView(isLaunched: $isLaunched, + workflow: workflow, + onFinish: onFinish, + onAbandon: onAbandon, + passedArgs: passedArgs) + } +} + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension WorkflowView where Args == Never { + /** + Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the args that will be passed forward. + - Parameter workflowItem: a `WorkflowItem` that holds onto the next `FlowRepresentable` in the workflow. + - Returns: a new `WorkflowView` with the additional `FlowRepresentable` item. + */ + public func thenProceed(with item: WorkflowItem) -> WorkflowView where FR.WorkflowInput == Never { + var workflow = self.workflow + if workflow == nil { + workflow = AnyWorkflow(Workflow(item.metadata)) + } else { + workflow?.append(item.metadata) + } + return WorkflowView(isLaunched: $isLaunched, + workflow: workflow, + onFinish: onFinish, + onAbandon: onAbandon, + passedArgs: passedArgs) + } +} + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension WorkflowView where Args == AnyWorkflow.PassedArgs { + /** + Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the args that will be passed forward. + - Parameter workflowItem: a `WorkflowItem` that holds onto the next `FlowRepresentable` in the workflow. + - Returns: a new `WorkflowView` with the additional `FlowRepresentable` item. + */ + public func thenProceed(with item: WorkflowItem) -> WorkflowView where FR.WorkflowInput == AnyWorkflow.PassedArgs { + var workflow = self.workflow + if workflow == nil { + workflow = AnyWorkflow(Workflow(item.metadata)) + } else { + workflow?.append(item.metadata) + } + return WorkflowView(isLaunched: $isLaunched, + workflow: workflow, + onFinish: onFinish, + onAbandon: onAbandon, + passedArgs: passedArgs) + } + + /** + Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the args that will be passed forward. + - Parameter workflowItem: a `WorkflowItem` that holds onto the next `FlowRepresentable` in the workflow. + - Returns: a new `WorkflowView` with the additional `FlowRepresentable` item. + */ + public func thenProceed(with item: WorkflowItem) -> WorkflowView { + var workflow = self.workflow + if workflow == nil { + workflow = AnyWorkflow(Workflow(item.metadata)) + } else { + workflow?.append(item.metadata) + } + return WorkflowView(isLaunched: $isLaunched, + workflow: workflow, + onFinish: onFinish, + onAbandon: onAbandon, + passedArgs: passedArgs) + } +} + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension WorkflowView { + /** + Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the args that will be passed forward. + - Parameter workflowItem: a `WorkflowItem` that holds onto the next `FlowRepresentable` in the workflow. + - Returns: a new `WorkflowView` with the additional `FlowRepresentable` item. + */ + public func thenProceed(with item: WorkflowItem) -> WorkflowView where Args == FR.WorkflowInput { + var workflow = self.workflow + if workflow == nil { + workflow = AnyWorkflow(Workflow(item.metadata)) + } else { + workflow?.append(item.metadata) + } + return WorkflowView(isLaunched: $isLaunched, + workflow: workflow, + onFinish: onFinish, + onAbandon: onAbandon, + passedArgs: passedArgs) + } + + /** + Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the args that will be passed forward. + - Parameter workflowItem: a `WorkflowItem` that holds onto the next `FlowRepresentable` in the workflow. + - Returns: a new `WorkflowView` with the additional `FlowRepresentable` item. + */ + public func thenProceed(with item: WorkflowItem) -> WorkflowView where FR.WorkflowInput == AnyWorkflow.PassedArgs { + var workflow = self.workflow + if workflow == nil { + workflow = AnyWorkflow(Workflow(item.metadata)) + } else { + workflow?.append(item.metadata) + } + return WorkflowView(isLaunched: $isLaunched, + workflow: workflow, + onFinish: onFinish, + onAbandon: onAbandon, + passedArgs: passedArgs) + } +} diff --git a/Sources/SwiftCurrent_SwiftUI/WorkflowItem.swift b/Sources/SwiftCurrent_SwiftUI/WorkflowItem.swift new file mode 100644 index 000000000..519da92dc --- /dev/null +++ b/Sources/SwiftCurrent_SwiftUI/WorkflowItem.swift @@ -0,0 +1,114 @@ +// +// WorkflowItem.swift +// SwiftCurrent_SwiftUI +// +// Created by Tyler Thompson on 7/12/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import Foundation +import SwiftUI +import SwiftCurrent + +/** + A concrete type used to modify a `FlowRepresentable` in a `WorkflowView`. + + ### Discussion + `WorkflowItem` gives you the ability to specify changes you'd like to apply to a specific `FlowRepresentable` when it is time to present it in a `Workflow`. + + #### Example + ```swift + WorkflowItem(FirstView.self) + .persistence(.removedAfterProceeding) // affects only FirstView + .applyModifiers { + if true { // Enabling transition animation + $0.background(Color.gray) // $0 is a FirstView instance + .transition(.slide) + .animation(.spring()) + } + } + ``` + */ +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +public final class WorkflowItem { + var metadata: FlowRepresentableMetadata! + private var flowPersistenceClosure: (AnyWorkflow.PassedArgs) -> FlowPersistence = { _ in .default } + private var modifierClosure: ((AnyFlowRepresentableView) -> Void)? + + /// Creates a `WorkflowItem` with no arguments from a `FlowRepresentable` that is also a View. + public init(_: F.Type) { + metadata = FlowRepresentableMetadata(F.self, + launchStyle: .new, + flowPersistence: flowPersistenceClosure, + flowRepresentableFactory: factory) + } + + /** + Provides a way to apply modifiers to your `FlowRepresentable` view. + + ### Important: The most recently defined (or last) use of this, is the only one that applies modifiers, unlike onAbandon or onFinish. + */ + public func applyModifiers(@ViewBuilder _ closure: @escaping (F) -> V) -> Self { + modifierClosure = { + // We are essentially casting this to itself, that cannot fail. (Famous last words) + // swiftlint:disable:next force_cast + let instance = $0.underlyingInstance as! F + $0.changeUnderlyingView(to: closure(instance)) + } + return self + } + + private func factory(args: AnyWorkflow.PassedArgs) -> AnyFlowRepresentable { + let afrv = AnyFlowRepresentableView(type: F.self, args: args) + modifierClosure?(afrv) + return afrv + } +} + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension WorkflowItem { + /// Sets persistence on the `FlowRepresentable` of the `WorkflowItem`. + public func persistence(_ persistence: @escaping @autoclosure () -> FlowPersistence) -> Self { + flowPersistenceClosure = { _ in persistence() } + metadata = FlowRepresentableMetadata(F.self, + launchStyle: .new, + flowPersistence: flowPersistenceClosure, + flowRepresentableFactory: factory) + return self + } + + /// Sets persistence on the `FlowRepresentable` of the `WorkflowItem`. + public func persistence(_ persistence: @escaping (F.WorkflowInput) -> FlowPersistence) -> Self { + flowPersistenceClosure = { + guard case .args(let arg as F.WorkflowInput) = $0 else { + fatalError("Could not cast \(String(describing: $0)) to expected type: \(F.WorkflowInput.self)") + } + return persistence(arg) + } + metadata = FlowRepresentableMetadata(F.self, + launchStyle: .new, + flowPersistence: flowPersistenceClosure, + flowRepresentableFactory: factory) + return self + } + + /// Sets persistence on the `FlowRepresentable` of the `WorkflowItem`. + public func persistence(_ persistence: @escaping (F.WorkflowInput) -> FlowPersistence) -> Self where F.WorkflowInput == AnyWorkflow.PassedArgs { + flowPersistenceClosure = { persistence($0) } + metadata = FlowRepresentableMetadata(F.self, + launchStyle: .new, + flowPersistence: flowPersistenceClosure, + flowRepresentableFactory: factory) + return self + } + + /// Sets persistence on the `FlowRepresentable` of the `WorkflowItem`. + public func persistence(_ persistence: @escaping () -> FlowPersistence) -> Self where F.WorkflowInput == Never { + flowPersistenceClosure = { _ in persistence() } + metadata = FlowRepresentableMetadata(F.self, + launchStyle: .new, + flowPersistence: flowPersistenceClosure, + flowRepresentableFactory: factory) + return self + } +} diff --git a/SwiftCurrent.podspec b/SwiftCurrent.podspec index b9d04790f..a61d890b6 100644 --- a/SwiftCurrent.podspec +++ b/SwiftCurrent.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'SwiftCurrent' - s.version = '4.0.16' + s.version = '4.1.0' s.summary = 'A library for complex workflows in Swift' s.description = <<-DESC SwiftCurrent is a library that lets you easily manage journeys through your Swift application. @@ -26,6 +26,12 @@ Pod::Spec.new do |s| ss.dependency 'SwiftCurrent/Core' end + s.subspec 'BETA_SwiftUI' do |ss| + ss.ios.deployment_target = '11.0' + ss.source_files = 'Sources/SwiftCurrent_SwiftUI/**/*.{swift,h,m}' + ss.dependency 'SwiftCurrent/Core' + end + s.pod_target_xcconfig = { 'FRAMEWORK_SEARCH_PATHS' => '$(inherited) "$(PLATFORM_DIR)/Developer/Library/Frameworks"', } diff --git a/SwiftCurrent.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftCurrent.xcworkspace/xcshareddata/swiftpm/Package.resolved index 668695fb5..e5d218f8d 100644 --- a/SwiftCurrent.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftCurrent.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -54,6 +54,15 @@ "revision": "33cc7be1352dfdb37984e38e4276daa65030c5bc", "version": "1.8.0" } + }, + { + "package": "ViewInspector", + "repositoryURL": "https://github.com/nalexn/ViewInspector.git", + "state": { + "branch": null, + "revision": "a9b0d2af51b8afbbcb6a3e1005aabf8726852a77", + "version": "0.8.1" + } } ] }, diff --git a/SwiftCurrent.xcworkspace/xcshareddata/xcschemes/SwiftCurrent.xcscheme b/SwiftCurrent.xcworkspace/xcshareddata/xcschemes/SwiftCurrent.xcscheme index 39f94112a..b9783d501 100644 --- a/SwiftCurrent.xcworkspace/xcshareddata/xcschemes/SwiftCurrent.xcscheme +++ b/SwiftCurrent.xcworkspace/xcshareddata/xcschemes/SwiftCurrent.xcscheme @@ -87,6 +87,16 @@ ReferencedContainer = "container:SwiftCurrentExample/SwiftCurrentExample.xcodeproj"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SwiftCurrentExample/SwiftCurrentExample.xcodeproj/project.pbxproj b/SwiftCurrentExample/SwiftCurrentExample.xcodeproj/project.pbxproj index dca20d3a1..81e9a3302 100644 --- a/SwiftCurrentExample/SwiftCurrentExample.xcodeproj/project.pbxproj +++ b/SwiftCurrentExample/SwiftCurrentExample.xcodeproj/project.pbxproj @@ -679,7 +679,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 9CUJHB48U6; + DEVELOPMENT_TEAM = D76F7H5QKL; INFOPLIST_FILE = SwiftCurrentExample/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Tests/SwiftCurrent_SwiftUITests/AnyFlowRepresentableViewTests.swift b/Tests/SwiftCurrent_SwiftUITests/AnyFlowRepresentableViewTests.swift new file mode 100644 index 000000000..08a7e2000 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/AnyFlowRepresentableViewTests.swift @@ -0,0 +1,51 @@ +// +// AnyFlowRepresentableViewTests.swift +// SwiftCurrent_SwiftUI +// +// Created by Tyler Thompson on 7/13/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import XCTest +import SwiftUI + +import SwiftCurrent + +@testable import SwiftCurrent_SwiftUI + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class AnyFlowRepresentableViewTests: XCTestCase { + func testAnyFlowRepresentableViewDoesNotCreate_StrongRetainCycle() { + var afrv: AnyFlowRepresentableView? + weak var ref: AnyFlowRepresentableView? + afrv = AnyFlowRepresentableView(type: FR.self, args: .none) + ref = afrv + XCTAssertNotNil(afrv) + XCTAssertNotNil(ref) + afrv = nil + XCTAssertNil(afrv) + XCTAssertNil(ref) + } + + func testAnyFlowRepresentableViewDoesNotCreate_StrongRetainCycle_WhenUnderlyingViewIsChanged() { + var afrv: AnyFlowRepresentableView? + weak var ref: AnyFlowRepresentableView? + afrv = AnyFlowRepresentableView(type: FR.self, args: .none) + afrv?.changeUnderlyingView(to: EmptyView()) + ref = afrv + XCTAssertNotNil(afrv) + XCTAssertNotNil(ref) + afrv = nil + XCTAssertNil(afrv) + XCTAssertNil(ref) + } +} + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +fileprivate struct FR: View, FlowRepresentable { + weak var _workflowPointer: AnyFlowRepresentable? + + var body: some View { + EmptyView() + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/AnyWorkflowTests.swift b/Tests/SwiftCurrent_SwiftUITests/AnyWorkflowTests.swift new file mode 100644 index 000000000..de9f30ef0 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/AnyWorkflowTests.swift @@ -0,0 +1,34 @@ +// +// AnyWorkflowTests.swift +// SwiftCurrent_SwiftUI +// +// Created by Tyler Thompson on 7/13/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import XCTest +import SwiftUI + +import SwiftCurrent +import SwiftCurrent_SwiftUI + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class AnyWorkflowTests: XCTestCase { + func testAbandonDoesNotBLOWUP() { + let wf = Workflow(FR.self) + AnyWorkflow(wf).abandon() + } + + func testAbandonDoesNotBLOWUPOnTypedWorkflow() { + Workflow(FR.self).abandon() + } +} + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +fileprivate struct FR: View, FlowRepresentable { + weak var _workflowPointer: AnyFlowRepresentable? + + var body: some View { + EmptyView() + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/CustomAssertions.swift b/Tests/SwiftCurrent_SwiftUITests/CustomAssertions.swift new file mode 100644 index 000000000..222254e37 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/CustomAssertions.swift @@ -0,0 +1,22 @@ +// +// CustomAssertions.swift +// WorkflowTests +// +// Created by Tyler Thompson on 9/2/19. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import Foundation +import XCTest + +@testable import CwlPreconditionTesting + +func XCTAssertThrowsFatalError(instructions: @escaping () -> Void, file: StaticString = #file, line: UInt = #line) { + var reached = false + let exception = catchBadInstruction { + instructions() + reached = true + } + XCTAssertNotNil(exception, "No fatal error thrown", file: file, line: line) + XCTAssertFalse(reached, "Code executed past expected fatal error", file: file, line: line) +} diff --git a/Tests/SwiftCurrent_SwiftUITests/MockOrchestrationResponder.swift b/Tests/SwiftCurrent_SwiftUITests/MockOrchestrationResponder.swift new file mode 100644 index 000000000..dd6d879a2 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/MockOrchestrationResponder.swift @@ -0,0 +1,64 @@ +// +// MockOrchestrationResponder.swift +// +// +// Created by Tyler Thompson on 11/25/20. +// + +import Foundation +import SwiftCurrent + +class MockOrchestrationResponder: OrchestrationResponder { + var launchCalled = 0 + var allTos = [AnyWorkflow.Element]() + var lastTo: AnyWorkflow.Element? { + allTos.last + } + + func launch(to: AnyWorkflow.Element) { + allTos.append(to) + launchCalled += 1 + } + + var proceedCalled = 0 + var allFroms = [AnyWorkflow.Element]() + var lastFrom: AnyWorkflow.Element? { + allFroms.last + } + + func proceed(to: AnyWorkflow.Element, + from: AnyWorkflow.Element) { + allTos.append(to) + allFroms.append(from) + proceedCalled += 1 + } + + var backUpCalled = 0 + func backUp(from: AnyWorkflow.Element, to: AnyWorkflow.Element) { + allFroms.append(from) + allTos.append(to) + backUpCalled += 1 + } + + var abandonCalled = 0 + var lastWorkflow: AnyWorkflow? + var lastOnFinish:(() -> Void)? + func abandon(_ workflow: AnyWorkflow, onFinish: (() -> Void)?) { + lastWorkflow = workflow + lastOnFinish = onFinish + abandonCalled += 1 + } + + var completeCalled = 0 + var lastPassedArgs: AnyWorkflow.PassedArgs? + var lastCompleteOnFinish: ((AnyWorkflow.PassedArgs) -> Void)? + var complete_EnableDefaultImplementation = false + func complete(_ workflow: AnyWorkflow, passedArgs: AnyWorkflow.PassedArgs, onFinish: ((AnyWorkflow.PassedArgs) -> Void)?) { + lastWorkflow = workflow + lastPassedArgs = passedArgs + lastCompleteOnFinish = onFinish + completeCalled += 1 + + if complete_EnableDefaultImplementation { onFinish?(passedArgs) } + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/PersistenceTests.swift b/Tests/SwiftCurrent_SwiftUITests/PersistenceTests.swift new file mode 100644 index 000000000..f34d0b738 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/PersistenceTests.swift @@ -0,0 +1,523 @@ +// +// PersistenceTests.swift +// SwiftCurrent_SwiftUI +// +// Created by Tyler Thompson on 7/12/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import XCTest +import SwiftUI +import ViewInspector + +import SwiftCurrent +@testable import SwiftCurrent_SwiftUI // testable sadly needed for inspection.inspect to work + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class PersistenceTests: XCTestCase { + // MARK: RemovedAfterProceedingTests + func testRemovedAfterProceeding_OnFirstItemInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR2.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testRemovedAfterProceeding_OnMiddleItemInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testRemovedAfterProceeding_OnLastItemInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self).persistence(.removedAfterProceeding)) + .onFinish { _ in expectOnFinish.fulfill() }) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self)) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self)) + } + + wait(for: [expectViewLoaded, expectOnFinish], timeout: 0.3) + } + + func testRemovedAfterProceeding_OnMultipleItemsInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR3.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testRemovedAfterProceeding_OnAllItemsInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let binding = Binding(wrappedValue: true) + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: binding) + .thenProceed(with: WorkflowItem(FR1.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR2.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR3.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR4.self).persistence(.removedAfterProceeding)) + .onFinish { _ in expectOnFinish.fulfill() }) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR3.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR2.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR1.self)) + XCTAssertFalse(binding.wrappedValue, "Binding should be flipped to false") + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } + + // MARK: Closure API Tests + + func testPersistenceWorks_WhenDefinedFromAClosure() throws { + struct FR1: View, FlowRepresentable, Inspectable { + init(with args: String) { } + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let binding = Binding(wrappedValue: true) + let expectOnFinish = expectation(description: "OnFinish called") + let expectedStart = UUID().uuidString + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: binding, startingArgs: expectedStart) + .thenProceed(with: WorkflowItem(FR1.self).persistence { + XCTAssertEqual($0, expectedStart) + return .removedAfterProceeding + }) + .thenProceed(with: WorkflowItem(FR2.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR3.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR4.self).persistence(.removedAfterProceeding)) + .onFinish { _ in expectOnFinish.fulfill() }) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR3.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR2.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR1.self)) + XCTAssertFalse(binding.wrappedValue, "Binding should be flipped to false") + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } + + func testPersistenceWorks_WhenDefinedFromAClosure_AndItemHasInputOfPassedArgs() throws { + struct FR1: View, FlowRepresentable, Inspectable { + init(with args: AnyWorkflow.PassedArgs) { } + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let binding = Binding(wrappedValue: true) + let expectOnFinish = expectation(description: "OnFinish called") + let expectedStart = AnyWorkflow.PassedArgs.args(UUID().uuidString) + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: binding, startingArgs: expectedStart) + .thenProceed(with: WorkflowItem(FR1.self) + .persistence { + XCTAssertNotNil(expectedStart.extractArgs(defaultValue: 1) as? String) + XCTAssertEqual($0.extractArgs(defaultValue: nil) as? String, expectedStart.extractArgs(defaultValue: 1) as? String) + return .removedAfterProceeding + }) + .thenProceed(with: WorkflowItem(FR2.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR3.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR4.self).persistence(.removedAfterProceeding)) + .onFinish { _ in expectOnFinish.fulfill() }) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR3.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR2.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR1.self)) + XCTAssertFalse(binding.wrappedValue, "Binding should be flipped to false") + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } + + func testPersistenceWorks_WhenDefinedFromAClosure_AndItemHasInputOfNever() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let binding = Binding(wrappedValue: true) + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: binding) + .thenProceed(with: WorkflowItem(FR1.self) + .persistence { .removedAfterProceeding }) + .thenProceed(with: WorkflowItem(FR2.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR3.self).persistence(.removedAfterProceeding)) + .thenProceed(with: WorkflowItem(FR4.self).persistence(.removedAfterProceeding)) + .onFinish { _ in expectOnFinish.fulfill() }) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR3.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR2.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR1.self)) + XCTAssertFalse(binding.wrappedValue, "Binding should be flipped to false") + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } + + // MARK: PersistWhenSkippedTests + func testPersistWhenSkipped_OnFirstItemInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + func shouldLoad() -> Bool { false } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self).persistence(.persistWhenSkipped)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testPersistWhenSkipped_OnMiddleItemInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + func shouldLoad() -> Bool { false } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self).persistence(.persistWhenSkipped)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testPersistWhenSkipped_OnLastItemInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + func shouldLoad() -> Bool { false } + } + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self).persistence(.persistWhenSkipped)) + .onFinish { _ in expectOnFinish.fulfill() }) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded, expectOnFinish], timeout: 0.3) + } + + func testPersistWhenSkipped_OnMultipleItemsInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + func shouldLoad() -> Bool { false } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + func shouldLoad() -> Bool { false } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self).persistence(.persistWhenSkipped)) + .thenProceed(with: WorkflowItem(FR3.self).persistence(.persistWhenSkipped)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testPersistWhenSkipped_OnAllItemsInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + func shouldLoad() -> Bool { false } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + func shouldLoad() -> Bool { false } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + func shouldLoad() -> Bool { false } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + func shouldLoad() -> Bool { false } + } + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self).persistence(.persistWhenSkipped)) + .thenProceed(with: WorkflowItem(FR2.self).persistence(.persistWhenSkipped)) + .thenProceed(with: WorkflowItem(FR3.self).persistence(.persistWhenSkipped)) + .thenProceed(with: WorkflowItem(FR4.self).persistence(.persistWhenSkipped)) + .onFinish { _ in expectOnFinish.fulfill() }) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR4.self)) + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/SkipTests.swift b/Tests/SwiftCurrent_SwiftUITests/SkipTests.swift new file mode 100644 index 000000000..2ccf48ade --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/SkipTests.swift @@ -0,0 +1,196 @@ +// +// SkipTests.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 7/12/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import XCTest +import SwiftUI +import ViewInspector + +import SwiftCurrent +@testable import SwiftCurrent_SwiftUI // testable sadly needed for inspection.inspect to work + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class SkipTests: XCTestCase { + func testSkippingFirstItemInAWorkflow() throws { + // NOTE: Workflows in the past had issues with 4+ items, so this is to cover our bases. SwiftUI also has a nasty habit of behaving a little differently as number of views increase. + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + func shouldLoad() -> Bool { false } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertThrowsError(try viewUnderTest.find(FR1.self)) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testSkippingMiddleItemInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + func shouldLoad() -> Bool { false } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR2.self)) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testSkippingLastItemInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + func shouldLoad() -> Bool { false } + } + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self)) + .onFinish { _ in expectOnFinish.fulfill() }) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self)) + } + + wait(for: [expectViewLoaded, expectOnFinish], timeout: 0.3) + } + + func testSkippingMultipleItemsInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + func shouldLoad() -> Bool { false } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + func shouldLoad() -> Bool { false } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertThrowsError(try viewUnderTest.find(FR2.self).actualView()) + XCTAssertThrowsError(try viewUnderTest.find(FR3.self).actualView()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testSkippingAllItemsInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + func shouldLoad() -> Bool { false } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + func shouldLoad() -> Bool { false } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + func shouldLoad() -> Bool { false } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + func shouldLoad() -> Bool { false } + } + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self)) + .onFinish { _ in expectOnFinish.fulfill() }) + .inspection.inspect { viewUnderTest in + XCTAssertThrowsError(try viewUnderTest.find(FR1.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR2.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR3.self)) + XCTAssertThrowsError(try viewUnderTest.find(FR4.self)) + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/SwiftCurrent_SwiftUITests.swift b/Tests/SwiftCurrent_SwiftUITests/SwiftCurrent_SwiftUITests.swift new file mode 100644 index 000000000..d1cc18e78 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/SwiftCurrent_SwiftUITests.swift @@ -0,0 +1,479 @@ +// +// SwiftCurrent_SwiftUIConsumerTests.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 7/12/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import XCTest +import SwiftUI +import ViewInspector + +import SwiftCurrent +@testable import SwiftCurrent_SwiftUI // testable sadly needed for inspection.inspect to work + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase { + func testWorkflowCanBeFollowed() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .onFinish { _ in + expectOnFinish.fulfill() + }).inspection.inspect { viewUnderTest in + XCTAssertEqual(try viewUnderTest.vStack().anyView(0).view(FR1.self).text().string(), "FR1 type") + XCTAssertNoThrow(try viewUnderTest.vStack().anyView(0).view(FR1.self).actualView().proceedInWorkflow()) + XCTAssertEqual(try viewUnderTest.vStack().anyView(0).view(FR2.self).text().string(), "FR2 type") + XCTAssertNoThrow(try viewUnderTest.vStack().anyView(0).view(FR2.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } + + func testWorkflowCanHaveMultipleOnFinishClosures() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + let expectOnFinish1 = expectation(description: "OnFinish1 called") + let expectOnFinish2 = expectation(description: "OnFinish2 called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .onFinish { _ in + expectOnFinish1.fulfill() + }.onFinish { _ in + expectOnFinish2.fulfill() + }).inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectOnFinish1, expectOnFinish2, expectViewLoaded], timeout: 0.3) + } + + func testWorkflowPassesArgumentsToTheFirstItem() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + let stringProperty: String + init(with: String) { + self.stringProperty = with + } + var body: some View { Text("FR1 type") } + } + let expected = UUID().uuidString + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true), startingArgs: expected) + .thenProceed(with: WorkflowItem(FR1.self))).inspection.inspect { viewUnderTest in + XCTAssertEqual(try viewUnderTest.find(FR1.self).actualView().stringProperty, expected) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testWorkflowPassesArgumentsToTheFirstItem_WhenThatFirstItemTakesInAnyWorkflowPassedArgs() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + let property: AnyWorkflow.PassedArgs + init(with: AnyWorkflow.PassedArgs) { + self.property = with + } + var body: some View { Text("FR1 type") } + } + let expected = UUID().uuidString + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true), startingArgs: expected) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR1.self))).inspection.inspect { viewUnderTest in + XCTAssertEqual(try viewUnderTest.find(FR1.self).actualView().property.extractArgs(defaultValue: nil) as? String, expected) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testWorkflowPassesArgumentsToTheFirstItem_WhenThatFirstItemTakesInAnyWorkflowPassedArgs_AndTheLaunchArgsAreAnyWorkflowPassedArgs() throws { + struct FR1: View, FlowRepresentable, Inspectable { + typealias WorkflowOutput = AnyWorkflow.PassedArgs + var _workflowPointer: AnyFlowRepresentable? + let property: AnyWorkflow.PassedArgs + init(with: AnyWorkflow.PassedArgs) { + self.property = with + } + var body: some View { Text("FR1 type") } + } + let expected = UUID().uuidString + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true), startingArgs: AnyWorkflow.PassedArgs.args(expected)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR1.self))).inspection.inspect { viewUnderTest in + XCTAssertEqual(try viewUnderTest.find(FR1.self).actualView().property.extractArgs(defaultValue: nil) as? String, expected) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testWorkflowPassesArgumentsToAllItems() throws { + struct FR1: View, FlowRepresentable, Inspectable { + typealias WorkflowOutput = Int + var _workflowPointer: AnyFlowRepresentable? + let property: String + init(with: String) { + self.property = with + } + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + typealias WorkflowOutput = Bool + var _workflowPointer: AnyFlowRepresentable? + let property: Int + init(with: Int) { + self.property = with + } + var body: some View { Text("FR1 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + typealias WorkflowOutput = String + var _workflowPointer: AnyFlowRepresentable? + let property: Bool + init(with: Bool) { + self.property = with + } + var body: some View { Text("FR1 type") } + } + let expectedFR1 = UUID().uuidString + let expectedFR2 = Int.random(in: 1...10) + let expectedFR3 = Bool.random() + let expectedEnd = UUID().uuidString + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true), startingArgs: expectedFR1) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .onFinish { + XCTAssertEqual($0.extractArgs(defaultValue: nil) as? String, expectedEnd) + }).inspection.inspect { viewUnderTest in + XCTAssertEqual(try viewUnderTest.find(FR1.self).actualView().property, expectedFR1) + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow(expectedFR2)) + XCTAssertEqual(try viewUnderTest.find(FR2.self).actualView().property, expectedFR2) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow(expectedFR3)) + XCTAssertEqual(try viewUnderTest.find(FR3.self).actualView().property, expectedFR3) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow(expectedEnd)) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testLargeWorkflowCanBeFollowed() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + struct FR5: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR5 type") } + } + struct FR6: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR6 type") } + } + struct FR7: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR7 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self)) + .thenProceed(with: WorkflowItem(FR5.self)) + .thenProceed(with: WorkflowItem(FR6.self)) + .thenProceed(with: WorkflowItem(FR7.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR5.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR6.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR7.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testMovingBiDirectionallyInAWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + struct FR3: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR3 type") } + } + struct FR4: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR4 type") } + } + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .thenProceed(with: WorkflowItem(FR3.self)) + .thenProceed(with: WorkflowItem(FR4.self))) + .inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().backUpInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR3.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR4.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testWorkflowSetsBindingBooleanToFalseWhenAbandoned() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + let isLaunched = Binding(wrappedValue: true) + let expectOnAbandon = expectation(description: "OnAbandon called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: isLaunched) + .thenProceed(with: WorkflowItem(FR1.self)) + .onAbandon { + XCTAssertFalse(isLaunched.wrappedValue) + expectOnAbandon.fulfill() + }).inspection.inspect { viewUnderTest in + XCTAssertEqual(try viewUnderTest.find(FR1.self).text().string(), "FR1 type") + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().workflow?.abandon()) + XCTAssertThrowsError(try viewUnderTest.find(FR1.self)) + } + + wait(for: [expectOnAbandon, expectViewLoaded], timeout: 0.3) + } + + func testWorkflowViewCanHaveMultipleOnAbandonCallbacks() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + let isLaunched = Binding(wrappedValue: true) + let expectOnAbandon1 = expectation(description: "OnAbandon1 called") + let expectOnAbandon2 = expectation(description: "OnAbandon2 called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: isLaunched) + .thenProceed(with: WorkflowItem(FR1.self)) + .onAbandon { + XCTAssertFalse(isLaunched.wrappedValue) + expectOnAbandon1.fulfill() + }.onAbandon { + XCTAssertFalse(isLaunched.wrappedValue) + expectOnAbandon2.fulfill() + }).inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().workflow?.abandon()) + XCTAssertThrowsError(try viewUnderTest.find(FR1.self)) + } + + wait(for: [expectOnAbandon1, expectOnAbandon2, expectViewLoaded], timeout: 0.3) + } + + func testWorkflowViewCanHaveModifiers() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + + func customModifier() -> Self { self } + } + + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self) + .applyModifiers { $0.customModifier().background(Color.blue) })).inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.vStack().anyView(0).view(FR1.self).background()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testWorkflowViewRemovesRemnantsAfterWorkflowIsEnded() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + + func customModifier() -> Self { self } + } + + let binding = Binding(wrappedValue: true) + let workflowView = WorkflowView(isLaunched: binding) + .thenProceed(with: WorkflowItem(FR1.self) + .applyModifiers { $0.customModifier().background(Color.blue) }) + let expectViewLoaded = ViewHosting.loadView(workflowView).inspection.inspect { viewUnderTest in + // Capture vstack for lifecycle simulation + let vstack = try viewUnderTest.vStack() + binding.wrappedValue = false + XCTAssertNoThrow(try vstack.callOnDisappear()) + // Expected that there is no vstack at this point in the lifecycle + XCTAssertThrowsError(try viewUnderTest.vStack()) + + // Change state to put the vstack back + binding.wrappedValue = true + // Starting state of the vstack when we come back should be an empty view + XCTAssertNoThrow(try viewUnderTest.vStack().anyView(0).emptyView()) + } + + wait(for: [expectViewLoaded], timeout: 0.3) + } + + func testWorkflowOnlyLaunchesOnce() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true)) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .onFinish { _ in + expectOnFinish.fulfill() + }).inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.vStack().callOnAppear()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } + + func testWorkflowRelaunchesWhenSubsequentlyLaunched() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + let binding = Binding(wrappedValue: true) + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: binding) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .onFinish { _ in + expectOnFinish.fulfill() + }).inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + let vStack = try viewUnderTest.vStack() + binding.wrappedValue = false + XCTAssertNoThrow(try vStack.callOnDisappear()) + binding.wrappedValue = true + XCTAssertNoThrow(try viewUnderTest.vStack().callOnAppear()) + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } + + func testWorkflowMaintainsStateWhenViewDisappearsAndReappears_WithoutIsLaunchedChanging() throws { + struct FR1: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR1 type") } + } + struct FR2: View, FlowRepresentable, Inspectable { + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + let binding = Binding(wrappedValue: true) + let expectOnFinish = expectation(description: "OnFinish called") + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: binding) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .onFinish { _ in + XCTAssert(binding.wrappedValue) + expectOnFinish.fulfill() + }).inspection.inspect { viewUnderTest in + XCTAssertNoThrow(try viewUnderTest.find(FR1.self).actualView().proceedInWorkflow()) + XCTAssertNoThrow(try viewUnderTest.vStack().callOnDisappear()) + XCTAssertNoThrow(try viewUnderTest.vStack().callOnAppear()) + XCTAssertNoThrow(try viewUnderTest.find(FR2.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } + + func testWorkflowCanHaveAPassthroughRepresentable() throws { + struct FR1: View, FlowRepresentable, Inspectable { + typealias WorkflowOutput = AnyWorkflow.PassedArgs + var _workflowPointer: AnyFlowRepresentable? + private let data: AnyWorkflow.PassedArgs + var body: some View { Text("FR1 type") } + + init(with data: AnyWorkflow.PassedArgs) { + self.data = data + } + } + struct FR2: View, FlowRepresentable, Inspectable { + init(with str: String) { } + var _workflowPointer: AnyFlowRepresentable? + var body: some View { Text("FR2 type") } + } + let expectOnFinish = expectation(description: "OnFinish called") + let expectedArgs = UUID().uuidString + let expectViewLoaded = ViewHosting.loadView( + WorkflowView(isLaunched: .constant(true), startingArgs: expectedArgs) + .thenProceed(with: WorkflowItem(FR1.self)) + .thenProceed(with: WorkflowItem(FR2.self)) + .onFinish { _ in + expectOnFinish.fulfill() + }).inspection.inspect { viewUnderTest in + XCTAssertEqual(try viewUnderTest.vStack().anyView(0).view(FR1.self).text().string(), "FR1 type") + XCTAssertNoThrow(try viewUnderTest.vStack().anyView(0).view(FR1.self).actualView().proceedInWorkflow(.args(expectedArgs))) + XCTAssertEqual(try viewUnderTest.vStack().anyView(0).view(FR2.self).text().string(), "FR2 type") + XCTAssertNoThrow(try viewUnderTest.vStack().anyView(0).view(FR2.self).actualView().proceedInWorkflow()) + } + + wait(for: [expectOnFinish, expectViewLoaded], timeout: 0.3) + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/ViewInspector/InspectableExtensions.swift b/Tests/SwiftCurrent_SwiftUITests/ViewInspector/InspectableExtensions.swift new file mode 100644 index 000000000..415105b55 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/ViewInspector/InspectableExtensions.swift @@ -0,0 +1,19 @@ +// +// InspectableExtensions.swift +// SwiftCurrent_SwiftUITests +// +// Created by Tyler Thompson on 7/12/21. +// + +import Foundation +import ViewInspector +import SwiftUI + +@testable import SwiftCurrent_SwiftUI + +// Don't forget you need to make every view you want to test with ViewInspector Inspectable +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension WorkflowView: Inspectable { } + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension Inspection: InspectionEmissary where V: View { } diff --git a/Tests/SwiftCurrent_SwiftUITests/ViewInspector/ViewHostingExtensions.swift b/Tests/SwiftCurrent_SwiftUITests/ViewInspector/ViewHostingExtensions.swift new file mode 100644 index 000000000..dd9a155c0 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/ViewInspector/ViewHostingExtensions.swift @@ -0,0 +1,20 @@ +// +// ViewHostingExtensions.swift +// SwiftCurrent_SwiftUITests +// +// Created by Tyler Thompson on 7/12/21. +// + +import Foundation +import SwiftUI +import ViewInspector + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension ViewHosting { + static func loadView(_ view: V) -> V { + defer { + Self.host(view: view) + } + return view + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/WorkflowItemTests.swift b/Tests/SwiftCurrent_SwiftUITests/WorkflowItemTests.swift new file mode 100644 index 000000000..480babeb9 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/WorkflowItemTests.swift @@ -0,0 +1,34 @@ +// +// WorkflowItemTests.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 7/13/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import XCTest +import SwiftUI + +@testable import SwiftCurrent +@testable import SwiftCurrent_SwiftUI + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class WorkflowItemTests: XCTestCase { + func testWorkflowItemThrowsFatalError_IfPersistenceCannotBeCast() { + XCTAssertThrowsFatalError { + _ = WorkflowItem(FR.self).persistence { _ in + .default + }.metadata.setPersistence(.args(1)) + } + } +} + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +fileprivate struct FR: View, FlowRepresentable { + init(with args: String) { } + weak var _workflowPointer: AnyFlowRepresentable? + + var body: some View { + EmptyView() + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/WorkflowViewModelTests.swift b/Tests/SwiftCurrent_SwiftUITests/WorkflowViewModelTests.swift new file mode 100644 index 000000000..b1397b811 --- /dev/null +++ b/Tests/SwiftCurrent_SwiftUITests/WorkflowViewModelTests.swift @@ -0,0 +1,64 @@ +// +// WorkflowViewModelTests.swift +// SwiftCurrent_SwiftUI +// +// Created by Tyler Thompson on 7/13/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import XCTest + +@testable import SwiftCurrent +@testable import SwiftCurrent_SwiftUI + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +final class WorkflowViewModelTests: XCTestCase { + func testWorkflowViewModelThrowsFatalError_WhenLaunchedWithSomethingOtherThan_AnyFlowRepresentableView() { + let model = WorkflowViewModel() + XCTAssertThrowsFatalError { + model.launch(to: .createForTests(FR.self)) + } + } + + func testWorkflowViewModelThrowsFatalError_WhenProceedingWithSomethingOtherThan_AnyFlowRepresentableView() { + let model = WorkflowViewModel() + XCTAssertThrowsFatalError { + model.proceed(to: .createForTests(FR.self), from: .createForTests(FR.self)) + } + } + + func testWorkflowViewModelThrowsFatalError_WhenBackingUpWithSomethingOtherThan_AnyFlowRepresentableView() { + let model = WorkflowViewModel() + XCTAssertThrowsFatalError { + model.backUp(from: .createForTests(FR.self), to: .createForTests(FR.self)) + } + } + + func testWorkflowViewModelThrowsFatalError_WhenCompletingWithSomethingOtherThan_AnyFlowRepresentableView() { + let model = WorkflowViewModel() + let typedWorkflow = Workflow(FR.self).thenProceed(with: FR.self, flowPersistence: .removedAfterProceeding) + let mock = MockOrchestrationResponder() + let firstLoadedInstance = typedWorkflow.launch(withOrchestrationResponder: mock) + firstLoadedInstance?.value.instance?.proceedInWorkflowStorage?(.none) + XCTAssertThrowsFatalError { + model.complete(AnyWorkflow(typedWorkflow), passedArgs: .none, onFinish: nil) + } + } +} + +fileprivate struct FR: FlowRepresentable { + var _workflowPointer: AnyFlowRepresentable? +} + +extension FlowRepresentableMetadata { + fileprivate static func createForTests(_: FR.Type) -> FlowRepresentableMetadata { + .init(FR.self, flowPersistence: { _ in .default }) + } +} + +extension AnyWorkflow.Element { + fileprivate static func createForTests(_ :FR.Type) -> AnyWorkflow.Element { + return .init(with: .init(metadata: .createForTests(FR.self), + instance: AnyFlowRepresentable(FR.self, args: .none))) + } +}