Skip to content

Commit

Permalink
[feat]: give AnyWorkflow a Workflow conformance (#184)
Browse files Browse the repository at this point in the history
### Motivation

the fact that `AnyWorkflow` is not itself a `Workflow` creates confusion, and some awkwardness in some of our testing facilities. we'd like to add such a conformance to address these issues, and prevent consumers from having to write such type-erasing wrappers themselves.

### Changes

- add a `Workflow` conformance to `AnyWorkflow`
- replace `RootWorkflow` with `AnyWorkflow` in `WorkflowHostingController`
- expose the wrapped workflow for use in observability code
- add/update tests
  • Loading branch information
jamieQ authored Mar 7, 2023
1 parent 99024b5 commit b2b0790
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 30 deletions.
33 changes: 27 additions & 6 deletions Workflow/Sources/AnyWorkflow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,24 @@
public struct AnyWorkflow<Rendering, Output> {
private let storage: AnyStorage

/// The underlying erased workflow instance
public var base: Any { storage.base }

private init(storage: AnyStorage) {
self.storage = storage
}

/// Initializes a new type-erased wrapper for the given workflow.
public init<T: Workflow>(_ workflow: T) where T.Rendering == Rendering, T.Output == Output {
self.init(storage: Storage<T>(
workflow: workflow,
renderingTransform: { $0 },
outputTransform: { $0 }
))
if let workflow = workflow as? AnyWorkflow<Rendering, Output> {
self = workflow
} else {
self.init(storage: Storage<T>(
workflow: workflow,
renderingTransform: { $0 },
outputTransform: { $0 }
))
}
}

/// The underlying workflow's implementation type.
Expand All @@ -37,7 +44,17 @@ public struct AnyWorkflow<Rendering, Output> {
}
}

extension AnyWorkflow: AnyWorkflowConvertible {
extension AnyWorkflow: Workflow {
public typealias Output = Output
public typealias State = Void
public typealias Rendering = Rendering

public func render(state: Void, context: RenderContext<AnyWorkflow<Rendering, Output>>) -> Rendering {
storage.render(context: context, key: "") {
AnyWorkflowAction(sendingOutput: $0)
}
}

public func asAnyWorkflow() -> AnyWorkflow<Rendering, Output> {
return self
}
Expand Down Expand Up @@ -84,6 +101,8 @@ extension AnyWorkflow {
///
/// This type is never used directly.
fileprivate class AnyStorage {
var base: Any { fatalError() }

func render<Parent, Action>(context: RenderContext<Parent>, key: String, outputMap: @escaping (Output) -> Action) -> Rendering where Action: WorkflowAction, Action.WorkflowType == Parent {
fatalError()
}
Expand Down Expand Up @@ -115,6 +134,8 @@ extension AnyWorkflow {
self.outputTransform = outputTransform
}

override var base: Any { workflow }

override var workflowType: Any.Type {
return T.self
}
Expand Down
28 changes: 28 additions & 0 deletions Workflow/Tests/AnyWorkflowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,34 @@ public class AnyWorkflowTests: XCTestCase {
}
wait(for: [renderingExpectation, outputExpectation], timeout: 1)
}

func testOnlyWrapsOnce() {
// direct initializer
do {
let base = OnOutputWorkflow()
let wrappedOnce = AnyWorkflow(base)
let wrappedTwice = AnyWorkflow(wrappedOnce)

XCTAssertNotNil(wrappedOnce.base as? OnOutputWorkflow)
XCTAssertNotNil(wrappedTwice.base as? OnOutputWorkflow)
}

// method chaining
do {
let base = OnOutputWorkflow()
let wrappedOnce = base.asAnyWorkflow()
let wrappedTwice = base.asAnyWorkflow().asAnyWorkflow()

XCTAssertNotNil(wrappedOnce.base as? OnOutputWorkflow)
XCTAssertNotNil(wrappedTwice.base as? OnOutputWorkflow)
}
}

func testBaseValue() {
let erased = OnOutputWorkflow().asAnyWorkflow()

XCTAssertNotNil(erased.base as? OnOutputWorkflow)
}
}

/// Has no state or output, simply renders a reversed string
Expand Down
69 changes: 69 additions & 0 deletions WorkflowTesting/Tests/WorkflowRenderTesterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,40 @@ final class WorkflowRenderTesterTests: XCTestCase {
.assertNoOutput()
}

func test_ignoredOutput_opaqueChild() {
OpaqueChildOutputIgnoringWorkflow(
childProvider: {
OutputWorkflow()
.mapRendering { _ in "screen" }
.asAnyWorkflow()
}
)
.renderTester()
.expectWorkflowIgnoringOutput(
type: AnyWorkflow<String, OutputWorkflow.Output>.self,
producingRendering: "test"
)
.render { rendering in
XCTAssertEqual(rendering, "test")
}
}

func test_opaqueChild() {
OpaqueChildWorkflow(
childProvider: {
MockChildWorkflow().asAnyWorkflow()
}
)
.renderTester()
.expectWorkflow(
type: MockChildWorkflow.self,
producingRendering: "test"
)
.render { rendering in
XCTAssertEqual(rendering, "test")
}
}

func test_childWorkflow() {
ParentWorkflow(initialText: "hello")
.renderTester()
Expand Down Expand Up @@ -262,6 +296,41 @@ private struct OutputIgnoringWorkflow: Workflow {
}
}

private struct MockChildWorkflow: Workflow {
typealias State = Void
typealias Rendering = String

func render(state: Void, context: RenderContext<MockChildWorkflow>) -> String {
XCTFail("should never be rendered")
return ""
}
}

private struct OpaqueChildWorkflow: Workflow {
typealias State = Void
typealias Rendering = String

var childProvider: () -> AnyWorkflow<String, Never>

func render(state: Void, context: RenderContext<Self>) -> Rendering {
childProvider()
.rendered(in: context)
}
}

private struct OpaqueChildOutputIgnoringWorkflow: Workflow {
typealias State = Void
typealias Rendering = String

var childProvider: () -> AnyWorkflow<String, OutputWorkflow.Output>

func render(state: Void, context: RenderContext<Self>) -> Rendering {
childProvider()
.ignoringOutput()
.rendered(in: context)
}
}

private struct TestSideEffectKey: Hashable {
let key: String = "Test Side Effect"
}
Expand Down
27 changes: 3 additions & 24 deletions WorkflowUI/Sources/Hosting/WorkflowHostingController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll

private(set) var rootViewController: UIViewController

private let workflowHost: WorkflowHost<RootWorkflow<ScreenType, Output>>
private let workflowHost: WorkflowHost<AnyWorkflow<ScreenType, Output>>

private let (lifetime, token) = Lifetime.make()

Expand All @@ -45,7 +45,7 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
observers: [WorkflowObserver] = []
) where W.Rendering == ScreenType, W.Output == Output {
self.workflowHost = WorkflowHost(
workflow: RootWorkflow(workflow),
workflow: workflow.asAnyWorkflow(),
observers: observers
)

Expand Down Expand Up @@ -74,7 +74,7 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll

/// Updates the root Workflow in this container.
public func update<W: AnyWorkflowConvertible>(workflow: W) where W.Rendering == ScreenType, W.Output == Output {
workflowHost.update(workflow: RootWorkflow(workflow))
workflowHost.update(workflow: workflow.asAnyWorkflow())
}

public required init?(coder aDecoder: NSCoder) {
Expand Down Expand Up @@ -150,25 +150,4 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
}
}

/// Wrapper around an AnyWorkflow that allows us to have a concrete
/// WorkflowHost without WorkflowHostingController itself being generic
/// around a Workflow.
fileprivate struct RootWorkflow<Rendering, Output>: Workflow {
typealias State = Void
typealias Output = Output
typealias Rendering = Rendering

var wrapped: AnyWorkflow<Rendering, Output>

init<W: AnyWorkflowConvertible>(_ wrapped: W) where W.Rendering == Rendering, W.Output == Output {
self.wrapped = wrapped.asAnyWorkflow()
}

func render(state: State, context: RenderContext<RootWorkflow>) -> Rendering {
return wrapped
.mapOutput { AnyWorkflowAction(sendingOutput: $0) }
.rendered(in: context)
}
}

#endif

0 comments on commit b2b0790

Please sign in to comment.