Skip to content

Commit b2b0790

Browse files
authored
[feat]: give AnyWorkflow a Workflow conformance (#184)
### 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
1 parent 99024b5 commit b2b0790

File tree

4 files changed

+127
-30
lines changed

4 files changed

+127
-30
lines changed

Diff for: Workflow/Sources/AnyWorkflow.swift

+27-6
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,24 @@
1818
public struct AnyWorkflow<Rendering, Output> {
1919
private let storage: AnyStorage
2020

21+
/// The underlying erased workflow instance
22+
public var base: Any { storage.base }
23+
2124
private init(storage: AnyStorage) {
2225
self.storage = storage
2326
}
2427

2528
/// Initializes a new type-erased wrapper for the given workflow.
2629
public init<T: Workflow>(_ workflow: T) where T.Rendering == Rendering, T.Output == Output {
27-
self.init(storage: Storage<T>(
28-
workflow: workflow,
29-
renderingTransform: { $0 },
30-
outputTransform: { $0 }
31-
))
30+
if let workflow = workflow as? AnyWorkflow<Rendering, Output> {
31+
self = workflow
32+
} else {
33+
self.init(storage: Storage<T>(
34+
workflow: workflow,
35+
renderingTransform: { $0 },
36+
outputTransform: { $0 }
37+
))
38+
}
3239
}
3340

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

40-
extension AnyWorkflow: AnyWorkflowConvertible {
47+
extension AnyWorkflow: Workflow {
48+
public typealias Output = Output
49+
public typealias State = Void
50+
public typealias Rendering = Rendering
51+
52+
public func render(state: Void, context: RenderContext<AnyWorkflow<Rendering, Output>>) -> Rendering {
53+
storage.render(context: context, key: "") {
54+
AnyWorkflowAction(sendingOutput: $0)
55+
}
56+
}
57+
4158
public func asAnyWorkflow() -> AnyWorkflow<Rendering, Output> {
4259
return self
4360
}
@@ -84,6 +101,8 @@ extension AnyWorkflow {
84101
///
85102
/// This type is never used directly.
86103
fileprivate class AnyStorage {
104+
var base: Any { fatalError() }
105+
87106
func render<Parent, Action>(context: RenderContext<Parent>, key: String, outputMap: @escaping (Output) -> Action) -> Rendering where Action: WorkflowAction, Action.WorkflowType == Parent {
88107
fatalError()
89108
}
@@ -115,6 +134,8 @@ extension AnyWorkflow {
115134
self.outputTransform = outputTransform
116135
}
117136

137+
override var base: Any { workflow }
138+
118139
override var workflowType: Any.Type {
119140
return T.self
120141
}

Diff for: Workflow/Tests/AnyWorkflowTests.swift

+28
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,34 @@ public class AnyWorkflowTests: XCTestCase {
5454
}
5555
wait(for: [renderingExpectation, outputExpectation], timeout: 1)
5656
}
57+
58+
func testOnlyWrapsOnce() {
59+
// direct initializer
60+
do {
61+
let base = OnOutputWorkflow()
62+
let wrappedOnce = AnyWorkflow(base)
63+
let wrappedTwice = AnyWorkflow(wrappedOnce)
64+
65+
XCTAssertNotNil(wrappedOnce.base as? OnOutputWorkflow)
66+
XCTAssertNotNil(wrappedTwice.base as? OnOutputWorkflow)
67+
}
68+
69+
// method chaining
70+
do {
71+
let base = OnOutputWorkflow()
72+
let wrappedOnce = base.asAnyWorkflow()
73+
let wrappedTwice = base.asAnyWorkflow().asAnyWorkflow()
74+
75+
XCTAssertNotNil(wrappedOnce.base as? OnOutputWorkflow)
76+
XCTAssertNotNil(wrappedTwice.base as? OnOutputWorkflow)
77+
}
78+
}
79+
80+
func testBaseValue() {
81+
let erased = OnOutputWorkflow().asAnyWorkflow()
82+
83+
XCTAssertNotNil(erased.base as? OnOutputWorkflow)
84+
}
5785
}
5886

5987
/// Has no state or output, simply renders a reversed string

Diff for: WorkflowTesting/Tests/WorkflowRenderTesterTests.swift

+69
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,40 @@ final class WorkflowRenderTesterTests: XCTestCase {
109109
.assertNoOutput()
110110
}
111111

112+
func test_ignoredOutput_opaqueChild() {
113+
OpaqueChildOutputIgnoringWorkflow(
114+
childProvider: {
115+
OutputWorkflow()
116+
.mapRendering { _ in "screen" }
117+
.asAnyWorkflow()
118+
}
119+
)
120+
.renderTester()
121+
.expectWorkflowIgnoringOutput(
122+
type: AnyWorkflow<String, OutputWorkflow.Output>.self,
123+
producingRendering: "test"
124+
)
125+
.render { rendering in
126+
XCTAssertEqual(rendering, "test")
127+
}
128+
}
129+
130+
func test_opaqueChild() {
131+
OpaqueChildWorkflow(
132+
childProvider: {
133+
MockChildWorkflow().asAnyWorkflow()
134+
}
135+
)
136+
.renderTester()
137+
.expectWorkflow(
138+
type: MockChildWorkflow.self,
139+
producingRendering: "test"
140+
)
141+
.render { rendering in
142+
XCTAssertEqual(rendering, "test")
143+
}
144+
}
145+
112146
func test_childWorkflow() {
113147
ParentWorkflow(initialText: "hello")
114148
.renderTester()
@@ -262,6 +296,41 @@ private struct OutputIgnoringWorkflow: Workflow {
262296
}
263297
}
264298

299+
private struct MockChildWorkflow: Workflow {
300+
typealias State = Void
301+
typealias Rendering = String
302+
303+
func render(state: Void, context: RenderContext<MockChildWorkflow>) -> String {
304+
XCTFail("should never be rendered")
305+
return ""
306+
}
307+
}
308+
309+
private struct OpaqueChildWorkflow: Workflow {
310+
typealias State = Void
311+
typealias Rendering = String
312+
313+
var childProvider: () -> AnyWorkflow<String, Never>
314+
315+
func render(state: Void, context: RenderContext<Self>) -> Rendering {
316+
childProvider()
317+
.rendered(in: context)
318+
}
319+
}
320+
321+
private struct OpaqueChildOutputIgnoringWorkflow: Workflow {
322+
typealias State = Void
323+
typealias Rendering = String
324+
325+
var childProvider: () -> AnyWorkflow<String, OutputWorkflow.Output>
326+
327+
func render(state: Void, context: RenderContext<Self>) -> Rendering {
328+
childProvider()
329+
.ignoringOutput()
330+
.rendered(in: context)
331+
}
332+
}
333+
265334
private struct TestSideEffectKey: Hashable {
266335
let key: String = "Test Side Effect"
267336
}

Diff for: WorkflowUI/Sources/Hosting/WorkflowHostingController.swift

+3-24
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
2929

3030
private(set) var rootViewController: UIViewController
3131

32-
private let workflowHost: WorkflowHost<RootWorkflow<ScreenType, Output>>
32+
private let workflowHost: WorkflowHost<AnyWorkflow<ScreenType, Output>>
3333

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

@@ -45,7 +45,7 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
4545
observers: [WorkflowObserver] = []
4646
) where W.Rendering == ScreenType, W.Output == Output {
4747
self.workflowHost = WorkflowHost(
48-
workflow: RootWorkflow(workflow),
48+
workflow: workflow.asAnyWorkflow(),
4949
observers: observers
5050
)
5151

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

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

8080
public required init?(coder aDecoder: NSCoder) {
@@ -150,25 +150,4 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
150150
}
151151
}
152152

153-
/// Wrapper around an AnyWorkflow that allows us to have a concrete
154-
/// WorkflowHost without WorkflowHostingController itself being generic
155-
/// around a Workflow.
156-
fileprivate struct RootWorkflow<Rendering, Output>: Workflow {
157-
typealias State = Void
158-
typealias Output = Output
159-
typealias Rendering = Rendering
160-
161-
var wrapped: AnyWorkflow<Rendering, Output>
162-
163-
init<W: AnyWorkflowConvertible>(_ wrapped: W) where W.Rendering == Rendering, W.Output == Output {
164-
self.wrapped = wrapped.asAnyWorkflow()
165-
}
166-
167-
func render(state: State, context: RenderContext<RootWorkflow>) -> Rendering {
168-
return wrapped
169-
.mapOutput { AnyWorkflowAction(sendingOutput: $0) }
170-
.rendered(in: context)
171-
}
172-
}
173-
174153
#endif

0 commit comments

Comments
 (0)