diff --git a/Sources/ComposableSubscriber/Effect+fail.swift b/Sources/ComposableSubscriber/Effect+fail.swift new file mode 100644 index 0000000..12d2b68 --- /dev/null +++ b/Sources/ComposableSubscriber/Effect+fail.swift @@ -0,0 +1,41 @@ +import ComposableArchitecture +import OSLog + +extension Effect { + + /// An effect that throws a runtime warning and optionally logs an error message. + @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) + public static func fail( + _ message: String, + logger: Logger? = nil + ) -> Self { + XCTFail("\(message)") + logger?.error("\(message)") + return .none + } + + /// An effect that throws a runtime warning and optionally logs an error message. + @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) + public static func fail( + prefix: String = "Failed error:", + error: any Error, + logger: Logger + ) -> Self { + return .fail(prefix: prefix, error: error, log: { logger.error("\($0)") }) + } + + /// An effect that throws a runtime warning and optionally logs an error message. + public static func fail( + prefix: String = "Failed error:", + error: any Error, + log: ((String) -> Void)? = nil + ) -> Self { + let message = "\(prefix) \(error.localizedDescription)" + XCTFail("\(message)") + log?("\(message)") + return .none + } + + + +} diff --git a/Sources/ComposableSubscriber/Effect+receive.swift b/Sources/ComposableSubscriber/Effect+receive.swift new file mode 100644 index 0000000..08f6761 --- /dev/null +++ b/Sources/ComposableSubscriber/Effect+receive.swift @@ -0,0 +1,51 @@ +import ComposableArchitecture +import OSLog + +public protocol ReceiveAction { + associatedtype ReceiveAction + static func receive(_ result: TaskResult) -> Self + + var result: TaskResult? { get } +} + +extension ReceiveAction { + + public var result: TaskResult? { + AnyCasePath(unsafe: Self.receive).extract(from: self) + } +} + +extension Effect where Action: ReceiveAction { + + public static func receive( + _ operation: @escaping () async throws -> Action.ReceiveAction + ) -> Self { + .run { send in + await send(.receive( + TaskResult { try await operation() } + )) + } + } + + public static func receive( + _ operation: @escaping () async throws -> T, + transform: @escaping (T) -> Action.ReceiveAction + ) -> Self { + .run { send in + await send(.receive( + TaskResult { try await operation() } + .map(transform) + )) + } + } + + public static func receive( + _ toReceiveAction: CaseKeyPath, + _ operation: @escaping () async throws -> T + ) -> Self { + return .receive(operation) { + AnyCasePath(toReceiveAction).embed($0) + } + } +} + diff --git a/Sources/ComposableSubscriber/ReceiveReducer.swift b/Sources/ComposableSubscriber/ReceiveReducer.swift index 421b7b7..8c7e977 100644 --- a/Sources/ComposableSubscriber/ReceiveReducer.swift +++ b/Sources/ComposableSubscriber/ReceiveReducer.swift @@ -1,18 +1,36 @@ import ComposableArchitecture +import OSLog extension Reducer { + @inlinable + public func onReceive( + action toReceiveAction: CaseKeyPath, + set setAction: @escaping (inout State, V) -> Effect + ) -> _ReceiveReducer { + .init( + parent: self, + receiveAction: { AnyCasePath(toReceiveAction).extract(from: $0) }, + setAction: setAction + ) + } + + @inlinable public func onReceive( action toReceiveAction: CaseKeyPath, set setAction: @escaping (inout State, V) -> Void ) -> _ReceiveReducer { .init( parent: self, - receiveAction: AnyCasePath(toReceiveAction), - setAction: setAction + receiveAction: { AnyCasePath(toReceiveAction).extract(from: $0) }, + setAction: { state, value in + setAction(&state, value) + return .none + } ) } - + + @inlinable public func onReceive( action toReceiveAction: CaseKeyPath, set toStateKeyPath: WritableKeyPath @@ -20,6 +38,7 @@ extension Reducer { self.onReceive(action: toReceiveAction, set: toStateKeyPath.callAsFunction(root:value:)) } + @inlinable public func onReceive( action toReceiveAction: CaseKeyPath, set toStateKeyPath: WritableKeyPath @@ -27,80 +46,98 @@ extension Reducer { self.onReceive(action: toReceiveAction, set: toStateKeyPath.callAsFunction(root:value:)) } + @inlinable public func onReceive( action toReceiveAction: CaseKeyPath>, - onFail: OnFailAction? = nil, + onFail: OnFailAction? = nil, onSuccess setAction: @escaping (inout State, V) -> Void ) -> _ReceiveReducer> { self.onReceive(action: toReceiveAction) { state, result in switch result { case let .failure(error): if let onFail { - onFail(state: &state, error: error) + return onFail(state: &state, error: error) } + return .none case let .success(value): setAction(&state, value) + return .none } } } + @inlinable public func onReceive( action toReceiveAction: CaseKeyPath>, set toStateKeyPath: WritableKeyPath, - onFail: OnFailAction? = nil + onFail: OnFailAction? = nil ) -> _ReceiveReducer> { self.onReceive(action: toReceiveAction) { state, result in switch result { case let .failure(error): if let onFail { - onFail(state: &state, error: error) + return onFail(state: &state, error: error) } + return .none case let .success(value): toStateKeyPath(root: &state, value: value) + return .none } } } + @inlinable public func onReceive( action toReceiveAction: CaseKeyPath>, set toStateKeyPath: WritableKeyPath, - onFail: OnFailAction? = nil + onFail: OnFailAction? = nil ) -> _ReceiveReducer> { self.onReceive(action: toReceiveAction) { state, result in switch result { case let .failure(error): if let onFail { - onFail(state: &state, error: error) + return onFail(state: &state, error: error) } + return .none case let .success(value): toStateKeyPath(root: &state, value: value) + return .none } } } } -public enum OnFailAction { - case xctFail(prefix: String? = nil) +public enum OnFailAction { + case fail(prefix: String? = nil, log: ((String) -> Void)? = nil) case handle((inout State, Error) -> Void) - + + @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) + @inlinable + static func fail(prefix: String? = nil, logger: Logger) -> Self { + .fail(prefix: prefix, log: { logger.error("\($0)") }) + } + @usableFromInline - func callAsFunction(state: inout State, error: Error) { + func callAsFunction(state: inout State, error: Error) -> Effect { switch self { - case let .xctFail(prefix): + case let .fail(prefix, log): if let prefix { - XCTFail("\(prefix): \(error)") + return .fail(prefix: prefix, error: error, log: log) } else { - XCTFail("\(error)") + return .fail(error: error, log: log) } case let .handle(handler): handler(&state, error) + return .none } } + } extension WritableKeyPath { - - public func callAsFunction(root: inout Root, value: Value) { + + @usableFromInline + func callAsFunction(root: inout Root, value: Value) { root[keyPath: self] = value } @@ -112,18 +149,133 @@ public struct _ReceiveReducer: Reducer { let parent: Parent @usableFromInline - let receiveAction: AnyCasePath - + let receiveAction: (Parent.Action) -> Value? + @usableFromInline - let setAction: (inout Parent.State, Value) -> Void - - public func reduce(into state: inout Parent.State, action: Parent.Action) -> Effect { + let setAction: (inout Parent.State, Value) -> Effect + + @usableFromInline + init( + parent: Parent, + receiveAction: @escaping (Parent.Action) -> Value?, + setAction: @escaping (inout Parent.State, Value) -> Effect + ) { + self.parent = parent + self.receiveAction = receiveAction + self.setAction = setAction + } + + @inlinable + public func reduce( + into state: inout Parent.State, + action: Parent.Action + ) -> Effect { let baseEffects = parent.reduce(into: &state, action: action) - - if let value = receiveAction.extract(from: action) { - setAction(&state, value) + var setEffects = Effect.none + + if let value = receiveAction(action) { + setEffects = setAction(&state, value) } - return baseEffects + return .merge(baseEffects, setEffects) } } + +public struct _OnRecieveReducer: Reducer { + + @usableFromInline + let parent: Parent + + @usableFromInline + let triggerAction: (Parent.Action) -> TriggerAction? + + @usableFromInline + let toReceiveAction: (TaskResult) -> Parent.Action + + @usableFromInline + let resultHandler: () async throws -> Value + + @usableFromInline + init( + parent: Parent, + triggerAction: @escaping (Parent.Action) -> TriggerAction?, + toReceiveAction: @escaping (TaskResult) -> Parent.Action, + resultHandler: @escaping () -> Value + ) { + self.parent = parent + self.triggerAction = triggerAction + self.toReceiveAction = toReceiveAction + self.resultHandler = resultHandler + } + + @inlinable + public func reduce( + into state: inout Parent.State, + action: Parent.Action) -> Effect + { + let baseEffects = parent.reduce(into: &state, action: action) + + guard triggerAction(action) != nil else { + return baseEffects + } + + return .merge( + baseEffects, + .run { send in + await send(toReceiveAction( + TaskResult { try await resultHandler() } + )) + } + ) + } +} + +public struct ReceiveReducer: Reducer { + + @usableFromInline + let toResult: (Action) -> TaskResult? + + @usableFromInline + let onFail: OnFailAction? + + @usableFromInline + let onSuccess: (inout State, Action.ReceiveAction) -> Effect + + @inlinable + init( + internal toResult: @escaping (Action) -> TaskResult?, + onFail: OnFailAction?, + onSuccess: @escaping(inout State, Action.ReceiveAction) -> Effect + ) { + self.toResult = toResult + self.onFail = onFail + self.onSuccess = onSuccess + } + + @inlinable + public init( + onFail: OnFailAction? = nil, + onSuccess: @escaping (inout State, Action.ReceiveAction) -> Effect + ) { + self.init( + internal: { + AnyCasePath(unsafe: Action.receive).extract(from: $0) + }, + onFail: onFail, + onSuccess: onSuccess + ) + } + + @inlinable + public func reduce(into state: inout State, action: Action) -> Effect { + guard let result = toResult(action) else { return .none } + switch result { + case let .failure(error): + guard let onFail else { return .none } + return onFail(state: &state, error: error) + case let .success(value): + return onSuccess(&state, value) + } + } + +} diff --git a/Tests/swift-composable-subscriberTests/swift_composable_subscriberTests.swift b/Tests/swift-composable-subscriberTests/TCAExtrasTests.swift similarity index 64% rename from Tests/swift-composable-subscriberTests/swift_composable_subscriberTests.swift rename to Tests/swift-composable-subscriberTests/TCAExtrasTests.swift index b8fa9d9..3c80671 100644 --- a/Tests/swift-composable-subscriberTests/swift_composable_subscriberTests.swift +++ b/Tests/swift-composable-subscriberTests/TCAExtrasTests.swift @@ -6,6 +6,16 @@ import XCTest struct NumberClient { var numberStreamWithoutArg: @Sendable () async -> AsyncStream = { .never } var numberStreamWithArg: @Sendable (Int) async -> AsyncStream = { _ in .never } + var currentNumber: @Sendable () async throws -> Int + + func currentNumber(fail: Bool = false) async throws -> Int { + if fail { + struct CurrentNumberError: Error { } + throw CurrentNumberError() + } + return try await currentNumber() + } + } extension NumberClient: TestDependencyKey { @@ -23,7 +33,8 @@ extension NumberClient: TestDependencyKey { continuation.yield(number) continuation.finish() } - } + }, + currentNumber: { 69420 } ) } @@ -91,9 +102,51 @@ struct ReducerWithTransform { } } +@Reducer +struct ReducerWithReceiveAction { + typealias State = NumberState + + enum Action: ReceiveAction { + + case receive(TaskResult) + case task + + @CasePathable + enum ReceiveAction { + case currentNumber(Int) + } + } + + @Dependency(\.numberClient) var numberClient + + public var body: some Reducer { + ReceiveReducer(onFail: .fail()) { state, action in + switch action { + case let .currentNumber(number): + state.currentNumber = number + return .none + } + } + + Reduce { state, action in + switch action { + + case .receive: + return .none + + case .task: + return .receive(\.currentNumber) { + try await numberClient.currentNumber() + } + } + } + } + +} + @MainActor -final class swift_composable_subscriberTests: XCTestCase { - +final class TCAExtrasTests: XCTestCase { + func testSubscribeWithArg() async throws { let store = TestStore( initialState: ReducerWithArg.State(number: 19), @@ -127,5 +180,22 @@ final class swift_composable_subscriberTests: XCTestCase { await task.cancel() await store.finish() } - + + func testReceiveAction() async throws { + let store = TestStore( + initialState: ReducerWithReceiveAction.State(number: 19), + reducer: ReducerWithReceiveAction.init + ) { + $0.numberClient = .live + } + + let task = await store.send(.task) + await store.receive(\.receive) { + $0.currentNumber = 69420 + } + + await task.cancel() + await store.finish() + } + }