diff --git a/Workflow/Sources/WorkflowAction.swift b/Workflow/Sources/WorkflowAction.swift index 331ef316a..260eb2f92 100644 --- a/Workflow/Sources/WorkflowAction.swift +++ b/Workflow/Sources/WorkflowAction.swift @@ -32,11 +32,16 @@ public protocol WorkflowAction { /// A type-erased workflow action. /// -/// The `AnyWorkflowAction` type forwards `apply` to an underlying workflow action, hiding its specific underlying type, -/// or to a closure that implements the `apply` logic. +/// The `AnyWorkflowAction` type forwards `apply` to an underlying workflow action, hiding its specific underlying type. public struct AnyWorkflowAction: WorkflowAction { private let _apply: (inout WorkflowType.State) -> WorkflowType.Output? + /// The underlying type-erased `WorkflowAction` + public let base: Any + + /// True iff the underlying `apply` implementation is defined by a closure vs wrapping a `WorkflowAction` conformance + public let isClosureBased: Bool + /// Creates a type-erased workflow action that wraps the given instance. /// /// - Parameter base: A workflow action to wrap. @@ -46,13 +51,32 @@ public struct AnyWorkflowAction: WorkflowAction { return } self._apply = { return base.apply(toState: &$0) } + self.base = base + self.isClosureBased = false } /// Creates a type-erased workflow action with the given `apply` implementation. /// /// - Parameter apply: the apply function for the resulting action. - public init(_ apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?) { - self._apply = apply + public init( + _ apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?, + fileID: StaticString = #fileID, + line: UInt = #line + ) { + let closureAction = ClosureAction( + _apply: apply, + fileID: fileID, + line: line + ) + self.init(closureAction: closureAction) + } + + /// Private initializer forwarded to via `init(_ apply:...)` + /// - Parameter closureAction: The `ClosureAction` wrapping the underlying `apply` closure. + fileprivate init(closureAction: ClosureAction) { + self._apply = closureAction.apply(toState:) + self.base = closureAction + self.isClosureBased = true } public func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { @@ -78,3 +102,34 @@ extension AnyWorkflowAction { } } } + +// MARK: Closure Action + +/// A `WorkflowAction` that wraps an `apply(...)` implementation defined by a closure. +/// Mainly used to provide more useful debugging/telemetry information for `AnyWorkflow` instances +/// defined via a closure. +struct ClosureAction: WorkflowAction { + private let _apply: (inout WorkflowType.State) -> WorkflowType.Output? + let fileID: StaticString + let line: UInt + + init( + _apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?, + fileID: StaticString, + line: UInt + ) { + self._apply = _apply + self.fileID = fileID + self.line = line + } + + func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { + _apply(&state) + } +} + +extension ClosureAction: CustomStringConvertible { + var description: String { + "\(Self.self)(fileID: \(fileID), line: \(line))" + } +} diff --git a/Workflow/Tests/AnyWorkflowActionTests.swift b/Workflow/Tests/AnyWorkflowActionTests.swift new file mode 100644 index 000000000..5e3ab968c --- /dev/null +++ b/Workflow/Tests/AnyWorkflowActionTests.swift @@ -0,0 +1,125 @@ +/* + * Copyright 2023 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import XCTest +@testable import Workflow + +final class AnyWorkflowActionTests: XCTestCase { + func testRetainsBaseActionTypeInfo() { + let action = ExampleAction() + let erased = AnyWorkflowAction(action) + + XCTAssertEqual(action, erased.base as? ExampleAction) + } + + func testRetainsClosureActionTypeInfo() throws { + do { + let erased = AnyWorkflowAction { _ in + nil + } + + XCTAssertNotNil(erased.base as? ClosureAction) + } + + do { + let fileID: StaticString = #fileID + // must match line # the initializer is on + let line: UInt = #line; let erased = AnyWorkflowAction { _ in + nil + } + + let closureAction = try XCTUnwrap(erased.base as? ClosureAction) + XCTAssertEqual("\(closureAction.fileID)", "\(fileID)") + XCTAssertEqual(closureAction.line, line) + } + } + + func testMultipleErasure() { + // standard init + do { + let action = ExampleAction() + let erasedOnce = AnyWorkflowAction(action) + let erasedTwice = AnyWorkflowAction(erasedOnce) + + XCTAssertEqual( + erasedOnce.base as? ExampleAction, + erasedTwice.base as? ExampleAction + ) + } + + // closure init + do { + let action = AnyWorkflowAction { _ in nil } + let erasedAgain = AnyWorkflowAction(action) + + XCTAssertEqual( + "\(action.base.self)", + "\(erasedAgain.base.self)" + ) + } + } + + func testApplyForwarding() { + var log: [String] = [] + let action = ObservableExampleAction { + log.append("action invoked") + } + + let erased = AnyWorkflowAction(action) + + XCTAssertEqual(log, []) + + var state: Void = () + _ = erased.apply(toState: &state) + + XCTAssertEqual(log, ["action invoked"]) + } + + func testIsClosureBased() { + let nonClosureBased = AnyWorkflowAction(ExampleAction()) + XCTAssertFalse(nonClosureBased.isClosureBased) + + let closureBased = AnyWorkflowAction { _ in .none } + XCTAssertTrue(closureBased.isClosureBased) + } +} + +private struct ExampleWorkflow: Workflow { + typealias State = Void + typealias Output = Never + typealias Rendering = Void + + func render(state: Void, context: RenderContext) {} +} + +private struct ExampleAction: WorkflowAction, Equatable { + typealias WorkflowType = ExampleWorkflow + + func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { + return nil + } +} + +private struct ObservableExampleAction: WorkflowAction { + typealias WorkflowType = ExampleWorkflow + + var block: () -> Void = {} + + func apply(toState state: inout WorkflowType.State) -> WorkflowType.Output? { + block() + return nil + } +}