diff --git a/.github/SwiftCurrentLint/.swiftlint.yml b/.github/SwiftCurrentLint/.swiftlint.yml index 0ff69d95e..f21c7c108 100644 --- a/.github/SwiftCurrentLint/.swiftlint.yml +++ b/.github/SwiftCurrentLint/.swiftlint.yml @@ -171,6 +171,6 @@ type_contents_order: ] file_header: - required_pattern: '// Copyright © 2021 WWT and Tyler Thompson\. All rights reserved\.' + required_pattern: '// Copyright © 202([0-9]) WWT and Tyler Thompson\. All rights reserved\.' reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, codeclimate, junit, html, emoji, markdown, github-actions-logging) diff --git a/.github/fastlane/README.md b/.github/fastlane/README.md index d88086ad5..d314c7bd0 100644 --- a/.github/fastlane/README.md +++ b/.github/fastlane/README.md @@ -1,64 +1,88 @@ fastlane documentation -================ +---- + # Installation Make sure you have the latest version of the Xcode command line tools installed: -``` +```sh xcode-select --install ``` -Install _fastlane_ using -``` -[sudo] gem install fastlane -NV -``` -or alternatively using `brew install fastlane` +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) # Available Actions + ## iOS + ### ios unit_test + +```sh +[bundle exec] fastlane ios unit_test ``` -fastlane ios unit_test -``` + + ### ios build_swiftpm + +```sh +[bundle exec] fastlane ios build_swiftpm ``` -fastlane ios build_swiftpm -``` + + ### ios cocoapods_liblint + +```sh +[bundle exec] fastlane ios cocoapods_liblint ``` -fastlane ios cocoapods_liblint -``` + + ### ios lint + +```sh +[bundle exec] fastlane ios lint ``` -fastlane ios lint -``` + + ### ios lintfix -``` -fastlane ios lintfix + +```sh +[bundle exec] fastlane ios lintfix ``` + + ### ios patch + +```sh +[bundle exec] fastlane ios patch ``` -fastlane ios patch -``` + Release a new version with a patch bump_type + ### ios minor + +```sh +[bundle exec] fastlane ios minor ``` -fastlane ios minor -``` + Release a new version with a minor bump_type + ### ios major + +```sh +[bundle exec] fastlane ios major ``` -fastlane ios major -``` + Release a new version with a major bump_type ---- This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. -More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). -The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/ExampleApps/SwiftUIExample/SwiftUIExampleTests/UIKitInteropTests.swift b/ExampleApps/SwiftUIExample/SwiftUIExampleTests/UIKitInteropTests.swift index e7e1c286c..d4751eb34 100644 --- a/ExampleApps/SwiftUIExample/SwiftUIExampleTests/UIKitInteropTests.swift +++ b/ExampleApps/SwiftUIExample/SwiftUIExampleTests/UIKitInteropTests.swift @@ -274,7 +274,7 @@ final class UIKitInteropTests: XCTestCase, View { extension UIViewController { func loadOnDevice() { // UIUTest's loadForTesting method does not work because it uses the deprecated `keyWindow` property. - let window = UIApplication.shared.windows.first + let window = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.windows.first window?.removeViewsFromRootViewController() window?.rootViewController = self diff --git a/ExampleApps/SwiftUIExample/SwiftUIExampleTests/Views/QRScanningViewTests.swift b/ExampleApps/SwiftUIExample/SwiftUIExampleTests/Views/QRScanningViewTests.swift index 13a129ba1..60b937b4a 100644 --- a/ExampleApps/SwiftUIExample/SwiftUIExampleTests/Views/QRScanningViewTests.swift +++ b/ExampleApps/SwiftUIExample/SwiftUIExampleTests/Views/QRScanningViewTests.swift @@ -28,7 +28,7 @@ final class QRScanningViewTests: XCTestCase { let exp = ViewHosting.loadView(QRScannerFeatureView()).inspection.inspect { viewUnderTest in XCTAssertNoThrow(try viewUnderTest.view(CodeScannerView.self).actualView().completion(.success(code))) XCTAssertEqual(try viewUnderTest.view(CodeScannerView.self).sheet().find(ViewType.Text.self).string(), "SCANNED DATA: \(code)") - XCTAssertNoThrow(try viewUnderTest.view(CodeScannerView.self).sheet().callOnDismiss()) + XCTAssertNoThrow(try viewUnderTest.view(CodeScannerView.self).sheet().dismiss()) XCTAssertThrowsError(try viewUnderTest.view(CodeScannerView.self).sheet()) } wait(for: [exp], timeout: TestConstant.timeout) diff --git a/ExampleApps/UIKitExample/SwiftCurrent_UIKitTests/TestUtilities/TopViewController.swift b/ExampleApps/UIKitExample/SwiftCurrent_UIKitTests/TestUtilities/TopViewController.swift index 945e2e46d..3feece127 100644 --- a/ExampleApps/UIKitExample/SwiftCurrent_UIKitTests/TestUtilities/TopViewController.swift +++ b/ExampleApps/UIKitExample/SwiftCurrent_UIKitTests/TestUtilities/TopViewController.swift @@ -10,7 +10,11 @@ import Foundation import UIKit extension UIApplication { - static func topViewController(of controller: UIViewController? = UIApplication.shared.windows.first?.rootViewController) -> UIViewController? { + var firstWindow: UIWindow? { + connectedScenes.compactMap { $0 as? UIWindowScene }.first?.windows.first + } + + static func topViewController(of controller: UIViewController? = UIApplication.shared.firstWindow?.rootViewController) -> UIViewController? { if let navigationController = controller as? UINavigationController, let visible = navigationController.visibleViewController { return topViewController(of: visible) diff --git a/ExampleApps/UIKitExample/SwiftCurrent_UIKitTests/UIKitConsumerLaunchTests.swift b/ExampleApps/UIKitExample/SwiftCurrent_UIKitTests/UIKitConsumerLaunchTests.swift index a7dafd9a2..523948f0c 100644 --- a/ExampleApps/UIKitExample/SwiftCurrent_UIKitTests/UIKitConsumerLaunchTests.swift +++ b/ExampleApps/UIKitExample/SwiftCurrent_UIKitTests/UIKitConsumerLaunchTests.swift @@ -371,6 +371,37 @@ class UIKitConsumerLaunchTests: XCTestCase { XCTAssertNil((UIApplication.topViewController() as? ExpectedModal)?.navigationController, "You didn't present modally") } + func testKnownPresentationTypes_CanBeDecoded() throws { + final class TestView: UIViewController, FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + } + let validLaunchStyles: [String: LaunchStyle] = [ + "automatic": .default, + "navigationStack": .PresentationType.navigationStack.rawValue, + "modal": .PresentationType.modal.rawValue, + "modal(.automatic)": .PresentationType.modal(.automatic).rawValue, + "modal(.currentContext)": .PresentationType.modal(.currentContext).rawValue, + "modal(.custom)": .PresentationType.modal(.custom).rawValue, + "modal(.formSheet)": .PresentationType.modal(.formSheet).rawValue, + "modal(.fullScreen)": .PresentationType.modal(.fullScreen).rawValue, + "modal(.overCurrentContext)": .PresentationType.modal(.overCurrentContext).rawValue, + "modal(.overFullScreen)": .PresentationType.modal(.overFullScreen).rawValue, + "modal(.popover)": .PresentationType.modal(.popover).rawValue, + "modal(.pageSheet)": .PresentationType.modal(.pageSheet).rawValue, + ] + + let WD: WorkflowDecodable.Type = TestView.self + + try validLaunchStyles.forEach { (key, value) in + XCTAssertIdentical(try TestView.decodeLaunchStyle(named: key), value) + XCTAssertIdentical(try WD.decodeLaunchStyle(named: key), value) + } + + // Metatest, testing we covered all styles + LaunchStyle.PresentationType.allCases.forEach { presentationType in + XCTAssert(validLaunchStyles.values.contains { $0 === presentationType.rawValue }, "dictionary of validLaunchStyles did not contain one for \(presentationType)") + } + } } extension UIKitConsumerLaunchTests { diff --git a/Sources/SwiftCurrent/CollectionExtensions.swift b/Sources/SwiftCurrent/Extensions/CollectionExtensions.swift similarity index 100% rename from Sources/SwiftCurrent/CollectionExtensions.swift rename to Sources/SwiftCurrent/Extensions/CollectionExtensions.swift diff --git a/Sources/SwiftCurrent/Extensions/DecoderExtensions.swift b/Sources/SwiftCurrent/Extensions/DecoderExtensions.swift new file mode 100644 index 000000000..f59ae20d9 --- /dev/null +++ b/Sources/SwiftCurrent/Extensions/DecoderExtensions.swift @@ -0,0 +1,53 @@ +// swiftlint:disable:this file_name +// DecoderExtensions.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 1/14/22. +// Copyright © 2022 WWT and Tyler Thompson. All rights reserved. +// + +import Foundation + +extension JSONDecoder { + struct WorkflowJSONSpec: Decodable { + let schemaVersion: AnyWorkflow.JSONSchemaVersion + let sequence: [Sequence] + + struct Sequence: Decodable { + let flowRepresentableName: String + let launchStyle: String? + let flowPersistence: String? + } + } + + /// Convenience method to decode an ``AnyWorkflow`` from Data. + public func decodeWorkflow(withAggregator aggregator: FlowRepresentableAggregator, from data: Data) throws -> AnyWorkflow { + try AnyWorkflow(spec: decode(WorkflowJSONSpec.self, from: data), aggregator: aggregator) + } +} + +extension AnyWorkflow { + convenience init(spec: JSONDecoder.WorkflowJSONSpec, aggregator: FlowRepresentableAggregator) throws { + let typeMap = aggregator.typeMap + self.init(Workflow()) + try spec.sequence.forEach { + if let type = typeMap[$0.flowRepresentableName] { + let launchStyle = try getLaunchStyle(decodable: type, from: $0) + let flowPersistence = try getFlowPersistence(decodable: type, from: $0) + append(type.metadataFactory(launchStyle: launchStyle) { _ in flowPersistence }) + } else { + throw AnyWorkflow.DecodingError.invalidFlowRepresentable($0.flowRepresentableName) + } + } + } + + private func getLaunchStyle(decodable: WorkflowDecodable.Type, from sequence: JSONDecoder.WorkflowJSONSpec.Sequence) throws -> LaunchStyle { + guard let launchStyleName = sequence.launchStyle else { return .default } + return try decodable.decodeLaunchStyle(named: launchStyleName) + } + + private func getFlowPersistence(decodable: WorkflowDecodable.Type, from sequence: JSONDecoder.WorkflowJSONSpec.Sequence) throws -> FlowPersistence { + guard let flowPersistenceName = sequence.flowPersistence else { return .default } + return try decodable.decodeFlowPersistence(named: flowPersistenceName) + } +} diff --git a/Sources/SwiftCurrent/PropertyWrappers/DecodeWorkflow.swift b/Sources/SwiftCurrent/PropertyWrappers/DecodeWorkflow.swift new file mode 100644 index 000000000..e1ac1674b --- /dev/null +++ b/Sources/SwiftCurrent/PropertyWrappers/DecodeWorkflow.swift @@ -0,0 +1,24 @@ +// +// DecodeWorkflow.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 1/18/22. +// Copyright © 2022 WWT and Tyler Thompson. All rights reserved. +// + +import Foundation + +@propertyWrapper +public struct DecodeWorkflow: Decodable { + public var wrappedValue: AnyWorkflow + + public init(wrappedValue: AnyWorkflow = .empty, aggregator: Aggregator.Type) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let spec = try container.decode(JSONDecoder.WorkflowJSONSpec.self) + wrappedValue = try AnyWorkflow(spec: spec, aggregator: Aggregator()) + } +} diff --git a/Sources/SwiftCurrent/Protocols/FlowRepresentableAggregator.swift b/Sources/SwiftCurrent/Protocols/FlowRepresentableAggregator.swift new file mode 100644 index 000000000..6e8b00eb9 --- /dev/null +++ b/Sources/SwiftCurrent/Protocols/FlowRepresentableAggregator.swift @@ -0,0 +1,37 @@ +// +// FlowRepresentableAggregator.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 1/14/22. +// Copyright © 2022 WWT and Tyler Thompson. All rights reserved. +// + +/** + Aggregates ``WorkflowDecodable`` types for decoding. + */ +public protocol FlowRepresentableAggregator { + /// A list of ``WorkflowDecodable`` types to use when decoding a workflow + var types: [WorkflowDecodable.Type] { get } + + /** + A dictionary representation of flowRepresentableName to ``WorkflowDecodable`` + - NOTE: This is auto-generated unless you override the behavior + */ + var typeMap: [String: WorkflowDecodable.Type] { get } + + /** + Creates a FlowRepresentableAggregator with default types. + - NOTE: Convenience methods use this empty initializer; alternative public methods exist for an already initialized aggregator. + */ + init() +} + +extension FlowRepresentableAggregator { + /** + A dictionary representation of flowRepresentableName to ``WorkflowDecodable`` + - NOTE: This is auto-generated unless you override the behavior + */ + public var typeMap: [String: WorkflowDecodable.Type] { + types.reduce(into: [:]) { $0[$1.flowRepresentableName] = $1 } + } +} diff --git a/Sources/SwiftCurrent/Protocols/FlowRepresentableMetadataDescriber.swift b/Sources/SwiftCurrent/Protocols/FlowRepresentableMetadataDescriber.swift deleted file mode 100644 index a0f7b46d0..000000000 --- a/Sources/SwiftCurrent/Protocols/FlowRepresentableMetadataDescriber.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// FlowRepresentableMetadataDescriber.swift -// SwiftCurrent -// -// Created by Richard Gist on 12/7/21. -// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. -// - -/// Aspects of the described ``FlowRepresentable`` needed to dynamically generate metadata from the Workflow Data Scheme. -public protocol FlowRepresentableMetadataDescriber { - /// The name of the ``FlowRepresentable`` as used in the Workflow Data Scheme - static var flowRepresentableName: String { get } - - /// Creates a new instance of ``FlowRepresentableMetadata`` - static func metadataFactory() -> FlowRepresentableMetadata -} - -// Provides the implementation for the protocol without immediately conforming FlowRepresentable -// See FlowRepresentableMetadataDescriberConsumerTests for reasons. -extension FlowRepresentable where Self: FlowRepresentableMetadataDescriber { - /// The name of the ``FlowRepresentable`` as used in the Workflow Data Scheme - public static var flowRepresentableName: String { String(describing: Self.self) } - - /// Creates a new instance of ``FlowRepresentableMetadata`` - public static func metadataFactory() -> FlowRepresentableMetadata { - FlowRepresentableMetadata(Self.self) - } -} diff --git a/Sources/SwiftCurrent/Protocols/WorkflowDecodable.swift b/Sources/SwiftCurrent/Protocols/WorkflowDecodable.swift new file mode 100644 index 000000000..62a08c78e --- /dev/null +++ b/Sources/SwiftCurrent/Protocols/WorkflowDecodable.swift @@ -0,0 +1,52 @@ +// +// FlowRepresentableMetadataDescriber.swift +// SwiftCurrent +// +// Created by Richard Gist on 12/7/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +/// Aspects of the described ``FlowRepresentable`` needed to dynamically generate metadata from the Workflow Data Scheme. +public protocol WorkflowDecodable { + /// The name of the ``FlowRepresentable`` as used in the Workflow Data Scheme. + static var flowRepresentableName: String { get } + + /// Creates a new instance of ``FlowRepresentableMetadata``. + static func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata + + /// Decodes a ``LaunchStyle`` from a string. + static func decodeLaunchStyle(named name: String) throws -> LaunchStyle + + /// Decodes a ``FlowPersistence`` from a string. + static func decodeFlowPersistence(named name: String) throws -> FlowPersistence +} + +extension WorkflowDecodable { + /// Decodes a ``LaunchStyle`` from a string. + public static func decodeLaunchStyle(named name: String) throws -> LaunchStyle { + throw AnyWorkflow.DecodingError.invalidLaunchStyle(name) + } + + /// Decodes a ``FlowPersistence`` from a string. + public static func decodeFlowPersistence(named name: String) throws -> FlowPersistence { + switch name.lowercased() { + case "persistwhenskipped": return .persistWhenSkipped + case "removedafterproceeding": return .removedAfterProceeding + default: throw AnyWorkflow.DecodingError.invalidFlowPersistence(name) + } + } +} + +// Provides the implementation for the protocol without immediately conforming FlowRepresentable +// See WorkflowDecodableConsumerTests for reasons. +extension FlowRepresentable where Self: WorkflowDecodable { + /// The name of the ``FlowRepresentable`` as used in the Workflow Data Scheme + public static var flowRepresentableName: String { String(describing: Self.self) } + + /// Creates a new instance of ``FlowRepresentableMetadata`` + public static func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata { + FlowRepresentableMetadata(Self.self, launchStyle: launchStyle, flowPersistence: flowPersistence) + } +} diff --git a/Sources/SwiftCurrent/TypeErased/AnyWorkflow.swift b/Sources/SwiftCurrent/TypeErased/AnyWorkflow.swift index ea6f49eae..98b1bb950 100644 --- a/Sources/SwiftCurrent/TypeErased/AnyWorkflow.swift +++ b/Sources/SwiftCurrent/TypeErased/AnyWorkflow.swift @@ -87,6 +87,44 @@ extension AnyWorkflow: Sequence { } } +extension AnyWorkflow { + /// Errors that can occur during decoding + public enum DecodingError: Error, Equatable { + /// The ``WorkflowDecodable`` could not be found in supplied aggregator. + /// AssociatedType: invalid FlowRepresentable name + case invalidFlowRepresentable(String) + /// The ``LaunchStyle`` could not be found. + /// AssociatedType: invalid LaunchStyle + case invalidLaunchStyle(String) + /// The ``FlowPersistence`` could not be found. + /// AssociatedType: invalid LaunchStyle + case invalidFlowPersistence(String) + + public static func == (lhs: DecodingError, rhs: DecodingError) -> Bool { + switch (lhs, rhs) { + case (.invalidFlowRepresentable(let lhsName), .invalidFlowRepresentable(let rhsName)): + return lhsName == rhsName + case (.invalidLaunchStyle(let lhsName), .invalidLaunchStyle(let rhsName)): + return lhsName == rhsName + case (.invalidFlowPersistence(let lhsName), .invalidFlowPersistence(let rhsName)): + return lhsName == rhsName + default: return false + } + } + } +} + +extension AnyWorkflow { + /// Latest supported schema version + public static var jsonSchemaVersion: JSONSchemaVersion = .v0_0_1 + + /// Codified list of supported JSON schema versions by this library + public enum JSONSchemaVersion: String, Decodable { + /// JSON Schema v0.0.1 + case v0_0_1 = "v0.0.1" + } +} + extension AnyWorkflow { /// A type that represents either a type erased value or no value. public enum PassedArgs { diff --git a/Sources/SwiftCurrent_SwiftUI/Extensions/FlowRepresentableMetadataDescriberExtensions.swift b/Sources/SwiftCurrent_SwiftUI/Extensions/FlowRepresentableMetadataDescriberExtensions.swift deleted file mode 100644 index 8f0241f99..000000000 --- a/Sources/SwiftCurrent_SwiftUI/Extensions/FlowRepresentableMetadataDescriberExtensions.swift +++ /dev/null @@ -1,18 +0,0 @@ -// swiftlint:disable:this file_name -// FlowRepresentableMetadataDescriberExtensions.swift -// SwiftCurrent_SwiftUI -// -// Created by Richard Gist on 12/15/21. -// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. -// - -import SwiftUI -import SwiftCurrent - -@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) -extension View where Self: FlowRepresentable & FlowRepresentableMetadataDescriber { - /// Creates a new instance of ``FlowRepresentableMetadata`` - public static func metadataFactory() -> FlowRepresentableMetadata { - ExtendedFlowRepresentableMetadata(flowRepresentableType: Self.self) - } -} diff --git a/Sources/SwiftCurrent_SwiftUI/Extensions/WorkflowDecodableExtensions.swift b/Sources/SwiftCurrent_SwiftUI/Extensions/WorkflowDecodableExtensions.swift new file mode 100644 index 000000000..806cc053c --- /dev/null +++ b/Sources/SwiftCurrent_SwiftUI/Extensions/WorkflowDecodableExtensions.swift @@ -0,0 +1,30 @@ +// swiftlint:disable:this file_name +// FlowRepresentableMetadataDescriberExtensions.swift +// SwiftCurrent_SwiftUI +// +// Created by Richard Gist on 12/15/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftUI +import SwiftCurrent + +@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) +extension WorkflowDecodable where Self: FlowRepresentable & View { + /// Creates a new instance of ``FlowRepresentableMetadata`` + public static func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata { + ExtendedFlowRepresentableMetadata(flowRepresentableType: Self.self, launchStyle: launchStyle, flowPersistence: flowPersistence) + } + + /// Decodes a ``LaunchStyle`` from a string. + public static func decodeLaunchStyle(named name: String) throws -> LaunchStyle { + switch name.lowercased() { + case "viewswapping": return .default + case "modal": return ._swiftUI_modal + case "modal(.fullscreen)": return ._swiftUI_modal_fullscreen + case "navigationlink": return ._swiftUI_navigationLink + default: throw AnyWorkflow.DecodingError.invalidLaunchStyle(name) + } + } +} diff --git a/Sources/SwiftCurrent_SwiftUI/Extensions/WorkflowExtensions.swift b/Sources/SwiftCurrent_SwiftUI/Extensions/WorkflowExtensions.swift deleted file mode 100644 index 7f5e41055..000000000 --- a/Sources/SwiftCurrent_SwiftUI/Extensions/WorkflowExtensions.swift +++ /dev/null @@ -1,258 +0,0 @@ -// 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 { - /* - TODO: The below initializers are left public to facilitate testing data-driven workflows. - They should be made private when data driven progress allows. - */ - - /** - Creates a `Workflow` with a `FlowRepresentable`. - - Parameter type: a reference to the first `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a `FlowPersistence` representing how this item in the workflow should persist. - */ - public convenience init(_ type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping @autoclosure () -> FlowPersistence = .default) { - self.init(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { _ in flowPersistence() }) - } - - /** - Creates a `Workflow` with a `FlowRepresentable`. - - Parameter type: a reference to the first `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a `FlowPersistence` representing how this item in the workflow should persist. - */ - public convenience init(_ type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping (F.WorkflowInput) -> FlowPersistence) { - self.init(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { data in - guard case.args(let extracted) = data, - let cast = extracted as? F.WorkflowInput else { return .default } - - return flowPersistence(cast) - }) - } - - /** - Creates a `Workflow` with a `FlowRepresentable`. - - Parameter type: a reference to the first `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a closure returning a `FlowPersistence` representing how this item in the workflow should persist. - */ - public convenience init(_ type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping () -> FlowPersistence) where F.WorkflowInput == Never { - self.init(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { _ in flowPersistence() }) - } - - /// 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() - } - - // TODO: Remove the following untested functions when data-driven is more mature - - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a closure returning a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: FR.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping (FR.WorkflowInput) -> FlowPersistence) -> Workflow where F.WorkflowOutput == FR.WorkflowInput { - let workflow = Workflow(first) - workflow.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { data in - guard case.args(let extracted) = data, - let cast = extracted as? FR.WorkflowInput else { return .default } - - return flowPersistence(cast) - }) - return workflow - } - - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a closure returning a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping (F.WorkflowInput) -> FlowPersistence) -> Workflow where F.WorkflowInput == AnyWorkflow.PassedArgs { - let workflow = Workflow(first) - workflow.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { data in - guard case.args(let extracted) = data, - let cast = extracted as? F.WorkflowInput else { return .default } - - return flowPersistence(cast) - }) - return workflow - } -} - -@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) -extension Workflow where F: FlowRepresentable & View, F.WorkflowOutput == Never { - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a closure returning a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping () -> FlowPersistence) -> Workflow where F.WorkflowInput == AnyWorkflow.PassedArgs { - let workflow = Workflow(first) - workflow.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { _ in flowPersistence() }) - return workflow - } - - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a closure returning a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping () -> FlowPersistence) -> Workflow where F.WorkflowInput == Never { - let workflow = Workflow(first) - workflow.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { _ in flowPersistence() }) - return workflow - } - - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: FR.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping @autoclosure () -> FlowPersistence = .default) -> Workflow where FR.WorkflowInput == AnyWorkflow.PassedArgs { - let wf = Workflow(first) - wf.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { _ in flowPersistence() }) - return wf - } -} - -@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) -extension Workflow where F: FlowRepresentable & View, F.WorkflowOutput == AnyWorkflow.PassedArgs { - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a closure returning a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping () -> FlowPersistence) -> Workflow where F.WorkflowInput == AnyWorkflow.PassedArgs { - let workflow = Workflow(first) - workflow.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { _ in flowPersistence() }) - return workflow - } - - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a closure returning a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping (F.WorkflowInput) -> FlowPersistence) -> Workflow where F.WorkflowInput == AnyWorkflow.PassedArgs { - let workflow = Workflow(first) - workflow.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { data in - guard case.args(let extracted) = data, - let cast = extracted as? F.WorkflowInput else { return .default } - - return flowPersistence(cast) - }) - return workflow - } - - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a closure returning a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping () -> FlowPersistence) -> Workflow where F.WorkflowInput == Never { - let workflow = Workflow(first) - workflow.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { _ in flowPersistence() }) - return workflow - } - - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a closure returning a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: F.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping (F.WorkflowInput) -> FlowPersistence) -> Workflow { - let workflow = Workflow(first) - workflow.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { data in - guard case.args(let extracted) = data, - let cast = extracted as? F.WorkflowInput else { return .default } - - return flowPersistence(cast) - }) - return workflow - } -} - -@available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) -extension Workflow where F.WorkflowOutput == Never { - /** - Adds an item to the workflow; enforces the `FlowRepresentable.WorkflowOutput` of the previous item matches the `FlowRepresentable.WorkflowInput` of this item. - - Parameter type: a reference to the next `FlowRepresentable`'s concrete type in the workflow. - - Parameter launchStyle: the `LaunchStyle` the `FlowRepresentable` should use while it's part of this workflow. - - Parameter flowPersistence: a `FlowPersistence` representing how this item in the workflow should persist. - - Returns: a new workflow with the additional `FlowRepresentable` item. - */ - public func thenProceed(with type: FR.Type, - launchStyle: LaunchStyle = .default, - flowPersistence: @escaping @autoclosure () -> FlowPersistence = .default) -> Workflow where FR.WorkflowInput == Never { - let wf = Workflow(first) - wf.append(ExtendedFlowRepresentableMetadata(flowRepresentableType: type, - launchStyle: launchStyle) { _ in flowPersistence() }) - return wf - } -} diff --git a/Sources/SwiftCurrent_SwiftUI/Views/WorkflowLauncher.swift b/Sources/SwiftCurrent_SwiftUI/Views/WorkflowLauncher.swift index 80da53f58..6da710ad9 100644 --- a/Sources/SwiftCurrent_SwiftUI/Views/WorkflowLauncher.swift +++ b/Sources/SwiftCurrent_SwiftUI/Views/WorkflowLauncher.swift @@ -77,23 +77,14 @@ public struct WorkflowLauncher: View { .onReceive(inspection.notice) { inspection.visit(self, $0) } } - /** - Creates a base for proceeding with a `WorkflowItem`. - - Parameter isLaunched: binding that controls launching the underlying `Workflow`. - - Parameter workflow: workflow to be launched; must contain `FlowRepresentable`s of type `View` - */ - public init(isLaunched: Binding, workflow: Workflow) where Content == AnyWorkflowItem { - self.init(isLaunched: isLaunched, startingArgs: .none, workflow: AnyWorkflow(workflow)) - } - /** Creates a base for proceeding with a `WorkflowItem`. - Parameter isLaunched: binding that controls launching the underlying `Workflow`. - Parameter startingArgs: arguments passed to the first loaded `FlowRepresentable` in the underlying `Workflow`. - Parameter workflow: workflow to be launched; must contain `FlowRepresentable`s of type `View` */ - public init(isLaunched: Binding, startingArgs: AnyWorkflow.PassedArgs, workflow: Workflow) where Content == AnyWorkflowItem { - self.init(isLaunched: isLaunched, startingArgs: startingArgs, workflow: AnyWorkflow(workflow)) + public init(isLaunched: Binding, startingArgs: A, workflow: AnyWorkflow) where Content == AnyWorkflowItem { + self.init(isLaunched: isLaunched, startingArgs: .args(startingArgs), workflow: workflow) } /** @@ -102,13 +93,9 @@ public struct WorkflowLauncher: View { - Parameter startingArgs: arguments passed to the first loaded `FlowRepresentable` in the underlying `Workflow`. - Parameter workflow: workflow to be launched; must contain `FlowRepresentable`s of type `View` */ - public init(isLaunched: Binding, startingArgs: A, workflow: Workflow) where Content == AnyWorkflowItem { - self.init(isLaunched: isLaunched, startingArgs: .args(startingArgs), workflow: AnyWorkflow(workflow)) - } - - private init(isLaunched: Binding, startingArgs: AnyWorkflow.PassedArgs, workflow: AnyWorkflow) where Content == AnyWorkflowItem { + public init(isLaunched: Binding, startingArgs: AnyWorkflow.PassedArgs = .none, workflow: AnyWorkflow) where Content == AnyWorkflowItem { workflow.forEach { - assert($0.value.metadata is ExtendedFlowRepresentableMetadata) + assert($0.value.metadata is ExtendedFlowRepresentableMetadata, "It is possible the workflow was constructed incorrectly. This represents an internal error, please file a bug at https://github.com/wwt/SwiftCurrent/issues") // swiftlint:disable:this line_length } _isLaunched = isLaunched let model = WorkflowViewModel(isLaunched: isLaunched, launchArgs: startingArgs) diff --git a/Sources/SwiftCurrent_Testing/TestRegistry.swift b/Sources/SwiftCurrent_Testing/TestRegistry.swift new file mode 100644 index 000000000..623710c33 --- /dev/null +++ b/Sources/SwiftCurrent_Testing/TestRegistry.swift @@ -0,0 +1,18 @@ +// +// TestRegistry.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 1/14/22. +// Copyright © 2022 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftCurrent +public struct TestRegistry: FlowRepresentableAggregator { + public init() { types = [] } + + public var types: [WorkflowDecodable.Type] + + public init(types: [WorkflowDecodable.Type]) { + self.types = types + } +} diff --git a/Sources/SwiftCurrent_UIKit/Extensions/LaunchStyleAdditions.swift b/Sources/SwiftCurrent_UIKit/Extensions/LaunchStyleAdditions.swift index 458cb345a..e9d27ac40 100644 --- a/Sources/SwiftCurrent_UIKit/Extensions/LaunchStyleAdditions.swift +++ b/Sources/SwiftCurrent_UIKit/Extensions/LaunchStyleAdditions.swift @@ -29,7 +29,22 @@ extension LaunchStyle { extension LaunchStyle { /// A type indicating how a `FlowRepresentable` should be presented. - public enum PresentationType: RawRepresentable { + public enum PresentationType: RawRepresentable, CaseIterable { + public static var allCases: [LaunchStyle.PresentationType] = [ + .default, + .navigationStack, + .modal(.default), + .modal(.fullScreen), + .modal(.pageSheet), + .modal(.formSheet), + .modal(.currentContext), + .modal(.custom), + .modal(.overFullScreen), + .modal(.overCurrentContext), + .modal(.popover), + .modal(.automatic) + ] + /** Indicates a `FlowRepresentable` can be launched contextually. - Important: If there's already a navigation stack, it will be used; otherwise views will present modally. diff --git a/Sources/SwiftCurrent_UIKit/Extensions/WorkflowDecodableExtensions_UIKit.swift b/Sources/SwiftCurrent_UIKit/Extensions/WorkflowDecodableExtensions_UIKit.swift new file mode 100644 index 000000000..2849e14e8 --- /dev/null +++ b/Sources/SwiftCurrent_UIKit/Extensions/WorkflowDecodableExtensions_UIKit.swift @@ -0,0 +1,31 @@ +// swiftlint:disable:this file_name +// WorkflowDecodableExtensions_UIKit.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 1/18/22. +// Copyright © 2022 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftCurrent +import UIKit + +extension WorkflowDecodable where Self: UIViewController & FlowRepresentable { + /// Decodes a ``LaunchStyle`` from a string. + public static func decodeLaunchStyle(named name: String) throws -> LaunchStyle { + switch name.lowercased() { + case "automatic": return .default + case "navigationstack": return ._navigationStack + case "modal": return ._modal + case "modal(.automatic)": return ._modal_automatic + case "modal(.currentcontext)": return ._modal_currentContext + case "modal(.custom)": return ._modal_custom + case "modal(.formsheet)": return ._modal_formSheet + case "modal(.fullscreen)": return ._modal_fullscreen + case "modal(.overcurrentcontext)": return ._modal_overCurrentContext + case "modal(.overfullscreen)": return ._modal_overFullScreen + case "modal(.popover)": return ._modal_popover + case "modal(.pagesheet)": return ._modal_pageSheet + default: throw AnyWorkflow.DecodingError.invalidLaunchStyle(name) + } + } +} diff --git a/Tests/SwiftCurrentTests/JsonSpecificationTests.swift b/Tests/SwiftCurrentTests/JsonSpecificationTests.swift new file mode 100644 index 000000000..29ac16de4 --- /dev/null +++ b/Tests/SwiftCurrentTests/JsonSpecificationTests.swift @@ -0,0 +1,389 @@ +// +// JsonSpecificationTests.swift +// SwiftCurrent +// +// Created by Tyler Thompson on 1/14/22. +// Copyright © 2022 WWT and Tyler Thompson. All rights reserved. +// + +import XCTest +import SwiftCurrent +import SwiftCurrent_Testing + +final class JsonSpecificationTests: XCTestCase { + func testWorkflowCanBeInstantiatedFromJSON() throws { + struct FR1: FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + } + + final class FR2: FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + } + + let registry = TestRegistry(types: [ + FR1.self, + FR2.self + ]) + + let wf = try JSONDecoder().decodeWorkflow(withAggregator: registry, from: validWorkflowJSON) + XCTAssertEqual(wf.first?.value.metadata.flowRepresentableTypeDescriptor, FR1.flowRepresentableName) + XCTAssertIdentical(wf.first?.value.metadata.launchStyle, LaunchStyle.default) + XCTAssertEqual(wf.first?.next?.value.metadata.flowRepresentableTypeDescriptor, FR2.flowRepresentableName) + XCTAssertIdentical(wf.first?.next?.value.metadata.launchStyle, LaunchStyle.default) + XCTAssertNil(wf.first?.next?.next) + } + + func testWorkflowCanBeInstantiatedFromJSON_WithSubclasses() throws { + class FR1: FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + required init() { } + } + + class FR2: FR1 { } + + let registry = TestRegistry(types: [ + FR1.self, + FR2.self + ]) + + let wf = try JSONDecoder().decodeWorkflow(withAggregator: registry, from: validWorkflowJSON) + XCTAssertEqual(wf.first?.value.metadata.flowRepresentableTypeDescriptor, FR1.flowRepresentableName) + XCTAssertIdentical(wf.first?.value.metadata.launchStyle, LaunchStyle.default) + XCTAssertEqual(wf.first?.next?.value.metadata.flowRepresentableTypeDescriptor, FR2.flowRepresentableName) + XCTAssertIdentical(wf.first?.next?.value.metadata.launchStyle, LaunchStyle.default) + XCTAssertNil(wf.first?.next?.next) + } + + func testWorkflowThrowsAnErrorWhenGivenMalformedJSON() { + XCTAssertThrowsError(try JSONDecoder().decodeWorkflow(withAggregator: TestRegistry(types: []), from: malformedWorkflowJSON)) + } + + func testWorkflowThrowsAnErrorWhenItCannotMatchTheCorrespondingSequenceType() { + XCTAssertThrowsError(try JSONDecoder().decodeWorkflow(withAggregator: TestRegistry(types: []), from: validWorkflowJSON)) { error in + XCTAssertEqual((error as? AnyWorkflow.DecodingError), .invalidFlowRepresentable("FR1")) + } + } + + func testWorkflowCanBeDecodedAlongWithAnyOtherJSONBlob() throws { + struct FR1: FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + } + + struct CustomRegistry: FlowRepresentableAggregator { + let types: [WorkflowDecodable.Type] = [ FR1.self ] + } + + struct CustomDecodableObject: Decodable { + let someAdditionalThing: Int + @DecodeWorkflow(aggregator: CustomRegistry.self) var workflow: AnyWorkflow + } + + let json = try XCTUnwrap(""" + { + "someAdditionalThing" : 24, + "workflow" : { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence" : [ { "flowRepresentableName" : "FR1" } ] + } + } + """.data(using: .utf8)) + + let object = try JSONDecoder().decode(CustomDecodableObject.self, from: json) + let wf = object.workflow + XCTAssertEqual(object.someAdditionalThing, 24) + XCTAssertEqual(wf.first?.value.metadata.flowRepresentableTypeDescriptor, FR1.flowRepresentableName) + XCTAssertNil(wf.first?.next) + } + + func testCreatingWorkflowWithLaunchStyle() throws { + struct FR1: FlowRepresentable, WorkflowDecodable, TestLookup { + weak var _workflowPointer: AnyFlowRepresentable? + } + + let json = try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence": [ + { + "flowRepresentableName": "FR1", + "launchStyle": "testStyle" + } + ] + } + """.data(using: .utf8)) + + let registry = TestRegistry(types: [ FR1.self ]) + + let wf = try JSONDecoder().decodeWorkflow(withAggregator: registry, from: json) + XCTAssertEqual(wf.first?.value.metadata.flowRepresentableTypeDescriptor, FR1.flowRepresentableName) + XCTAssertIdentical(wf.first?.value.metadata.launchStyle, LaunchStyle.testStyle) + } + + func testCreatingWorkflowWithInvalidLaunchStyleOnExtendedType_Rethrows() throws { + struct FR1: FlowRepresentable, WorkflowDecodable, TestLookup { + weak var _workflowPointer: AnyFlowRepresentable? + } + + let json = try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence": [ + { + "flowRepresentableName": "FR1", + "launchStyle": "testStylez" + } + ] + } + """.data(using: .utf8)) + + let registry = TestRegistry(types: [ FR1.self ]) + + XCTAssertThrowsError(try JSONDecoder().decodeWorkflow(withAggregator: registry, from: json)) { error in + XCTAssertEqual((error as? URLError), URLError(.badURL)) + } + } + + func testCreatingWorkflowWithInvalidLaunchStyle_ThrowsError() throws { + struct FR1: FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + } + + let registry = TestRegistry(types: [ FR1.self ]) + + let json = try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence": [ + { + "flowRepresentableName": "FR1", + "launchStyle": "testStyle" + } + ] + } + """.data(using: .utf8)) + + XCTAssertThrowsError(try JSONDecoder().decodeWorkflow(withAggregator: registry, from: json)) { error in + XCTAssertEqual((error as? AnyWorkflow.DecodingError), .invalidLaunchStyle("testStyle")) + } + } + + func testCreatingWorkflowWithClassesAndSubclasses_AndJSONLaunchStyles_Works() throws { + class FR1: FlowRepresentable, WorkflowDecodable, TestLookup { + weak var _workflowPointer: AnyFlowRepresentable? + required init() { } + } + + class FR2: FR1 { } + + let registry = TestRegistry(types: [ FR1.self, FR2.self ]) + + let json = try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence": [ + { + "flowRepresentableName": "FR1", + "launchStyle": "testStyle" + }, + { + "flowRepresentableName": "FR2", + "launchStyle": "testStyle" + }, + { + "flowRepresentableName": "FR2" + } + ] + } + """.data(using: .utf8)) + + let wf = try JSONDecoder().decodeWorkflow(withAggregator: registry, from: json) + XCTAssertEqual(wf.first?.value.metadata.flowRepresentableTypeDescriptor, FR1.flowRepresentableName) + XCTAssertIdentical(wf.first?.value.metadata.launchStyle, LaunchStyle.testStyle) + XCTAssertEqual(wf.first?.next?.value.metadata.flowRepresentableTypeDescriptor, FR2.flowRepresentableName) + XCTAssertIdentical(wf.first?.next?.value.metadata.launchStyle, LaunchStyle.testStyle) + XCTAssertEqual(wf.first?.next?.next?.value.metadata.flowRepresentableTypeDescriptor, FR2.flowRepresentableName) + XCTAssertIdentical(wf.first?.next?.next?.value.metadata.launchStyle, LaunchStyle.default) + } + + func testCreatingWorkflowWithFlowPersistence() throws { + struct FR1: FlowRepresentable, WorkflowDecodable, TestLookup { + weak var _workflowPointer: AnyFlowRepresentable? + } + + let json = try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence": [ + { + "flowRepresentableName": "FR1", + "flowPersistence": "testPersistence" + } + ] + } + """.data(using: .utf8)) + + let registry = TestRegistry(types: [ FR1.self ]) + + let wf = try JSONDecoder().decodeWorkflow(withAggregator: registry, from: json) + let or = MockOrchestrationResponder() + + XCTAssertEqual(wf.first?.value.metadata.flowRepresentableTypeDescriptor, FR1.flowRepresentableName) + + wf.launch(withOrchestrationResponder: or, passedArgs: .none) + XCTAssertIdentical(or.lastTo?.value.metadata.persistence, FlowPersistence.testPersistence) + } + + func testCreatingWorkflowWithInvalidFlowPersistenceOnExtendedType_Rethrows() throws { + struct FR1: FlowRepresentable, WorkflowDecodable, TestLookup { + weak var _workflowPointer: AnyFlowRepresentable? + } + + let json = try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence": [ + { + "flowRepresentableName": "FR1", + "flowPersistence": "testPersistencez" + } + ] + } + """.data(using: .utf8)) + + let registry = TestRegistry(types: [ FR1.self ]) + + XCTAssertThrowsError(try JSONDecoder().decodeWorkflow(withAggregator: registry, from: json)) { error in + XCTAssertEqual((error as? URLError), URLError(.badURL)) + } + } + + func testCreatingWorkflowWithClassesAndSubclasses_AndJSONFlowPersistences_Works() throws { + class FR1: FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + required init() { } + } + + class FR2: FR1 { } + + let registry = TestRegistry(types: [ FR1.self, FR2.self ]) + + let json = try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence": [ + { + "flowRepresentableName": "FR1", + "flowPersistence": "persistWhenSkipped" + }, + { + "flowRepresentableName": "FR2", + "flowPersistence": "removedAfterProceeding" + }, + { + "flowRepresentableName": "FR2" + } + ] + } + """.data(using: .utf8)) + let wf = try JSONDecoder().decodeWorkflow(withAggregator: registry, from: json) + let orchestrationResponder = MockOrchestrationResponder() + + XCTAssertEqual(wf.first?.value.metadata.flowRepresentableTypeDescriptor, FR1.flowRepresentableName) + XCTAssertEqual(wf.first?.next?.value.metadata.flowRepresentableTypeDescriptor, FR2.flowRepresentableName) + XCTAssertEqual(wf.first?.next?.next?.value.metadata.flowRepresentableTypeDescriptor, FR2.flowRepresentableName) + + wf.launch(withOrchestrationResponder: orchestrationResponder, passedArgs: .none) + XCTAssertIdentical(orchestrationResponder.lastTo?.value.metadata.persistence, FlowPersistence.persistWhenSkipped) + + (orchestrationResponder.lastTo?.value.instance?.underlyingInstance as? FR1)?.proceedInWorkflow() + XCTAssertIdentical(orchestrationResponder.lastTo?.value.metadata.persistence, FlowPersistence.removedAfterProceeding) + + (orchestrationResponder.lastTo?.value.instance?.underlyingInstance as? FR2)?.proceedInWorkflow() + XCTAssertIdentical(orchestrationResponder.lastTo?.value.metadata.persistence, FlowPersistence.default) + } + + func testCreatingWorkflowWithInvalidFlowPersistence_ThrowsError() throws { + struct FR1: FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + } + + let registry = TestRegistry(types: [ FR1.self ]) + + let json = try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence": [ + { + "flowRepresentableName": "FR1", + "flowPersistence": "testPersistence" + } + ] + } + """.data(using: .utf8)) + + XCTAssertThrowsError(try JSONDecoder().decodeWorkflow(withAggregator: registry, from: json)) { error in + XCTAssertEqual((error as? AnyWorkflow.DecodingError), .invalidFlowPersistence("testPersistence")) + } + } +} + +public protocol TestLookup { } // For example: View + +extension WorkflowDecodable where Self: TestLookup { + public static func decodeLaunchStyle(named name: String) throws -> LaunchStyle { + switch name.lowercased() { + case "teststyle": return LaunchStyle.testStyle + default: + throw URLError.init(.badURL) + } + } +} + +extension WorkflowDecodable where Self: TestLookup { + public static func decodeFlowPersistence(named name: String) throws -> FlowPersistence { + switch name.lowercased() { + case "testpersistence": return FlowPersistence.testPersistence + default: + throw URLError.init(.badURL) + } + } +} + +extension LaunchStyle { + static var testStyle = LaunchStyle.new +} + +extension FlowPersistence { + static var testPersistence = FlowPersistence.new +} + +extension JsonSpecificationTests { + fileprivate var validWorkflowJSON: Data { + get throws { + try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence": [ + { + "flowRepresentableName": "FR1" + }, + { + "flowRepresentableName": "FR2" + } + ] + } + """.data(using: .utf8)) + } + } + + fileprivate var malformedWorkflowJSON: Data { + get throws { + try XCTUnwrap(""" + { + "iAmATeapot": true, + "thisIsValid": 0 + } + """.data(using: .utf8)) + } + } +} diff --git a/Tests/SwiftCurrentTests/Metadata/FlowRepresentableMetadataDescriberConsumerTests.swift b/Tests/SwiftCurrentTests/Metadata/FlowRepresentableMetadataDescriberConsumerTests.swift deleted file mode 100644 index e57ecd6c8..000000000 --- a/Tests/SwiftCurrentTests/Metadata/FlowRepresentableMetadataDescriberConsumerTests.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// FlowRepresentableMetadataDescriberConsumerTests.swift -// SwiftCurrent -// -// Created by Richard Gist on 12/6/21. -// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. -// - -import SwiftCurrent -import XCTest - -class FlowRepresentableMetadataDescriberConsumerTests: XCTestCase { - func testProtocolIsCorrectlyExposed() { - struct FR1: FlowRepresentable, FlowRepresentableMetadataDescriber { - static var flowRepresentableName: String { "Foo" } - static func metadataFactory() -> FlowRepresentableMetadata { FlowRepresentableMetadata(Self.self) } - - var _workflowPointer: AnyFlowRepresentable? - } - - let FRMD: FlowRepresentableMetadataDescriber.Type = FR1.self - - XCTAssertEqual(FRMD.flowRepresentableName, "Foo") - XCTAssertEqual(FRMD.metadataFactory().flowRepresentableTypeDescriptor, "FR1") - } - - func testFlowRepresentableProvidesConvenientImplementations() { - struct FR2: FlowRepresentable, FlowRepresentableMetadataDescriber { - var _workflowPointer: AnyFlowRepresentable? - } - - let FRMD = FR2.self as FlowRepresentableMetadataDescriber.Type - - XCTAssertEqual(FRMD.flowRepresentableName, "FR2") - XCTAssertEqual(FRMD.metadataFactory().flowRepresentableTypeDescriptor, "FR2") - } - - func testProtocolIsCorrectlyExposedForClasses() { - class ThirdMetadata: FlowRepresentableMetadata { } - class FourthMetadata: FlowRepresentableMetadata { } - final class FR1: FlowRepresentable, FlowRepresentableMetadataDescriber { var _workflowPointer: AnyFlowRepresentable? } - class ParentFR: FlowRepresentable, FlowRepresentableMetadataDescriber { - var _workflowPointer: AnyFlowRepresentable? - required init() { } - class var flowRepresentableName: String { "Parent FR" } - class func metadataFactory() -> FlowRepresentableMetadata { ThirdMetadata(Self.self) { _ in .default } } - } - class ChildFR1: ParentFR { } - class ChildFR2: ParentFR { - override class var flowRepresentableName: String { "Child FR2" } - override class func metadataFactory() -> FlowRepresentableMetadata { FourthMetadata(Self.self) { _ in .default } } - } - - let fr1AsFRMD = FR1.self as FlowRepresentableMetadataDescriber.Type - XCTAssertEqual(FR1.flowRepresentableName, "FR1") - XCTAssertFalse(FR1.metadataFactory() is ThirdMetadata, "Metadata should not be of type ThirdMetadata") - XCTAssertEqual(fr1AsFRMD.flowRepresentableName, "FR1") - XCTAssertFalse(fr1AsFRMD.metadataFactory() is ThirdMetadata, "Metadata should not be of type ThirdMetadata") - - let parentFRAsFRMD = ParentFR.self as FlowRepresentableMetadataDescriber.Type - XCTAssertEqual(ParentFR.flowRepresentableName, "Parent FR") - XCTAssert(ParentFR.metadataFactory() is ThirdMetadata, "Metadata should be of type ThirdMetadata") - XCTAssertEqual(parentFRAsFRMD.flowRepresentableName, "Parent FR") - XCTAssert(parentFRAsFRMD.metadataFactory() is ThirdMetadata, "Metadata should be of type ThirdMetadata") - - let childFR1AsFRMD = ChildFR1.self as FlowRepresentableMetadataDescriber.Type - XCTAssertEqual(ChildFR1.flowRepresentableName, "Parent FR") - XCTAssert(ChildFR1.metadataFactory() is ThirdMetadata, "Metadata should be of type ThirdMetadata") - XCTAssertEqual(childFR1AsFRMD.flowRepresentableName, "Parent FR") - XCTAssert(childFR1AsFRMD.metadataFactory() is ThirdMetadata, "Metadata should be of type ThirdMetadata") - - let childFR2AsFRMD = ChildFR2.self as FlowRepresentableMetadataDescriber.Type - XCTAssertEqual(ChildFR2.flowRepresentableName, "Child FR2") - XCTAssert(ChildFR2.metadataFactory() is FourthMetadata, "Metadata should be of type FourthMetadata") - XCTAssertEqual(childFR2AsFRMD.flowRepresentableName, "Child FR2") - XCTAssert(childFR2AsFRMD.metadataFactory() is FourthMetadata, "Metadata should be of type FourthMetadata") - } - - func testExtendingProductsCanProvideUniqueImplementationsForClasses() { - class ThirdMetadata: FlowRepresentableMetadata { } - class FR1: CustomExtensionClass { } - class FR2: CustomExtensionClass { - // These implementations only exist when referencing FR2 directly. - static var flowRepresentableName: String { "Special FR2"} - static func metadataFactory() -> FlowRepresentableMetadata { ThirdMetadata(Self.self) { _ in .default } } - } - - let FRMD1 = FR1.self as FlowRepresentableMetadataDescriber.Type - let FRMD2 = FR2.self as FlowRepresentableMetadataDescriber.Type - - XCTAssertEqual(FR1.flowRepresentableName, "Twice Overridden") - XCTAssert(FR1.metadataFactory() is CustomFlowRepresentableMetadata) - XCTAssertEqual(FRMD1.flowRepresentableName, "Twice Overridden") - XCTAssert(FRMD1.metadataFactory() is CustomFlowRepresentableMetadata) - XCTAssertEqual(FRMD2.flowRepresentableName, "Twice Overridden") - XCTAssert(FRMD2.metadataFactory() is CustomFlowRepresentableMetadata) - - XCTAssertEqual(FR2.flowRepresentableName, "Special FR2") - XCTAssert(FR2.metadataFactory() is ThirdMetadata) - } - - func testExtendingProductsCanProvideUniqueImplementationsForStructs() { - class ThirdMetada: FlowRepresentableMetadata { } - struct FR1: CustomExtensionProtocol { var _workflowPointer: AnyFlowRepresentable? } - struct FR2: CustomExtensionProtocol { - static var flowRepresentableName: String { "Special FR2"} - static func metadataFactory() -> FlowRepresentableMetadata { ThirdMetada(Self.self) { _ in .default } } - - var _workflowPointer: AnyFlowRepresentable? - } - - let FRMD1 = FR1.self as FlowRepresentableMetadataDescriber.Type - let FRMD2 = FR2.self as FlowRepresentableMetadataDescriber.Type - - XCTAssertEqual(FR1.flowRepresentableName, "Twice Overridden for Protocol") - XCTAssert(FR1.metadataFactory() is CustomFlowRepresentableMetadata) - XCTAssertEqual(FRMD1.flowRepresentableName, "Twice Overridden for Protocol") - XCTAssert(FRMD1.metadataFactory() is CustomFlowRepresentableMetadata) - - XCTAssertEqual(FR2.flowRepresentableName, "Special FR2") - XCTAssert(FR2.metadataFactory() is ThirdMetada) - XCTAssertEqual(FRMD2.flowRepresentableName, "Special FR2") - XCTAssert(FRMD2.metadataFactory() is ThirdMetada) - } -} - -fileprivate class CustomFlowRepresentableMetadata: FlowRepresentableMetadata { } - -fileprivate class CustomExtensionClass: FlowRepresentable, FlowRepresentableMetadataDescriber { - var _workflowPointer: AnyFlowRepresentable? - required init() { } -} -fileprivate extension FlowRepresentable where Self: CustomExtensionClass { - static var flowRepresentableName: String { "Twice Overridden" } - static func metadataFactory() -> FlowRepresentableMetadata { - CustomFlowRepresentableMetadata(Self.self) { _ in .default } - } -} - -fileprivate protocol CustomExtensionProtocol: FlowRepresentable, FlowRepresentableMetadataDescriber { } -fileprivate extension FlowRepresentable where Self: CustomExtensionProtocol { - static var flowRepresentableName: String { "Twice Overridden for Protocol" } - static func metadataFactory() -> FlowRepresentableMetadata { - CustomFlowRepresentableMetadata(Self.self) { _ in .default } - } -} diff --git a/Tests/SwiftCurrentTests/Metadata/WorkflowDecodableConsumerTests.swift b/Tests/SwiftCurrentTests/Metadata/WorkflowDecodableConsumerTests.swift new file mode 100644 index 000000000..602475df6 --- /dev/null +++ b/Tests/SwiftCurrentTests/Metadata/WorkflowDecodableConsumerTests.swift @@ -0,0 +1,157 @@ +// +// WorkflowDecodableConsumerTests.swift +// SwiftCurrent +// +// Created by Richard Gist on 12/6/21. +// Copyright © 2021 WWT and Tyler Thompson. All rights reserved. +// + +import SwiftCurrent +import SwiftCurrent_Testing +import XCTest + +class WorkflowDecodableConsumerTests: XCTestCase { + func testProtocolIsCorrectlyExposed() { + struct FR1: FlowRepresentable, WorkflowDecodable { + static var flowRepresentableName: String { "Foo" } + static func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata { FlowRepresentableMetadata(Self.self) } + + var _workflowPointer: AnyFlowRepresentable? + } + + let FRMD: WorkflowDecodable.Type = FR1.self + + XCTAssertEqual(FRMD.flowRepresentableName, "Foo") + XCTAssertEqual(FRMD.metadataFactory(launchStyle: .default) { _ in .default }.flowRepresentableTypeDescriptor, "FR1") + } + + func testFlowRepresentableProvidesConvenientImplementations() { + struct FR2: FlowRepresentable, WorkflowDecodable { + var _workflowPointer: AnyFlowRepresentable? + } + + let FRMD = FR2.self as WorkflowDecodable.Type + + XCTAssertEqual(FRMD.flowRepresentableName, "FR2") + let newStyle = LaunchStyle.new + let metadata = FRMD.metadataFactory(launchStyle: newStyle) { _ in .default } + XCTAssertEqual(metadata.flowRepresentableTypeDescriptor, "FR2") + XCTAssertIdentical(metadata.launchStyle, newStyle) + } + + func testProtocolIsCorrectlyExposedForClasses() { + class ThirdMetadata: FlowRepresentableMetadata { } + class FourthMetadata: FlowRepresentableMetadata { } + final class FR1: FlowRepresentable, WorkflowDecodable { var _workflowPointer: AnyFlowRepresentable? } + class ParentFR: FlowRepresentable, WorkflowDecodable { + var _workflowPointer: AnyFlowRepresentable? + required init() { } + class var flowRepresentableName: String { "Parent FR" } + class func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata { ThirdMetadata(Self.self) { _ in .default } } + } + class ChildFR1: ParentFR { } + class ChildFR2: ParentFR { + override class var flowRepresentableName: String { "Child FR2" } + override class func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata { FourthMetadata(Self.self) { _ in .default } } + } + + let fr1AsFRMD = FR1.self as WorkflowDecodable.Type + XCTAssertEqual(FR1.flowRepresentableName, "FR1") + XCTAssertFalse(FR1.metadataFactory(launchStyle: .default) { _ in .default } is ThirdMetadata, "Metadata should not be of type ThirdMetadata") + XCTAssertEqual(fr1AsFRMD.flowRepresentableName, "FR1") + XCTAssertFalse(fr1AsFRMD.metadataFactory(launchStyle: .default) { _ in .default } is ThirdMetadata, "Metadata should not be of type ThirdMetadata") + + let parentFRAsFRMD = ParentFR.self as WorkflowDecodable.Type + XCTAssertEqual(ParentFR.flowRepresentableName, "Parent FR") + XCTAssert(ParentFR.metadataFactory(launchStyle: .default) { _ in .default } is ThirdMetadata, "Metadata should be of type ThirdMetadata") + XCTAssertEqual(parentFRAsFRMD.flowRepresentableName, "Parent FR") + XCTAssert(parentFRAsFRMD.metadataFactory(launchStyle: .default) { _ in .default } is ThirdMetadata, "Metadata should be of type ThirdMetadata") + + let childFR1AsFRMD = ChildFR1.self as WorkflowDecodable.Type + XCTAssertEqual(ChildFR1.flowRepresentableName, "Parent FR") + XCTAssert(ChildFR1.metadataFactory(launchStyle: .default) { _ in .default } is ThirdMetadata, "Metadata should be of type ThirdMetadata") + XCTAssertEqual(childFR1AsFRMD.flowRepresentableName, "Parent FR") + XCTAssert(childFR1AsFRMD.metadataFactory(launchStyle: .default) { _ in .default } is ThirdMetadata, "Metadata should be of type ThirdMetadata") + + let childFR2AsFRMD = ChildFR2.self as WorkflowDecodable.Type + XCTAssertEqual(ChildFR2.flowRepresentableName, "Child FR2") + XCTAssert(ChildFR2.metadataFactory(launchStyle: .default) { _ in .default } is FourthMetadata, "Metadata should be of type FourthMetadata") + XCTAssertEqual(childFR2AsFRMD.flowRepresentableName, "Child FR2") + XCTAssert(childFR2AsFRMD.metadataFactory(launchStyle: .default) { _ in .default } is FourthMetadata, "Metadata should be of type FourthMetadata") + } + + func testExtendingProductsCanProvideUniqueImplementationsForClasses() { + class ThirdMetadata: FlowRepresentableMetadata { } + class FR1: CustomExtensionClass { } + class FR2: CustomExtensionClass { + // These implementations only exist when referencing FR2 directly. + static var flowRepresentableName: String { "Special FR2"} + static func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata { ThirdMetadata(Self.self) { _ in .default } } + } + + let FRMD1 = FR1.self as WorkflowDecodable.Type + let FRMD2 = FR2.self as WorkflowDecodable.Type + + XCTAssertEqual(FR1.flowRepresentableName, "Twice Overridden") + XCTAssert(FR1.metadataFactory(launchStyle: .default) { _ in .default } is CustomFlowRepresentableMetadata) + XCTAssertEqual(FRMD1.flowRepresentableName, "Twice Overridden") + XCTAssert(FRMD1.metadataFactory(launchStyle: .default) { _ in .default } is CustomFlowRepresentableMetadata) + XCTAssertEqual(FRMD2.flowRepresentableName, "Twice Overridden") + XCTAssert(FRMD2.metadataFactory(launchStyle: .default) { _ in .default } is CustomFlowRepresentableMetadata) + + XCTAssertEqual(FR2.flowRepresentableName, "Special FR2") + XCTAssert(FR2.metadataFactory(launchStyle: .default) { _ in .default } is ThirdMetadata) + } + + func testExtendingProductsCanProvideUniqueImplementationsForStructs() { + class ThirdMetada: FlowRepresentableMetadata { } + struct FR1: CustomExtensionProtocol { var _workflowPointer: AnyFlowRepresentable? } + struct FR2: CustomExtensionProtocol { + static var flowRepresentableName: String { "Special FR2"} + static func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata { ThirdMetada(Self.self) { _ in .default } } + + var _workflowPointer: AnyFlowRepresentable? + } + + let FRMD1 = FR1.self as WorkflowDecodable.Type + let FRMD2 = FR2.self as WorkflowDecodable.Type + + XCTAssertEqual(FR1.flowRepresentableName, "Twice Overridden for Protocol") + XCTAssert(FR1.metadataFactory(launchStyle: .default) { _ in .default } is CustomFlowRepresentableMetadata) + XCTAssertEqual(FRMD1.flowRepresentableName, "Twice Overridden for Protocol") + XCTAssert(FRMD1.metadataFactory(launchStyle: .default) { _ in .default } is CustomFlowRepresentableMetadata) + + XCTAssertEqual(FR2.flowRepresentableName, "Special FR2") + XCTAssert(FR2.metadataFactory(launchStyle: .default) { _ in .default } is ThirdMetada) + XCTAssertEqual(FRMD2.flowRepresentableName, "Special FR2") + XCTAssert(FRMD2.metadataFactory(launchStyle: .default) { _ in .default } is ThirdMetada) + } +} + +fileprivate class CustomFlowRepresentableMetadata: FlowRepresentableMetadata { } + +fileprivate class CustomExtensionClass: FlowRepresentable, WorkflowDecodable { + var _workflowPointer: AnyFlowRepresentable? + required init() { } +} +fileprivate extension FlowRepresentable where Self: CustomExtensionClass { + static var flowRepresentableName: String { "Twice Overridden" } + static func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata { + CustomFlowRepresentableMetadata(Self.self) { _ in .default } + } +} + +fileprivate protocol CustomExtensionProtocol: FlowRepresentable, WorkflowDecodable { } +fileprivate extension FlowRepresentable where Self: CustomExtensionProtocol { + static var flowRepresentableName: String { "Twice Overridden for Protocol" } + static func metadataFactory(launchStyle: LaunchStyle, + flowPersistence: @escaping (AnyWorkflow.PassedArgs) -> FlowPersistence) -> FlowRepresentableMetadata { + CustomFlowRepresentableMetadata(Self.self) { _ in .default } + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/AnyWorkflowTests.swift b/Tests/SwiftCurrent_SwiftUITests/AnyWorkflowTests.swift index f8d2f0bca..3880e1bb1 100644 --- a/Tests/SwiftCurrent_SwiftUITests/AnyWorkflowTests.swift +++ b/Tests/SwiftCurrent_SwiftUITests/AnyWorkflowTests.swift @@ -22,10 +22,6 @@ final class AnyWorkflowTests: XCTestCase, View { 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, *) diff --git a/Tests/SwiftCurrent_SwiftUITests/SwiftCurrent_SwiftUITests.swift b/Tests/SwiftCurrent_SwiftUITests/SwiftCurrent_SwiftUITests.swift index a8b640e2e..28d6d6127 100644 --- a/Tests/SwiftCurrent_SwiftUITests/SwiftCurrent_SwiftUITests.swift +++ b/Tests/SwiftCurrent_SwiftUITests/SwiftCurrent_SwiftUITests.swift @@ -12,6 +12,7 @@ import ViewInspector import SwiftCurrent @testable import SwiftCurrent_SwiftUI // testable sadly needed for inspection.inspect to work +import SwiftCurrent_Testing @available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { @@ -732,8 +733,8 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { wait(for: [exp], timeout: TestConstant.timeout) } - func testLaunchingAWorkflowWithOneItemFromAnAnyWorkflow() { - struct FR1: View, FlowRepresentable, Inspectable { + func testLaunchingAWorkflowWithOneItemFromAnAnyWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable, WorkflowDecodable { weak var _workflowPointer: AnyFlowRepresentable? var body: some View { @@ -741,7 +742,7 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { } } - let wf = Workflow(FR1.self) + let wf = try decodeAnyWorkflow(with: FR1.self) let launcher = WorkflowLauncher(isLaunched: .constant(true), workflow: wf) @@ -752,12 +753,12 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { wait(for: [exp], timeout: TestConstant.timeout) } - func testLaunchingAMultiTypeLongWorkflowFromAnAnyWorkflow() { - struct FR1: View, FlowRepresentable, Inspectable { + func testLaunchingAMultiTypeLongWorkflowFromAnAnyWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable, WorkflowDecodable { var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR1 type") } } - struct FR2: View, FlowRepresentable, Inspectable { + struct FR2: View, FlowRepresentable, Inspectable, WorkflowDecodable { typealias WorkflowOutput = AnyWorkflow.PassedArgs var _workflowPointer: AnyFlowRepresentable? private let data: AnyWorkflow.PassedArgs @@ -771,8 +772,7 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { let expectOnFinish = expectation(description: "OnFinish called") let expectedArgs = UUID().uuidString - let wf = Workflow(FR1.self) - .thenProceed(with: FR2.self) { .default } + let wf = try decodeAnyWorkflow(with: FR1.self, FR2.self) let expectViewLoaded = ViewHosting.loadView( WorkflowLauncher(isLaunched: .constant(true), workflow: wf) @@ -790,23 +790,21 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { wait(for: [expectOnFinish, expectViewLoaded], timeout: TestConstant.timeout) } - func testLaunchingAWorkflowFromAnAnyWorkflow() { - struct FR1: View, FlowRepresentable, Inspectable { + func testLaunchingAWorkflowFromAnAnyWorkflow() throws { + struct FR1: View, FlowRepresentable, Inspectable, WorkflowDecodable { weak var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR1 type") } } - struct FR2: View, PassthroughFlowRepresentable, Inspectable { + struct FR2: View, PassthroughFlowRepresentable, Inspectable, WorkflowDecodable { weak var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR2 type") } } - struct FR3: View, FlowRepresentable, Inspectable { + struct FR3: View, FlowRepresentable, Inspectable, WorkflowDecodable { weak var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR3 type") } } - let wf = Workflow(FR1.self) - .thenProceed(with: FR2.self, flowPersistence: { .default }) - .thenProceed(with: FR3.self, flowPersistence: { .default }) + let wf = try decodeAnyWorkflow(with: FR1.self, FR2.self, FR3.self) let launcher = WorkflowLauncher(isLaunched: .constant(true), workflow: wf) let expectOnFinish = expectation(description: "OnFinish called") @@ -831,12 +829,12 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { wait(for: [expectOnFinish, expectViewLoaded], timeout: TestConstant.timeout) } - func testWorkflowLaunchedFromAnAnyWorkflowCanHavePassthroughFlowRepresentableInTheMiddle() { - struct FR1: View, FlowRepresentable, Inspectable { + func testWorkflowLaunchedFromAnAnyWorkflowCanHavePassthroughFlowRepresentableInTheMiddle() throws { + struct FR1: View, FlowRepresentable, Inspectable, WorkflowDecodable { var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR1 type") } } - struct FR2: View, FlowRepresentable, Inspectable { + struct FR2: View, FlowRepresentable, Inspectable, WorkflowDecodable { typealias WorkflowOutput = String var _workflowPointer: AnyFlowRepresentable? private let data: AnyWorkflow.PassedArgs @@ -846,7 +844,7 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { self.data = args } } - struct FR3: View, FlowRepresentable, Inspectable { + struct FR3: View, FlowRepresentable, Inspectable, WorkflowDecodable { typealias WorkflowInput = String let str: String init(with str: String) { @@ -855,15 +853,12 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR3 type, \(str)") } } - struct FR4: View, FlowRepresentable, Inspectable { + struct FR4: View, FlowRepresentable, Inspectable, WorkflowDecodable { var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR4 type") } } - let wf = Workflow(FR1.self) - .thenProceed(with: FR2.self, flowPersistence: { .default }) - .thenProceed(with: FR3.self, flowPersistence: { _ in .default }) - .thenProceed(with: FR4.self, flowPersistence: { .default }) + let wf = try decodeAnyWorkflow(with: FR1.self, FR2.self, FR3.self, FR4.self) let launcher = WorkflowLauncher(isLaunched: .constant(true), workflow: wf) let expectOnFinish = expectation(description: "OnFinish called") @@ -893,8 +888,8 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { wait(for: [expectOnFinish, expectViewLoaded], timeout: TestConstant.timeout) } - func testWorkflowLaunchedFromAnAnyWorkflowCanHaveStartingArgs() { - struct FR1: View, FlowRepresentable, Inspectable { + func testWorkflowLaunchedFromAnAnyWorkflowCanHaveStartingArgs() throws { + struct FR1: View, FlowRepresentable, Inspectable, WorkflowDecodable { typealias WorkflowOutput = AnyWorkflow.PassedArgs var _workflowPointer: AnyFlowRepresentable? var args: AnyWorkflow.PassedArgs @@ -904,7 +899,7 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { self.args = args } } - struct FR2: View, FlowRepresentable, Inspectable { + struct FR2: View, FlowRepresentable, Inspectable, WorkflowDecodable { var _workflowPointer: AnyFlowRepresentable? var args: AnyWorkflow.PassedArgs var body: some View { Text("FR2 type, \(args.extractArgs(defaultValue: "") as! String)") } @@ -913,19 +908,16 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { self.args = args } } - struct FR3: View, FlowRepresentable, Inspectable { + struct FR3: View, FlowRepresentable, Inspectable, WorkflowDecodable { var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR3 type") } } - struct FR4: View, FlowRepresentable, Inspectable { + struct FR4: View, FlowRepresentable, Inspectable, WorkflowDecodable { var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR4 type") } } - let wf = Workflow(FR1.self) - .thenProceed(with: FR2.self, flowPersistence: { _ in .default }) - .thenProceed(with: FR3.self, flowPersistence: { _ in .default }) - .thenProceed(with: FR4.self, flowPersistence: { .default }) + let wf = try decodeAnyWorkflow(with: FR1.self, FR2.self, FR3.self, FR4.self) let expectedArgs = UUID().uuidString let launcher = WorkflowLauncher(isLaunched: .constant(true), startingArgs: .args(expectedArgs), workflow: wf) @@ -955,20 +947,21 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { wait(for: [expectOnFinish, expectViewLoaded], timeout: TestConstant.timeout) } - func testLaunchingAWorkflowUsingNonPassedArgsStartingArgs() { - struct FR1: View, FlowRepresentable, Inspectable { + func testLaunchingAWorkflowUsingNonPassedArgsStartingArgs() throws { + struct FR1: View, FlowRepresentable, Inspectable, WorkflowDecodable { weak var _workflowPointer: AnyFlowRepresentable? var body: some View { Text("FR1 type") } public var data: String init(with data: String) { self.data = data } } - let wf = Workflow(FR1.self) + let wf = try decodeAnyWorkflow(with: FR1.self) + let expectedData = UUID().uuidString let launcher = WorkflowLauncher(isLaunched: .constant(true), startingArgs: expectedData, workflow: wf) let expectViewLoaded = ViewHosting.loadView(launcher).inspection.inspect { viewUnderTest in - XCTAssertEqual(try viewUnderTest.find(FR1.self).actualView().data, expectedData) + XCTAssertEqual(try viewUnderTest.find(FR1.self).actualView().data, expectedData) } wait(for: [expectViewLoaded], timeout: TestConstant.timeout) @@ -987,7 +980,7 @@ final class SwiftCurrent_SwiftUIConsumerTests: XCTestCase, App { wf.removeLast() try XCTAssertThrowsFatalError { - _ = WorkflowLauncher(isLaunched: .constant(true), workflow: wf) + _ = WorkflowLauncher(isLaunched: .constant(true), workflow: AnyWorkflow(wf)) } } } @@ -999,3 +992,20 @@ protocol StateIdentifiable { } extension State: StateIdentifiable { } + +fileprivate extension XCTestCase { + func decodeAnyWorkflow(with sequence: WorkflowDecodable.Type...) throws -> AnyWorkflow { + try JSONDecoder().decodeWorkflow(withAggregator: TestRegistry(types: sequence), from: generateValidWorkflowSpecification(with: sequence)) + } + + func generateValidWorkflowSpecification(with sequence: [WorkflowDecodable.Type]) throws -> Data { + return try XCTUnwrap(""" + { + "schemaVersion": "\(AnyWorkflow.jsonSchemaVersion.rawValue)", + "sequence" : [ + \(sequence.map { "{\"flowRepresentableName\" : \"\($0.flowRepresentableName)\"}" }.joined(separator: ",\n")) + ] + } + """.data(using: .utf8)) + } +} diff --git a/Tests/SwiftCurrent_SwiftUITests/SwiftUILaunchStyleAdditionTests.swift b/Tests/SwiftCurrent_SwiftUITests/SwiftUILaunchStyleAdditionTests.swift index 58869e801..89a9bce59 100644 --- a/Tests/SwiftCurrent_SwiftUITests/SwiftUILaunchStyleAdditionTests.swift +++ b/Tests/SwiftCurrent_SwiftUITests/SwiftUILaunchStyleAdditionTests.swift @@ -12,6 +12,7 @@ import Algorithms import SwiftUI @testable import SwiftCurrent_SwiftUI +import SwiftCurrent_Testing @available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) final class LaunchStyleAdditionTests: XCTestCase, View { @@ -31,13 +32,57 @@ final class LaunchStyleAdditionTests: XCTestCase, View { func testKnownPresentationTypes_AreUnique() { [LaunchStyle.default, LaunchStyle._swiftUI_modal, LaunchStyle._swiftUI_modal_fullscreen, LaunchStyle._swiftUI_navigationLink].permutations().forEach { - XCTAssertFalse($0[0] === $0[1]) + XCTAssertNotIdentical($0[0], $0[1]) } LaunchStyle.SwiftUI.PresentationType.allCases.permutations().forEach { XCTAssertNotEqual($0[0], $0[1]) } } + func testKnownPresentationTypes_CanBeDecoded() throws { + struct TestView: View, FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + var body: some View { EmptyView() } + } + let validLaunchStyles: [String: LaunchStyle] = [ + "viewSwapping": .default, + "modal": ._swiftUI_modal, + "modal(.fullScreen)": ._swiftUI_modal_fullscreen, + "navigationLink": ._swiftUI_navigationLink + ] + + let WD: WorkflowDecodable.Type = TestView.self + + try validLaunchStyles.forEach { (key, value) in + XCTAssertIdentical(try TestView.decodeLaunchStyle(named: key), value) + XCTAssertIdentical(try WD.decodeLaunchStyle(named: key), value) + } + + // Metatest, testing we covered all styles + LaunchStyle.SwiftUI.PresentationType.allCases.forEach { presentationType in + XCTAssert(validLaunchStyles.values.contains { $0 === presentationType.rawValue }, "dictionary of validLaunchStyles did not contain one for \(presentationType)") + } + } + + func testLaunchStyleIsPassedThroughToExtendedFlowRepresentable() throws { + struct TestView: View, FlowRepresentable, WorkflowDecodable { + weak var _workflowPointer: AnyFlowRepresentable? + var body: some View { EmptyView() } + } + + let WD: WorkflowDecodable.Type = TestView.self + + let launchStyle = LaunchStyle.new + let flowPersistence = FlowPersistence.new + let metadata = WD.metadataFactory(launchStyle: launchStyle) { _ in flowPersistence } + let wf = Workflow(metadata) + let orchestrationResponder = MockOrchestrationResponder() + + wf.launch(withOrchestrationResponder: orchestrationResponder) + XCTAssertIdentical(metadata.launchStyle, launchStyle) + XCTAssertIdentical(orchestrationResponder.lastTo?.value.metadata.persistence, flowPersistence) + } + func testPresentationTypes_AreCorrectlyEquatable() { XCTAssertEqual(LaunchStyle.SwiftUI.PresentationType.default, .default) XCTAssertEqual(LaunchStyle.SwiftUI.PresentationType.navigationLink, .navigationLink) diff --git a/Tests/SwiftCurrent_SwiftUITests/FlowRepresentableMetadataDescriberExtensionsTests.swift b/Tests/SwiftCurrent_SwiftUITests/WorkflowDecodableExtensionsTests.swift similarity index 71% rename from Tests/SwiftCurrent_SwiftUITests/FlowRepresentableMetadataDescriberExtensionsTests.swift rename to Tests/SwiftCurrent_SwiftUITests/WorkflowDecodableExtensionsTests.swift index 1301bf436..8737a7662 100644 --- a/Tests/SwiftCurrent_SwiftUITests/FlowRepresentableMetadataDescriberExtensionsTests.swift +++ b/Tests/SwiftCurrent_SwiftUITests/WorkflowDecodableExtensionsTests.swift @@ -1,5 +1,5 @@ // -// FlowRepresentableMetadataDescriberExtensionsTests.swift +// WorkflowDecodableExtensionsTests.swift // SwiftCurrent_SwiftUI // // Created by Richard Gist on 12/29/21. @@ -13,15 +13,15 @@ import SwiftCurrent import SwiftCurrent_SwiftUI @available(iOS 14.0, macOS 11, tvOS 14.0, watchOS 7.0, *) -class FlowRepresentableMetadataDescriberExtensionsTests: XCTestCase { +class WorkflowDecodableExtensionsTests: XCTestCase { func testMetadataFactoryReturnsExtendedFlowRepresentableMetadataForViews() { - struct FR1: View, FlowRepresentable, FlowRepresentableMetadataDescriber { + struct FR1: View, FlowRepresentable, WorkflowDecodable { weak var _workflowPointer: AnyFlowRepresentable? var body: some View { EmptyView() } } - let metadata = FR1.metadataFactory() - let genericMetadata = (FR1.self as FlowRepresentableMetadataDescriber.Type).metadataFactory() + let metadata = FR1.metadataFactory(launchStyle: .default) { _ in .default } + let genericMetadata = (FR1.self as WorkflowDecodable.Type).metadataFactory(launchStyle: .default) { _ in .default } // ExtendedFlowRepresentableMetadata should be internal, but we must also test if the override is public. XCTAssert(type(of: metadata) != FlowRepresentableMetadata.self, "\(type(of: metadata)) should be overridden from type FlowRepresentableMetadata")