diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3951a09db..c59408d4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,15 +45,8 @@ jobs: name: SwiftPM Linux runs-on: ubuntu-latest steps: - - name: Install Swift - run: | - eval "$(curl -sL https://swiftenv.fuller.li/install.sh)" - - name: Checkout - uses: actions/checkout@v2 - - name: Pull dependencies - run: | - swift package resolve + - uses: actions/checkout@v2 + - name: Swift version + run: swift --version - name: Test via SwiftPM - run: | - swift --version - swift test --enable-test-discovery + run: swift test --enable-test-discovery diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj index 9d98ae65c..27e01099b 100644 --- a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj @@ -374,6 +374,7 @@ DC4C6EA82450DD380066A05D /* UIKitCaseStudies */, DC4C6EBF2450DD390066A05D /* UIKitCaseStudiesTests */, ); + indentWidth = 2; sourceTree = ""; }; DC89C41424460F95006900B9 /* Products */ = { @@ -930,6 +931,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudies; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -949,6 +951,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudies; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -1121,6 +1124,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudies; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -1139,6 +1143,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudies; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; diff --git a/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift b/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift index 598aec399..38f3de070 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift @@ -66,25 +66,31 @@ enum RootAction { } struct RootEnvironment { - var date: () -> Date + var date: @Sendable () -> Date var downloadClient: DownloadClient var fact: FactClient - var favorite: (UUID, Bool) -> Effect - var fetchNumber: () -> Effect + var favorite: @Sendable (UUID, Bool) async throws -> Bool + var fetchNumber: @Sendable () async throws -> Int var mainQueue: DateScheduler - var notificationCenter: NotificationCenter - var uuid: () -> UUID + var screenshots: @Sendable () async -> AsyncStream + var uuid: @Sendable () -> UUID var webSocket: WebSocketClient static let live = Self( - date: Date.init, + date: { Date() }, downloadClient: .live, fact: .live, favorite: favorite(id:isFavorite:), fetchNumber: liveFetchNumber, mainQueue: QueueScheduler.main, - notificationCenter: .default, - uuid: UUID.init, + screenshots: { @MainActor in + AsyncStream( + NotificationCenter.default + .notifications(named: UIApplication.userDidTakeScreenshotNotification) + .map { _ in } + ) + }, + uuid: { UUID() }, webSocket: .live ) } @@ -146,13 +152,13 @@ let rootReducer = Reducer.combine( .pullback( state: \.effectsCancellation, action: /RootAction.effectsCancellation, - environment: { .init(fact: $0.fact, mainQueue: $0.mainQueue) } + environment: { .init(fact: $0.fact) } ), episodesReducer .pullback( state: \.episodes, action: /RootAction.episodes, - environment: { .init(favorite: $0.favorite, mainQueue: $0.mainQueue) } + environment: { .init(favorite: $0.favorite) } ), focusDemoReducer .pullback( @@ -188,7 +194,7 @@ let rootReducer = Reducer.combine( .pullback( state: \.longLivingEffects, action: /RootAction.longLivingEffects, - environment: { .init(notificationCenter: $0.notificationCenter) } + environment: { .init(screenshots: $0.screenshots) } ), mapAppReducer .pullback( @@ -243,9 +249,7 @@ let rootReducer = Reducer.combine( .pullback( state: \.refreshable, action: /RootAction.refreshable, - environment: { - .init(fact: $0.fact, mainQueue: $0.mainQueue) - } + environment: { .init(fact: $0.fact, mainQueue: $0.mainQueue) } ), sharedStateReducer .pullback( @@ -275,7 +279,7 @@ let rootReducer = Reducer.combine( .debug() .signpost() -private func liveFetchNumber() -> Effect { - Effect.deferred { Effect(value: Int.random(in: 1...1_000)) } - .delay(1, on: QueueScheduler.main) +@Sendable private func liveFetchNumber() async throws -> Int { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return Int.random(in: 1...1_000) } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift index 947b2a01f..04de052e7 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import ReactiveSwift -import SwiftUI +@preconcurrency import SwiftUI // NB: SwiftUI.Color and SwiftUI.Animation are not Sendable yet. private let readMe = """ This screen demonstrates how changes to application state can drive animations. Because the \ @@ -19,24 +19,6 @@ private let readMe = """ toggle at the bottom of the screen. """ -extension Effect where Error == Never { - public static func keyFrames( - values: [(output: Value, duration: TimeInterval)], - scheduler: DateScheduler - ) -> Self { - .concatenate( - values - .enumerated() - .map { index, animationState in - index == 0 - ? Effect(value: animationState.output) - : Effect(value: animationState.output) - .delay(values[index - 1].duration, on: scheduler) - } - ) - } -} - struct AnimationsState: Equatable { var alert: AlertState? var circleCenter: CGPoint? @@ -44,7 +26,7 @@ struct AnimationsState: Equatable { var isCircleScaled = false } -enum AnimationsAction: Equatable { +enum AnimationsAction: Equatable, Sendable { case alertDismissed case circleScaleToggleChanged(Bool) case rainbowButtonTapped @@ -72,11 +54,12 @@ let animationsReducer = Reducer) + case numberFactResponse(TaskResult) } struct EffectsBasicsEnvironment { @@ -47,25 +48,42 @@ let effectsBasicsReducer = Reducer< EffectsBasicsAction, EffectsBasicsEnvironment > { state, action, environment in + enum DelayID {} + switch action { case .decrementButtonTapped: state.count -= 1 state.numberFact = nil + // Return an effect that re-increments the count after 1 second if the count is negative + return state.count >= 0 + ? .none + : .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .decrementDelayResponse + } + .cancellable(id: DelayID.self) + + case .decrementDelayResponse: + if state.count < 0 { + state.count += 1 + } return .none case .incrementButtonTapped: state.count += 1 state.numberFact = nil - return .none + return state.count >= 0 + ? .cancel(id: DelayID.self) + : .none case .numberFactButtonTapped: state.isNumberFactRequestInFlight = true state.numberFact = nil // Return an effect that fetches a number fact from the API and returns the // value back to the reducer's `numberFactResponse` action. - return environment.fact.fetch(state.count) - .observe(on: environment.mainQueue) - .catchToEffect(EffectsBasicsAction.numberFactResponse) + return .task { [count = state.count] in + await .numberFactResponse(TaskResult { try await environment.fact.fetch(count) }) + } case let .numberFactResponse(.success(response)): state.isNumberFactRequestInFlight = false diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift index 05f1fbf11..8bb350c3f 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift @@ -18,20 +18,19 @@ private let readMe = """ struct EffectsCancellationState: Equatable { var count = 0 - var currentTrivia: String? - var isTriviaRequestInFlight = false + var currentFact: String? + var isFactRequestInFlight = false } enum EffectsCancellationAction: Equatable { case cancelButtonTapped case stepperChanged(Int) - case triviaButtonTapped - case triviaResponse(Result) + case factButtonTapped + case factResponse(TaskResult) } struct EffectsCancellationEnvironment { var fact: FactClient - var mainQueue: DateScheduler } // MARK: - Business logic @@ -40,35 +39,35 @@ let effectsCancellationReducer = Reducer< EffectsCancellationState, EffectsCancellationAction, EffectsCancellationEnvironment > { state, action, environment in - enum TriviaRequestId {} + enum NumberFactRequestID {} switch action { case .cancelButtonTapped: - state.isTriviaRequestInFlight = false - return .cancel(id: TriviaRequestId.self) + state.isFactRequestInFlight = false + return .cancel(id: NumberFactRequestID.self) case let .stepperChanged(value): state.count = value - state.currentTrivia = nil - state.isTriviaRequestInFlight = false - return .cancel(id: TriviaRequestId.self) - - case .triviaButtonTapped: - state.currentTrivia = nil - state.isTriviaRequestInFlight = true - - return environment.fact.fetch(state.count) - .observe(on: environment.mainQueue) - .catchToEffect(EffectsCancellationAction.triviaResponse) - .cancellable(id: TriviaRequestId.self) - - case let .triviaResponse(.success(response)): - state.isTriviaRequestInFlight = false - state.currentTrivia = response + state.currentFact = nil + state.isFactRequestInFlight = false + return .cancel(id: NumberFactRequestID.self) + + case .factButtonTapped: + state.currentFact = nil + state.isFactRequestInFlight = true + + return .task { [count = state.count] in + await .factResponse(TaskResult { try await environment.fact.fetch(count) }) + } + .cancellable(id: NumberFactRequestID.self) + + case let .factResponse(.success(response)): + state.isFactRequestInFlight = false + state.currentFact = response return .none - case .triviaResponse(.failure): - state.isTriviaRequestInFlight = false + case .factResponse(.failure): + state.isFactRequestInFlight = false return .none } } @@ -91,7 +90,7 @@ struct EffectsCancellationView: View { value: viewStore.binding(get: \.count, send: EffectsCancellationAction.stepperChanged) ) - if viewStore.isTriviaRequestInFlight { + if viewStore.isFactRequestInFlight { HStack { Button("Cancel") { viewStore.send(.cancelButtonTapped) } Spacer() @@ -101,11 +100,11 @@ struct EffectsCancellationView: View { .id(UUID()) } } else { - Button("Number fact") { viewStore.send(.triviaButtonTapped) } - .disabled(viewStore.isTriviaRequestInFlight) + Button("Number fact") { viewStore.send(.factButtonTapped) } + .disabled(viewStore.isFactRequestInFlight) } - viewStore.currentTrivia.map { + viewStore.currentFact.map { Text($0).padding(.vertical, 8) } } @@ -134,8 +133,7 @@ struct EffectsCancellation_Previews: PreviewProvider { initialState: EffectsCancellationState(), reducer: effectsCancellationReducer, environment: EffectsCancellationEnvironment( - fact: .live, - mainQueue: QueueScheduler.main + fact: .live ) ) ) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift index dd672a3e4..c40be80af 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift @@ -4,14 +4,15 @@ import SwiftUI private let readMe = """ This application demonstrates how to handle long-living effects, for example notifications from \ - Notification Center. + Notification Center, and how to tie an effect's lifetime to the lifetime of the view. Run this application in the simulator, and take a few screenshots by going to \ *Device › Screenshot* in the menu, and observe that the UI counts the number of times that \ happens. Then, navigate to another screen and take screenshots there, and observe that this screen does \ - *not* count those screenshots. + *not* count those screenshots. The notifications effect is automatically cancelled when leaving \ + the screen, and restarted when entering the screen. """ // MARK: - Application domain @@ -21,13 +22,12 @@ struct LongLivingEffectsState: Equatable { } enum LongLivingEffectsAction { + case task case userDidTakeScreenshotNotification - case onAppear - case onDisappear } struct LongLivingEffectsEnvironment { - var notificationCenter: NotificationCenter + var screenshots: @Sendable () async -> AsyncStream } // MARK: - Business logic @@ -35,25 +35,18 @@ struct LongLivingEffectsEnvironment { let longLivingEffectsReducer = Reducer< LongLivingEffectsState, LongLivingEffectsAction, LongLivingEffectsEnvironment > { state, action, environment in - - enum UserDidTakeScreenshotNotificationId {} - switch action { + case .task: + // When the view appears, start the effect that emits when screenshots are taken. + return .run { send in + for await _ in await environment.screenshots() { + await send(.userDidTakeScreenshotNotification) + } + } + case .userDidTakeScreenshotNotification: state.screenshotCount += 1 return .none - - case .onAppear: - // When the view appears, start the effect that emits when screenshots are taken. - return environment.notificationCenter - .reactive.notifications(forName: UIApplication.userDidTakeScreenshotNotification) - .producer - .map { _ in LongLivingEffectsAction.userDidTakeScreenshotNotification } - .cancellable(id: UserDidTakeScreenshotNotificationId.self) - - case .onDisappear: - // When view disappears, stop the effect. - return .cancel(id: UserDidTakeScreenshotNotificationId.self) } } @@ -79,8 +72,7 @@ struct LongLivingEffectsView: View { } } .navigationTitle("Long-living effects") - .onAppear { viewStore.send(.onAppear) } - .onDisappear { viewStore.send(.onDisappear) } + .task { await viewStore.send(.task).finish() } } } @@ -105,7 +97,7 @@ struct EffectsLongLiving_Previews: PreviewProvider { initialState: LongLivingEffectsState(), reducer: longLivingEffectsReducer, environment: LongLivingEffectsEnvironment( - notificationCenter: .default + screenshots: { .init { _ in } } ) ) ) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift index 7db04a458..2c46d7929 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift @@ -2,7 +2,7 @@ import ComposableArchitecture import ReactiveSwift import SwiftUI -private var readMe = """ +private let readMe = """ This application demonstrates how to make use of SwiftUI's `refreshable` API in the Composable \ Architecture. Use the "-" and "+" buttons to count up and down, and then pull down to request \ a fact about that number. @@ -15,13 +15,12 @@ private var readMe = """ struct RefreshableState: Equatable { var count = 0 var fact: String? - var isLoading = false } enum RefreshableAction: Equatable { case cancelButtonTapped case decrementButtonTapped - case factResponse(Result) + case factResponse(TaskResult) case incrementButtonTapped case refresh } @@ -37,25 +36,22 @@ let refreshableReducer = Reducer< RefreshableEnvironment > { state, action, environment in - enum CancelId {} + enum FactRequestID {} switch action { case .cancelButtonTapped: - state.isLoading = false - return .cancel(id: CancelId.self) + return .cancel(id: FactRequestID.self) case .decrementButtonTapped: state.count -= 1 return .none case let .factResponse(.success(fact)): - state.isLoading = false state.fact = fact return .none case .factResponse(.failure): - state.isLoading = false - // TODO: do some error handling + // NB: This is where you could do some error handling. return .none case .incrementButtonTapped: @@ -64,15 +60,16 @@ let refreshableReducer = Reducer< case .refresh: state.fact = nil - state.isLoading = true - return environment.fact.fetch(state.count) - .delay(2, on: environment.mainQueue.animation()) - .catchToEffect(RefreshableAction.factResponse) - .cancellable(id: CancelId.self) + return .task { [count = state.count] in + await .factResponse(TaskResult { try await environment.fact.fetch(count) }) + } + .animation() + .cancellable(id: FactRequestID.self) } } struct RefreshableView: View { + @State var isLoading = false let store: Store var body: some View { @@ -105,14 +102,16 @@ struct RefreshableView: View { Text(fact) .bold() } - if viewStore.isLoading { + if self.isLoading { Button("Cancel") { viewStore.send(.cancelButtonTapped, animation: .default) } } } .refreshable { - await viewStore.send(.refresh, while: \.isLoading) + self.isLoading = true + defer { self.isLoading = false } + await viewStore.send(.refresh).finish() } } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift index f3ebc4b01..d481a2895 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift @@ -35,7 +35,7 @@ enum MultipleDependenciesAction: Equatable { } struct MultipleDependenciesEnvironment { - var fetchNumber: () -> Effect + var fetchNumber: @Sendable () async throws -> Int } let multipleDependenciesReducer = Reducer< @@ -46,8 +46,10 @@ let multipleDependenciesReducer = Reducer< switch action { case .alertButtonTapped: - return Effect(value: .alertDelayReceived) - .delay(1, on: environment.mainQueue) + return .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .alertDelayReceived + } case .alertDelayReceived: state.alert = AlertState(title: TextState("Here's an alert after a delay!")) @@ -63,8 +65,7 @@ let multipleDependenciesReducer = Reducer< case .fetchNumberButtonTapped: state.isFetchInFlight = true - return environment.fetchNumber() - .map(MultipleDependenciesAction.fetchNumberResponse) + return .task { .fetchNumberResponse(try await environment.fetchNumber()) } case let .fetchNumberResponse(number): state.isFetchInFlight = false @@ -154,8 +155,8 @@ struct MultipleDependenciesView_Previews: PreviewProvider { environment: .live( environment: MultipleDependenciesEnvironment( fetchNumber: { - Effect(value: Int.random(in: 1...1_000)) - .delay(1, on: QueueScheduler.main) + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return Int.random(in: 1...1_000) } ) ) @@ -167,10 +168,10 @@ struct MultipleDependenciesView_Previews: PreviewProvider { @dynamicMemberLookup struct SystemEnvironment { - var date: () -> Date + var date: @Sendable () -> Date var environment: Environment var mainQueue: DateScheduler - var uuid: () -> UUID + var uuid: @Sendable () -> UUID subscript( dynamicMember keyPath: WritableKeyPath @@ -185,10 +186,10 @@ struct SystemEnvironment { /// - Returns: A new system environment. static func live(environment: Environment) -> Self { Self( - date: Date.init, + date: { Date() }, environment: environment, mainQueue: QueueScheduler.main, - uuid: UUID.init + uuid: { UUID() } ) } @@ -205,15 +206,21 @@ struct SystemEnvironment { } } +extension SystemEnvironment: Sendable where Environment: Sendable {} + #if DEBUG import XCTestDynamicOverlay extension SystemEnvironment { static func unimplemented( - date: @escaping () -> Date = XCTUnimplemented("\(Self.self).date", placeholder: Date()), + date: @escaping @Sendable () -> Date = XCTUnimplemented( + "\(Self.self).date", placeholder: Date() + ), environment: Environment, mainQueue: DateScheduler = UnimplementedScheduler(), - uuid: @escaping () -> UUID = XCTUnimplemented("\(Self.self).uuid", placeholder: UUID()) + uuid: @escaping @Sendable () -> UUID = XCTUnimplemented( + "\(Self.self).uuid", placeholder: UUID() + ) ) -> Self { Self( date: date, diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift index 4610eb48e..a240c3070 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift @@ -1,14 +1,14 @@ import Combine import ComposableArchitecture import ReactiveSwift -import SwiftUI +@preconcurrency import SwiftUI // NB: SwiftUI.Animation is not Sendable yet. private let readMe = """ This application demonstrates how to work with timers in the Composable Architecture. - Although the Combine framework comes with a `Timer.publisher` API, and it is possible to use \ - that API in the Composable Architecture, it is not easy to test. That is why we have provided an \ - `Effect.timer` API that works with schedulers and can be tested. + It makes use of the `.timer` method on Combine Schedulers, which is a helper provided by the \ + Combine Schedulers library included with this library. The helper provides an \ + `AsyncSequence`-friendly API for dealing with timers in asynchronous code. """ // MARK: - Timer feature domain @@ -30,7 +30,7 @@ struct TimersEnvironment { let timersReducer = Reducer { state, action, environment in - enum TimerId {} + enum TimerID {} switch action { case .timerTicked: @@ -39,15 +39,13 @@ let timersReducer = Reducer { case .toggleTimerButtonTapped: state.isTimerActive.toggle() - return state.isTimerActive - ? Effect.timer( - id: TimerId.self, - every: .seconds(1), - tolerance: .seconds(0), - on: environment.mainQueue.animation(.interpolatingSpring(stiffness: 3000, damping: 40)) - ) - .map { _ in TimersAction.timerTicked } - : .cancel(id: TimerId.self) + return .run { [isTimerActive = state.isTimerActive] send in + guard isTimerActive else { return } + for await _ in environment.mainQueue.timer(interval: .seconds(1)) { + await send(.timerTicked, animation: .interpolatingSpring(stiffness: 3000, damping: 40)) + } + } + .cancellable(id: TimerID.self, cancelInFlight: true) } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift index d161dcdc9..0e0d1de14 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import ReactiveSwift import SwiftUI +import XCTestDynamicOverlay private let readMe = """ This application demonstrates how to work with a web socket in the Composable Architecture. @@ -27,10 +28,9 @@ enum WebSocketAction: Equatable { case alertDismissed case connectButtonTapped case messageToSendChanged(String) - case pingResponse(NSError?) - case receivedSocketMessage(Result) + case receivedSocketMessage(TaskResult) case sendButtonTapped - case sendResponse(NSError?) + case sendResponse(didSucceed: Bool) case webSocket(WebSocketClient.Action) } @@ -42,20 +42,7 @@ struct WebSocketEnvironment { let webSocketReducer = Reducer { state, action, environment in - struct WebSocketId: Hashable {} - - var receiveSocketMessageEffect: Effect { - return environment.webSocket.receive(WebSocketId()) - .observe(on: environment.mainQueue) - .catchToEffect(WebSocketAction.receivedSocketMessage) - .cancellable(id: WebSocketId()) - } - var sendPingEffect: Effect { - return environment.webSocket.sendPing(WebSocketId()) - .delay(10, on: environment.mainQueue) - .map(WebSocketAction.pingResponse) - .cancellable(id: WebSocketId()) - } + enum WebSocketID {} switch action { case .alertDismissed: @@ -66,35 +53,48 @@ let webSocketReducer = Reducer Bool { - switch (lhs, rhs) { - case let (.data(lhs), .data(rhs)): - return lhs == rhs - case let (.string(lhs), .string(rhs)): - return lhs == rhs - case (.data, _), (.string, _): - return false + case let .data(data): self = .data(data) + case let .string(string): self = .string(string) + @unknown default: throw Unknown() } } } - var cancel: (AnyHashable, URLSessionWebSocketTask.CloseCode, Data?) -> Effect - var open: (AnyHashable, URL, [String]) -> Effect - var receive: (AnyHashable) -> Effect - var send: (AnyHashable, URLSessionWebSocketTask.Message) -> Effect - var sendPing: (AnyHashable) -> Effect + var open: @Sendable (Any.Type, URL, [String]) async -> AsyncStream + var receive: @Sendable (Any.Type) async throws -> AsyncStream> + var send: @Sendable (Any.Type, URLSessionWebSocketTask.Message) async throws -> Void + var sendPing: @Sendable (Any.Type) async throws -> Void } extension WebSocketClient { - static let live = WebSocketClient( - cancel: { id, closeCode, reason in - .fireAndForget { - dependencies[id]?.task.cancel(with: closeCode, reason: reason) - dependencies[id]?.subscriber.sendCompleted() - dependencies[id] = nil - } - }, - open: { id, url, protocols in - Effect { subscriber, lifetime in - let delegate = WebSocketDelegate( - didBecomeInvalidWithError: { - subscriber.send(value: .didBecomeInvalidWithError($0 as NSError?)) - }, - didClose: { - subscriber.send(value: .didClose(code: $0, reason: $1)) - }, - didCompleteWithError: { - subscriber.send(value: .didCompleteWithError($0 as NSError?)) - }, - didOpenWithProtocol: { - subscriber.send(value: .didOpenWithProtocol($0)) - } - ) - let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) - let task = session.webSocketTask(with: url, protocols: protocols) - task.resume() - dependencies[id] = Dependencies(delegate: delegate, subscriber: subscriber, task: task) - lifetime += AnyDisposable { - task.cancel(with: .normalClosure, reason: nil) - dependencies[id]?.subscriber.sendCompleted() - dependencies[id] = nil + static var live: Self { + final actor WebSocketActor: GlobalActor { + final class Delegate: NSObject, URLSessionWebSocketDelegate { + var continuation: AsyncStream.Continuation? + + func urlSession( + _: URLSession, + webSocketTask _: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String? + ) { + self.continuation?.yield(.didOpen(protocol: `protocol`)) + } + + func urlSession( + _: URLSession, + webSocketTask _: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) { + self.continuation?.yield(.didClose(code: closeCode, reason: reason)) + self.continuation?.finish() } } - }, - receive: { id in - .future { callback in - dependencies[id]?.task.receive { result in - switch result.map(Message.init) { - case let .success(.some(message)): - callback(.success(message)) - case .success(.none): - callback(.failure(NSError(domain: "co.pointfree", code: 1))) - case let .failure(error): - callback(.failure(error as NSError)) + + typealias Dependencies = (socket: URLSessionWebSocketTask, delegate: Delegate) + + static let shared = WebSocketActor() + + var dependencies: [ObjectIdentifier: Dependencies] = [:] + + func open(id: Any.Type, url: URL, protocols: [String]) -> AsyncStream { + let id = ObjectIdentifier(id) + let delegate = Delegate() + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let socket = session.webSocketTask(with: url, protocols: protocols) + defer { socket.resume() } + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { + $0.onTermination = { _ in + socket.cancel() + Task { await self.removeDependencies(id: id) } } + continuation = $0 } + delegate.continuation = continuation + self.dependencies[id] = (socket, delegate) + return stream } - }, - send: { id, message in - .future { callback in - dependencies[id]?.task.send(message) { error in - callback(.success(error as NSError?)) - } + + func close( + id: Any.Type, with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data? + ) async throws { + let id = ObjectIdentifier(id) + defer { self.dependencies[id] = nil } + try self.socket(id: id).cancel(with: closeCode, reason: reason) } - }, - sendPing: { id in - .future { callback in - dependencies[id]?.task.sendPing { error in - callback(.success(error as NSError?)) + + func receive(id: Any.Type) throws -> AsyncStream> { + let socket = try self.socket(id: ObjectIdentifier(id)) + return AsyncStream { continuation in + let task = Task { + while !Task.isCancelled { + continuation.yield(await TaskResult { try await Message(socket.receive()) }) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } } } - } - ) -} -private var dependencies: [AnyHashable: Dependencies] = [:] -private struct Dependencies { - let delegate: URLSessionWebSocketDelegate - let subscriber: Signal.Observer - let task: URLSessionWebSocketTask -} + func send(id: Any.Type, message: URLSessionWebSocketTask.Message) async throws { + try await self.socket(id: ObjectIdentifier(id)).send(message) + } -private class WebSocketDelegate: NSObject, URLSessionWebSocketDelegate { - let didBecomeInvalidWithError: (Error?) -> Void - let didClose: (URLSessionWebSocketTask.CloseCode, Data?) -> Void - let didCompleteWithError: (Error?) -> Void - let didOpenWithProtocol: (String?) -> Void - - init( - didBecomeInvalidWithError: @escaping (Error?) -> Void, - didClose: @escaping (URLSessionWebSocketTask.CloseCode, Data?) -> Void, - didCompleteWithError: @escaping (Error?) -> Void, - didOpenWithProtocol: @escaping (String?) -> Void - ) { - self.didBecomeInvalidWithError = didBecomeInvalidWithError - self.didOpenWithProtocol = didOpenWithProtocol - self.didCompleteWithError = didCompleteWithError - self.didClose = didClose - } + func sendPing(id: Any.Type) async throws { + let socket = try self.socket(id: ObjectIdentifier(id)) + return try await withCheckedThrowingContinuation { continuation in + socket.sendPing { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didOpenWithProtocol protocol: String? - ) { - self.didOpenWithProtocol(`protocol`) - } + private func socket(id: ObjectIdentifier) throws -> URLSessionWebSocketTask { + guard let dependencies = self.dependencies[id]?.socket else { + struct Closed: Error {} + throw Closed() + } + return dependencies + } - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, - reason: Data? - ) { - self.didClose(closeCode, reason) - } + private func removeDependencies(id: ObjectIdentifier) { + self.dependencies[id] = nil + } + } - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - self.didCompleteWithError(error) + return Self( + open: { await WebSocketActor.shared.open(id: $0, url: $1, protocols: $2) }, + receive: { try await WebSocketActor.shared.receive(id: $0) }, + send: { try await WebSocketActor.shared.send(id: $0, message: $1) }, + sendPing: { try await WebSocketActor.shared.sendPing(id: $0) } + ) } +} - func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { - self.didBecomeInvalidWithError(error) - } +extension WebSocketClient { + static let unimplemented = Self( + open: XCTUnimplemented("\(Self.self).open", placeholder: AsyncStream.never), + receive: XCTUnimplemented("\(Self.self).receive"), + send: XCTUnimplemented("\(Self.self).send"), + sendPing: XCTUnimplemented("\(Self.self).sendPing") + ) } // MARK: - SwiftUI previews diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift index 902967f04..f2588a881 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import Foundation import ReactiveSwift import SwiftUI @@ -54,30 +55,31 @@ let loadThenNavigateListReducer = LoadThenNavigateListState, LoadThenNavigateListAction, LoadThenNavigateListEnvironment > { state, action, environment in - enum CancelId {} + enum CancelID {} switch action { case .counter: return .none case .onDisappear: - return .cancel(id: CancelId.self) + return .cancel(id: CancelID.self) case let .setNavigation(selection: .some(navigatedId)): for row in state.rows { state.rows[id: row.id]?.isActivityIndicatorVisible = row.id == navigatedId } - - return Effect(value: .setNavigationSelectionDelayCompleted(navigatedId)) - .delay(1, on: environment.mainQueue) - .cancellable(id: CancelId.self, cancelInFlight: true) + return .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .setNavigationSelectionDelayCompleted(navigatedId) + } + .cancellable(id: CancelID.self, cancelInFlight: true) case .setNavigation(selection: .none): if let selection = state.selection { state.rows[id: selection.id]?.count = selection.count } state.selection = nil - return .cancel(id: CancelId.self) + return .cancel(id: CancelID.self) case let .setNavigationSelectionDelayCompleted(id): state.rows[id: id]?.isActivityIndicatorVisible = false diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift index e8f0a063c..ff8b1dba7 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift @@ -48,7 +48,7 @@ let navigateAndLoadListReducer = NavigateAndLoadListState, NavigateAndLoadListAction, NavigateAndLoadListEnvironment > { state, action, environment in - enum CancelId {} + enum CancelID {} switch action { case .counter: @@ -56,17 +56,18 @@ let navigateAndLoadListReducer = case let .setNavigation(selection: .some(id)): state.selection = Identified(nil, id: id) - - return Effect(value: .setNavigationSelectionDelayCompleted) - .delay(1, on: environment.mainQueue) - .cancellable(id: CancelId.self) + return .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .setNavigationSelectionDelayCompleted + } + .cancellable(id: CancelID.self, cancelInFlight: true) case .setNavigation(selection: .none): if let selection = state.selection, let count = selection.value?.count { state.rows[id: selection.id]?.count = count } state.selection = nil - return .cancel(id: CancelId.self) + return .cancel(id: CancelID.self) case .setNavigationSelectionDelayCompleted: guard let id = state.selection?.id else { return .none } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift index aa7d14d9c..23038747d 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift @@ -41,17 +41,19 @@ let loadThenNavigateReducer = LoadThenNavigateState, LoadThenNavigateAction, LoadThenNavigateEnvironment > { state, action, environment in - enum CancelId {} + enum CancelID {} switch action { case .onDisappear: - return .cancel(id: CancelId.self) + return .cancel(id: CancelID.self) case .setNavigation(isActive: true): state.isActivityIndicatorVisible = true - return Effect(value: .setNavigationIsActiveDelayCompleted) - .delay(1, on: environment.mainQueue) - .cancellable(id: CancelId.self) + return .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .setNavigationIsActiveDelayCompleted + } + .cancellable(id: CancelID.self) case .setNavigation(isActive: false): state.optionalCounter = nil diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift index ab7fe7b7b..7a532eb62 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift @@ -39,19 +39,21 @@ let navigateAndLoadReducer = NavigateAndLoadState, NavigateAndLoadAction, NavigateAndLoadEnvironment > { state, action, environment in - enum CancelId {} + enum CancelID {} switch action { case .setNavigation(isActive: true): state.isNavigationActive = true - return Effect(value: .setNavigationIsActiveDelayCompleted) - .delay(1, on: environment.mainQueue) - .cancellable(id: CancelId.self) + return .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .setNavigationIsActiveDelayCompleted + } + .cancellable(id: CancelID.self) case .setNavigation(isActive: false): state.isNavigationActive = false state.optionalCounter = nil - return .cancel(id: CancelId.self) + return .cancel(id: CancelID.self) case .setNavigationIsActiveDelayCompleted: state.optionalCounter = CounterState() diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift index eec0e7648..a7d09f744 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift @@ -41,17 +41,19 @@ let loadThenPresentReducer = LoadThenPresentState, LoadThenPresentAction, LoadThenPresentEnvironment > { state, action, environment in - enum CancelId {} + enum CancelID {} switch action { case .onDisappear: - return .cancel(id: CancelId.self) + return .cancel(id: CancelID.self) case .setSheet(isPresented: true): state.isActivityIndicatorVisible = true - return Effect(value: .setSheetIsPresentedDelayCompleted) - .delay(1, on: environment.mainQueue) - .cancellable(id: CancelId.self) + return .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .setSheetIsPresentedDelayCompleted + } + .cancellable(id: CancelID.self) case .setSheet(isPresented: false): state.optionalCounter = nil diff --git a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift index b86421165..95e1855fd 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift @@ -37,19 +37,21 @@ let presentAndLoadReducer = PresentAndLoadState, PresentAndLoadAction, PresentAndLoadEnvironment > { state, action, environment in - enum CancelId {} + enum CancelID {} switch action { case .setSheet(isPresented: true): state.isSheetPresented = true - return Effect(value: .setSheetIsPresentedDelayCompleted) - .delay(1, on: environment.mainQueue) - .cancellable(id: CancelId.self) + return .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .setSheetIsPresentedDelayCompleted + } + .cancellable(id: CancelID.self) case .setSheet(isPresented: false): state.isSheetPresented = false state.optionalCounter = nil - return .cancel(id: CancelId.self) + return .cancel(id: CancelID.self) case .setSheetIsPresentedDelayCompleted: state.optionalCounter = CounterState() diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift index efadae50f..f91f34526 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import ReactiveSwift import SwiftUI +@preconcurrency import SwiftUI // NB: SwiftUI.Animation is not Sendable yet. private let readMe = """ This screen demonstrates how the `Reducer` struct can be extended to enhance reducers with \ @@ -61,18 +62,14 @@ let clockReducer = Reducer.combine( } }, .subscriptions { state, environment in - struct TimerId: Hashable {} guard state.isTimerActive else { return [:] } + struct TimerID: Hashable {} return [ - TimerId(): - Effect - .timer( - id: TimerId(), - every: .seconds(1), - tolerance: .seconds(0), - on: environment.mainQueue.animation(.interpolatingSpring(stiffness: 3000, damping: 40)) - ) - .map { _ in .timerTicked } + TimerID(): .run { send in + for await _ in environment.mainQueue.timer(interval: .seconds(1)) { + await send(.timerTicked, animation: .interpolatingSpring(stiffness: 3000, damping: 40)) + } + } ] } ) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Lifecycle.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Lifecycle.swift index ce5e5d6da..d80ed5d2b 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Lifecycle.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Lifecycle.swift @@ -96,7 +96,7 @@ struct LifecycleDemoView: View { } } -private enum TimerId {} +private enum TimerID {} enum TimerAction { case decrementButtonTapped @@ -125,12 +125,16 @@ private let timerReducer = Reducer { } } .lifecycle( - onAppear: { - Effect.timer(id: TimerId.self, every: .seconds(1), tolerance: .seconds(0), on: $0.mainQueue) - .map { _ in TimerAction.tick } + onAppear: { environment in + .run { send in + for await _ in environment.mainQueue.timer(interval: .seconds(1)) { + await send(.tick) + } + } + .cancellable(id: TimerID.self) }, onDisappear: { _ in - .cancel(id: TimerId.self) + .cancel(id: TimerID.self) } ) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift index eb3405da4..c1119f08c 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift @@ -1,14 +1,11 @@ import Combine import ComposableArchitecture import Foundation -import ReactiveSwift struct DownloadClient { - var download: (URL) -> Effect + var download: @Sendable (URL) -> AsyncThrowingStream - struct Error: Swift.Error, Equatable {} - - enum Action: Equatable { + enum Event: Equatable { case response(Data) case updateProgress(Double) } @@ -17,28 +14,27 @@ struct DownloadClient { extension DownloadClient { static let live = DownloadClient( download: { url in - .init { subscriber, lifetime in - let task = URLSession.shared.dataTask(with: url) { data, _, error in - switch (data, error) { - case let (.some(data), _): - subscriber.send(value: .response(data)) - subscriber.sendCompleted() - case let (_, .some(error)): - subscriber.send(error: Error()) - case (.none, .none): - fatalError("Data and Error should not both be nil") + .init { continuation in + Task { + do { + let (bytes, response) = try await URLSession.shared.bytes(from: url) + var data = Data() + var progress = 0 + for try await byte in bytes { + data.append(byte) + let newProgress = Int( + Double(data.count) / Double(response.expectedContentLength) * 100) + if newProgress != progress { + progress = newProgress + continuation.yield(.updateProgress(Double(progress) / 100)) + } + } + continuation.yield(.response(data)) + continuation.finish() + } catch { + continuation.finish(throwing: error) } } - - let observation = task.progress.observe(\.fractionCompleted) { progress, _ in - subscriber.send(value: .updateProgress(progress.fractionCompleted)) - } - - lifetime += AnyDisposable { - observation.invalidate() - task.cancel() - } - task.resume() } } ) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift index b7ba04cad..36431e249 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import ReactiveSwift -import SwiftUI +@preconcurrency import SwiftUI // NB: SwiftUI.Animation is not Sendable yet. struct DownloadComponentState: Equatable { var alert: AlertState? @@ -33,7 +33,7 @@ enum Mode: Equatable { enum DownloadComponentAction: Equatable { case alert(AlertAction) case buttonTapped - case downloadClient(Result) + case downloadClient(TaskResult) enum AlertAction: Equatable { case deleteButtonTapped @@ -45,7 +45,6 @@ enum DownloadComponentAction: Equatable { struct DownloadComponentEnvironment { var downloadClient: DownloadClient - var mainQueue: DateScheduler } extension Reducer { @@ -85,11 +84,15 @@ extension Reducer { case .notDownloaded: state.mode = .startingToDownload - return environment.downloadClient - .download(state.url) - .throttle(1, on: environment.mainQueue) - .catchToEffect(DownloadComponentAction.downloadClient) - .cancellable(id: state.id) + + return .run { [url = state.url] send in + for try await event in environment.downloadClient.download(url) { + await send(.downloadClient(.success(event)), animation: .default) + } + } catch: { error, send in + await send(.downloadClient(.failure(error)), animation: .default) + } + .cancellable(id: state.id) case .startingToDownload: state.alert = stopAlert @@ -119,13 +122,19 @@ extension Reducer { private let deleteAlert = AlertState( title: TextState("Do you want to delete this map from your offline storage?"), - primaryButton: .destructive(TextState("Delete"), action: .send(.deleteButtonTapped)), + primaryButton: .destructive( + TextState("Delete"), + action: .send(.deleteButtonTapped, animation: .default) + ), secondaryButton: nevermindButton ) private let stopAlert = AlertState( title: TextState("Do you want to stop downloading this map?"), - primaryButton: .destructive(TextState("Stop"), action: .send(.stopButtonTapped)), + primaryButton: .destructive( + TextState("Stop"), + action: .send(.stopButtonTapped, animation: .default) + ), secondaryButton: nevermindButton ) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift index 1797f12d0..629eeb924 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift @@ -59,11 +59,11 @@ let cityMapReducer = Reducer { state, action, environment in switch action { case let .downloadComponent(.downloadClient(.success(.response(data)))): - // TODO: save to disk + // NB: This is where you could perform the effect to save the data to a file on disk. return .none case .downloadComponent(.alert(.deleteButtonTapped)): - // TODO: delete file from disk + // NB: This is where you could perform the effect to delete the data from disk. return .none case .downloadComponent: @@ -73,12 +73,7 @@ let cityMapReducer = Reducer { .downloadable( state: \.downloadComponent, action: /CityMapAction.downloadComponent, - environment: { - DownloadComponentEnvironment( - downloadClient: $0.downloadClient, - mainQueue: $0.mainQueue - ) - } + environment: { DownloadComponentEnvironment(downloadClient: $0.downloadClient) } ) struct CityMapRowView: View { diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift index 7768685e4..8d3ae9021 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift @@ -1,5 +1,6 @@ import Combine import ComposableArchitecture +import Foundation import ReactiveSwift import SwiftUI @@ -30,27 +31,18 @@ struct FavoriteState: Equatable, Identifiable { enum FavoriteAction: Equatable { case alertDismissed case buttonTapped - case response(Result) + case response(TaskResult) } -struct FavoriteEnvironment { - var request: (ID, Bool) -> Effect - var mainQueue: DateScheduler +struct FavoriteEnvironment { + var request: @Sendable (ID, Bool) async throws -> Bool } /// A cancellation token that cancels in-flight favoriting requests. -struct FavoriteCancelId: Hashable { +struct FavoriteCancelID: Hashable { var id: ID } -/// A wrapper for errors that occur when favoriting. -struct FavoriteError: Equatable, Error, Identifiable { - let error: NSError - var localizedDescription: String { self.error.localizedDescription } - var id: String { self.error.localizedDescription } - static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } -} - extension Reducer { /// Enhances a reducer with favoriting logic. func favorite( @@ -71,11 +63,10 @@ extension Reducer { case .buttonTapped: state.isFavorite.toggle() - return environment.request(state.id, state.isFavorite) - .observe(on: environment.mainQueue) - .mapError { FavoriteError(error: $0 as NSError) } - .catchToEffect(FavoriteAction.response) - .cancellable(id: FavoriteCancelId(id: state.id), cancelInFlight: true) + return .task { [id = state.id, isFavorite = state.isFavorite] in + await .response(TaskResult { try await environment.request(id, isFavorite) }) + } + .cancellable(id: FavoriteCancelID(id: state.id), cancelInFlight: true) case let .response(.failure(error)): state.alert = AlertState(title: TextState(error.localizedDescription)) @@ -126,8 +117,7 @@ enum EpisodeAction: Equatable { } struct EpisodeEnvironment { - var favorite: (EpisodeState.ID, Bool) -> Effect - var mainQueue: DateScheduler + var favorite: @Sendable (EpisodeState.ID, Bool) async throws -> Bool } struct EpisodeView: View { @@ -150,7 +140,7 @@ struct EpisodeView: View { let episodeReducer = Reducer.empty.favorite( state: \.favorite, action: /EpisodeAction.favorite, - environment: { FavoriteEnvironment(request: $0.favorite, mainQueue: $0.mainQueue) } + environment: { FavoriteEnvironment(request: $0.favorite) } ) struct EpisodesState: Equatable { @@ -162,15 +152,14 @@ enum EpisodesAction: Equatable { } struct EpisodesEnvironment { - var favorite: (UUID, Bool) -> Effect - var mainQueue: DateScheduler + var favorite: @Sendable (UUID, Bool) async throws -> Bool } let episodesReducer: Reducer = episodeReducer.forEach( state: \EpisodesState.episodes, action: /EpisodesAction.episode(id:action:), - environment: { EpisodeEnvironment(favorite: $0.favorite, mainQueue: $0.mainQueue) } + environment: { EpisodeEnvironment(favorite: $0.favorite) } ) struct EpisodesView: View { @@ -202,8 +191,7 @@ struct EpisodesView_Previews: PreviewProvider { ), reducer: episodesReducer, environment: EpisodesEnvironment( - favorite: favorite(id:isFavorite:), - mainQueue: QueueScheduler.main + favorite: favorite(id:isFavorite:) ) ) ) @@ -211,22 +199,18 @@ struct EpisodesView_Previews: PreviewProvider { } } -func favorite(id: ID, isFavorite: Bool) -> Effect { - Effect.future { callback in - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if .random(in: 0...1) > 0.25 { - callback(.success(isFavorite)) - } else { - callback( - .failure( - NSError( - domain: "co.pointfree", code: -1, - userInfo: [NSLocalizedDescriptionKey: "Something went wrong!"] - ) - ) - ) - } - } +struct FavoriteError: LocalizedError { + var errorDescription: String? { + "Favoriting failed." + } +} + +@Sendable func favorite(id: ID, isFavorite: Bool) async throws -> Bool { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + if .random(in: 0...1) > 0.25 { + return isFavorite + } else { + throw FavoriteError() } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift b/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift index e8986aa2e..6e75a6a37 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift @@ -4,9 +4,7 @@ import ReactiveSwift import XCTestDynamicOverlay struct FactClient { - var fetch: (Int) -> Effect - - struct Failure: Error, Equatable {} + var fetch: @Sendable (Int) async throws -> String } // This is the "live" fact dependency that reaches into the outside world to fetch trivia. @@ -15,13 +13,10 @@ struct FactClient { extension FactClient { static let live = Self( fetch: { number in - Effect.task { - try await Task.sleep(nanoseconds: NSEC_PER_SEC) - let (data, _) = try await URLSession.shared - .data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!) - return String(decoding: data, as: UTF8.self) - } - .mapError { _ in Failure() } + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + let (data, _) = try await URLSession.shared + .data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!) + return String(decoding: data, as: UTF8.self) } ) } @@ -31,7 +26,7 @@ extension FactClient { // This is the "unimplemented" fact dependency that is useful to plug into tests that you want // to prove do not need the dependency. static let unimplemented = Self( - fetch: { _ in .unimplemented("\(Self.self).fetch") } + fetch: XCTUnimplemented("\(Self.self).fetch") ) } #endif diff --git a/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift b/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift index da5e2a058..ff3d90df4 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift @@ -7,6 +7,7 @@ /// to a binding that may disable the fields. /// /// See also: https://stackoverflow.com/a/69653555 + @MainActor func resignFirstResponder() -> Self { Self( get: { self.wrappedValue }, diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AlertsAndConfirmationDialogsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AlertsAndConfirmationDialogsTests.swift index 0438e14be..83873465a 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AlertsAndConfirmationDialogsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AlertsAndConfirmationDialogsTests.swift @@ -5,15 +5,16 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class AlertsAndConfirmationDialogsTests: XCTestCase { - func testAlert() { + func testAlert() async { let store = TestStore( initialState: AlertAndConfirmationDialogState(), reducer: alertAndConfirmationDialogReducer, environment: AlertAndConfirmationDialogEnvironment() ) - store.send(.alertButtonTapped) { + await store.send(.alertButtonTapped) { $0.alert = AlertState( title: TextState("Alert!"), message: TextState("This is an alert"), @@ -21,23 +22,23 @@ class AlertsAndConfirmationDialogsTests: XCTestCase { secondaryButton: .default(TextState("Increment"), action: .send(.incrementButtonTapped)) ) } - store.send(.incrementButtonTapped) { + await store.send(.incrementButtonTapped) { $0.alert = AlertState(title: TextState("Incremented!")) $0.count = 1 } - store.send(.alertDismissed) { + await store.send(.alertDismissed) { $0.alert = nil } } - func testConfirmationDialog() { + func testConfirmationDialog() async { let store = TestStore( initialState: AlertAndConfirmationDialogState(), reducer: alertAndConfirmationDialogReducer, environment: AlertAndConfirmationDialogEnvironment() ) - store.send(.confirmationDialogButtonTapped) { + await store.send(.confirmationDialogButtonTapped) { $0.confirmationDialog = ConfirmationDialogState( title: TextState("Confirmation dialog"), message: TextState("This is a confirmation dialog."), @@ -48,11 +49,11 @@ class AlertsAndConfirmationDialogsTests: XCTestCase { ] ) } - store.send(.incrementButtonTapped) { + await store.send(.incrementButtonTapped) { $0.alert = AlertState(title: TextState("Incremented!")) $0.count = 1 } - store.send(.confirmationDialogDismissed) { + await store.send(.confirmationDialogDismissed) { $0.confirmationDialog = nil } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AnimationsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AnimationsTests.swift index 23e64a26f..a94096d13 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AnimationsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AnimationsTests.swift @@ -4,63 +4,66 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class AnimationTests: XCTestCase { - let mainQueue = TestScheduler() + func testRainbow() async { + let mainQueue = TestScheduler() - func testRainbow() { let store = TestStore( initialState: AnimationsState(), reducer: animationsReducer, environment: AnimationsEnvironment( - mainQueue: self.mainQueue + mainQueue: mainQueue ) ) - store.send(.rainbowButtonTapped) + await store.send(.rainbowButtonTapped) - store.receive(.setColor(.red)) { + await store.receive(.setColor(.red)) { $0.circleColor = .red } - self.mainQueue.advance(by: 1) - store.receive(.setColor(.blue)) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.setColor(.blue)) { $0.circleColor = .blue } - self.mainQueue.advance(by: 1) - store.receive(.setColor(.green)) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.setColor(.green)) { $0.circleColor = .green } - self.mainQueue.advance(by: 1) - store.receive(.setColor(.orange)) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.setColor(.orange)) { $0.circleColor = .orange } - self.mainQueue.advance(by: 1) - store.receive(.setColor(.pink)) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.setColor(.pink)) { $0.circleColor = .pink } - self.mainQueue.advance(by: 1) - store.receive(.setColor(.purple)) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.setColor(.purple)) { $0.circleColor = .purple } - self.mainQueue.advance(by: 1) - store.receive(.setColor(.yellow)) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.setColor(.yellow)) { $0.circleColor = .yellow } - self.mainQueue.advance(by: 1) - store.receive(.setColor(.black)) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.setColor(.black)) { $0.circleColor = .black } - self.mainQueue.run() + await mainQueue.advance(by: .seconds(1)) + + await mainQueue.advance(by: .seconds(10)) } - func testReset() { + func testReset() async { let mainQueue = TestScheduler() let store = TestStore( @@ -71,18 +74,18 @@ class AnimationTests: XCTestCase { ) ) - store.send(.rainbowButtonTapped) + await store.send(.rainbowButtonTapped) - store.receive(.setColor(.red)) { + await store.receive(.setColor(.red)) { $0.circleColor = .red } - mainQueue.advance(by: .seconds(1)) - store.receive(.setColor(.blue)) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.setColor(.blue)) { $0.circleColor = .blue } - store.send(.resetButtonTapped) { + await store.send(.resetButtonTapped) { $0.alert = AlertState( title: TextState("Reset state?"), primaryButton: .destructive( @@ -93,10 +96,8 @@ class AnimationTests: XCTestCase { ) } - store.send(.resetConfirmationButtonTapped) { + await store.send(.resetConfirmationButtonTapped) { $0 = AnimationsState() } - - mainQueue.run() } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-BindingBasicsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-BindingBasicsTests.swift index 01a2602ec..cbe15e780 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-BindingBasicsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-BindingBasicsTests.swift @@ -4,28 +4,29 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class BindingFormTests: XCTestCase { - func testBasics() { + func testBasics() async { let store = TestStore( initialState: BindingFormState(), reducer: bindingFormReducer, environment: BindingFormEnvironment() ) - store.send(.set(\.$sliderValue, 2)) { + await store.send(.set(\.$sliderValue, 2)) { $0.sliderValue = 2 } - store.send(.set(\.$stepCount, 1)) { + await store.send(.set(\.$stepCount, 1)) { $0.sliderValue = 1 $0.stepCount = 1 } - store.send(.set(\.$text, "Blob")) { + await store.send(.set(\.$text, "Blob")) { $0.text = "Blob" } - store.send(.set(\.$toggleIsOn, true)) { + await store.send(.set(\.$toggleIsOn, true)) { $0.toggleIsOn = true } - store.send(.resetButtonTapped) { + await store.send(.resetButtonTapped) { $0 = BindingFormState(sliderValue: 5, stepCount: 10, text: "", toggleIsOn: false) } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift index 5d6eeeb52..c45b99a44 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-SharedStateTests.swift @@ -4,21 +4,22 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class SharedStateTests: XCTestCase { - func testTabRestoredOnReset() { + func testTabRestoredOnReset() async { let store = TestStore( initialState: SharedState(), reducer: sharedStateReducer, environment: () ) - store.send(.selectTab(.profile)) { + await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.profile = SharedState.ProfileState( currentTab: .profile, count: 0, maxCount: 0, minCount: 0, numberOfCounts: 0 ) } - store.send(.profile(.resetCounterButtonTapped)) { + await store.send(.profile(.resetCounterButtonTapped)) { $0.currentTab = .counter $0.profile = SharedState.ProfileState( currentTab: .counter, count: 0, maxCount: 0, minCount: 0, numberOfCounts: 0 @@ -26,20 +27,20 @@ class SharedStateTests: XCTestCase { } } - func testTabSelection() { + func testTabSelection() async { let store = TestStore( initialState: SharedState(), reducer: sharedStateReducer, environment: () ) - store.send(.selectTab(.profile)) { + await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.profile = SharedState.ProfileState( currentTab: .profile, count: 0, maxCount: 0, minCount: 0, numberOfCounts: 0 ) } - store.send(.selectTab(.counter)) { + await store.send(.selectTab(.counter)) { $0.currentTab = .counter $0.profile = SharedState.ProfileState( currentTab: .counter, count: 0, maxCount: 0, minCount: 0, numberOfCounts: 0 @@ -47,30 +48,30 @@ class SharedStateTests: XCTestCase { } } - func testSharedCounts() { + func testSharedCounts() async { let store = TestStore( initialState: SharedState(), reducer: sharedStateReducer, environment: () ) - store.send(.counter(.incrementButtonTapped)) { + await store.send(.counter(.incrementButtonTapped)) { $0.counter.count = 1 $0.counter.maxCount = 1 $0.counter.numberOfCounts = 1 } - store.send(.counter(.decrementButtonTapped)) { + await store.send(.counter(.decrementButtonTapped)) { $0.counter.count = 0 $0.counter.numberOfCounts = 2 } - store.send(.counter(.decrementButtonTapped)) { + await store.send(.counter(.decrementButtonTapped)) { $0.counter.count = -1 $0.counter.minCount = -1 $0.counter.numberOfCounts = 3 } } - func testIsPrimeWhenPrime() { + func testIsPrimeWhenPrime() async { let store = TestStore( initialState: SharedState.CounterState( alert: nil, count: 3, maxCount: 0, minCount: 0, numberOfCounts: 0 @@ -79,17 +80,17 @@ class SharedStateTests: XCTestCase { environment: () ) - store.send(.isPrimeButtonTapped) { + await store.send(.isPrimeButtonTapped) { $0.alert = AlertState( title: TextState("👍 The number \($0.count) is prime!") ) } - store.send(.alertDismissed) { + await store.send(.alertDismissed) { $0.alert = nil } } - func testIsPrimeWhenNotPrime() { + func testIsPrimeWhenNotPrime() async { let store = TestStore( initialState: SharedState.CounterState( alert: nil, count: 6, maxCount: 0, minCount: 0, numberOfCounts: 0 @@ -98,12 +99,12 @@ class SharedStateTests: XCTestCase { environment: () ) - store.send(.isPrimeButtonTapped) { + await store.send(.isPrimeButtonTapped) { $0.alert = AlertState( title: TextState("👎 The number \($0.count) is not prime :(") ) } - store.send(.alertDismissed) { + await store.send(.alertDismissed) { $0.alert = nil } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift index 22bd96822..f3ced8195 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-BasicsTests.swift @@ -4,62 +4,78 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class EffectsBasicsTests: XCTestCase { - func testCountUpAndDown() { + func testCountDown() async { let store = TestStore( initialState: EffectsBasicsState(), reducer: effectsBasicsReducer, environment: .unimplemented ) - store.send(.incrementButtonTapped) { + store.environment.mainQueue = ImmediateScheduler() + + await store.send(.incrementButtonTapped) { $0.count = 1 } - store.send(.decrementButtonTapped) { + await store.send(.decrementButtonTapped) { $0.count = 0 } } - func testNumberFact_HappyPath() { + func testNumberFact() async { let store = TestStore( initialState: EffectsBasicsState(), reducer: effectsBasicsReducer, environment: .unimplemented ) - store.environment.fact.fetch = { Effect(value: "\($0) is a good number Brent") } + store.environment.fact.fetch = { "\($0) is a good number Brent" } store.environment.mainQueue = ImmediateScheduler() - store.send(.incrementButtonTapped) { + await store.send(.incrementButtonTapped) { $0.count = 1 } - store.send(.numberFactButtonTapped) { + await store.send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true } - store.receive(.numberFactResponse(.success("1 is a good number Brent"))) { + await store.receive(.numberFactResponse(.success("1 is a good number Brent"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number Brent" } } - func testNumberFact_UnhappyPath() { + func testDecrement() async { let store = TestStore( initialState: EffectsBasicsState(), reducer: effectsBasicsReducer, environment: .unimplemented ) - store.environment.fact.fetch = { _ in Effect(error: FactClient.Failure()) } store.environment.mainQueue = ImmediateScheduler() - store.send(.incrementButtonTapped) { - $0.count = 1 + await store.send(.decrementButtonTapped) { + $0.count = -1 } - store.send(.numberFactButtonTapped) { - $0.isNumberFactRequestInFlight = true + await store.receive(.decrementDelayResponse) { + $0.count = 0 } - store.receive(.numberFactResponse(.failure(FactClient.Failure()))) { - $0.isNumberFactRequestInFlight = false + } + + func testDecrementCancellation() async { + let store = TestStore( + initialState: EffectsBasicsState(), + reducer: effectsBasicsReducer, + environment: .unimplemented + ) + + store.environment.mainQueue = TestScheduler() + + await store.send(.decrementButtonTapped) { + $0.count = -1 + } + await store.send(.incrementButtonTapped) { + $0.count = 0 } } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift index ebde9729b..0fedf83d1 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-CancellationTests.swift @@ -4,47 +4,47 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class EffectsCancellationTests: XCTestCase { - func testTrivia_SuccessfulRequest() { + func testTrivia_SuccessfulRequest() async { let store = TestStore( initialState: EffectsCancellationState(), reducer: effectsCancellationReducer, environment: .unimplemented ) - store.environment.fact.fetch = { Effect(value: "\($0) is a good number Brent") } - store.environment.mainQueue = ImmediateScheduler() + store.environment.fact.fetch = { "\($0) is a good number Brent" } - store.send(.stepperChanged(1)) { + await store.send(.stepperChanged(1)) { $0.count = 1 } - store.send(.stepperChanged(0)) { + await store.send(.stepperChanged(0)) { $0.count = 0 } - store.send(.triviaButtonTapped) { - $0.isTriviaRequestInFlight = true + await store.send(.factButtonTapped) { + $0.isFactRequestInFlight = true } - store.receive(.triviaResponse(.success("0 is a good number Brent"))) { - $0.currentTrivia = "0 is a good number Brent" - $0.isTriviaRequestInFlight = false + await store.receive(.factResponse(.success("0 is a good number Brent"))) { + $0.currentFact = "0 is a good number Brent" + $0.isFactRequestInFlight = false } } - func testTrivia_FailedRequest() { + func testTrivia_FailedRequest() async { + struct FactError: Equatable, Error {} let store = TestStore( initialState: EffectsCancellationState(), reducer: effectsCancellationReducer, environment: .unimplemented ) - store.environment.fact.fetch = { _ in Effect(error: FactClient.Failure()) } - store.environment.mainQueue = ImmediateScheduler() + store.environment.fact.fetch = { _ in throw FactError() } - store.send(.triviaButtonTapped) { - $0.isTriviaRequestInFlight = true + await store.send(.factButtonTapped) { + $0.isFactRequestInFlight = true } - store.receive(.triviaResponse(.failure(FactClient.Failure()))) { - $0.isTriviaRequestInFlight = false + await store.receive(.factResponse(.failure(FactError()))) { + $0.isFactRequestInFlight = false } } @@ -54,51 +54,50 @@ class EffectsCancellationTests: XCTestCase { // in the `.cancelButtonTapped` action of the `effectsCancellationReducer`. This will cause the // test to fail, showing that we are exhaustively asserting that the effect truly is canceled and // will never emit. - func testTrivia_CancelButtonCancelsRequest() { - let mainQueue = TestScheduler() + func testTrivia_CancelButtonCancelsRequest() async { let store = TestStore( initialState: EffectsCancellationState(), reducer: effectsCancellationReducer, environment: .unimplemented ) - store.environment.fact.fetch = { Effect(value: "\($0) is a good number Brent") } - store.environment.mainQueue = mainQueue + store.environment.fact.fetch = { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return "\($0) is a good number Brent" + } - store.send(.triviaButtonTapped) { - $0.isTriviaRequestInFlight = true + await store.send(.factButtonTapped) { + $0.isFactRequestInFlight = true } - store.send(.cancelButtonTapped) { - $0.isTriviaRequestInFlight = false + await store.send(.cancelButtonTapped) { + $0.isFactRequestInFlight = false } - mainQueue.run() } - func testTrivia_PlusMinusButtonsCancelsRequest() { - let mainQueue = TestScheduler() + func testTrivia_PlusMinusButtonsCancelsRequest() async { let store = TestStore( initialState: EffectsCancellationState(), reducer: effectsCancellationReducer, environment: .unimplemented ) - store.environment.fact.fetch = { Effect(value: "\($0) is a good number Brent") } - store.environment.mainQueue = mainQueue + store.environment.fact.fetch = { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return "\($0) is a good number Brent" + } - store.send(.triviaButtonTapped) { - $0.isTriviaRequestInFlight = true + await store.send(.factButtonTapped) { + $0.isFactRequestInFlight = true } - store.send(.stepperChanged(1)) { + await store.send(.stepperChanged(1)) { $0.count = 1 - $0.isTriviaRequestInFlight = false + $0.isFactRequestInFlight = false } - mainQueue.advance() } } extension EffectsCancellationEnvironment { static let unimplemented = Self( - fact: .unimplemented, - mainQueue: UnimplementedScheduler() + fact: .unimplemented ) } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift index 54b54a3a4..77f409763 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-LongLivingTests.swift @@ -4,30 +4,32 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class LongLivingEffectsTests: XCTestCase { - func testReducer() { - let notificationCenter = NotificationCenter() + func testReducer() async { + let (screenshots, takeScreenshot) = AsyncStream.streamWithContinuation() let store = TestStore( initialState: LongLivingEffectsState(), reducer: longLivingEffectsReducer, environment: LongLivingEffectsEnvironment( - notificationCenter: notificationCenter + screenshots: { screenshots } ) ) - store.send(.onAppear) + let task = await store.send(.task) // Simulate a screenshot being taken - notificationCenter.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil) - store.receive(.userDidTakeScreenshotNotification) { + takeScreenshot.yield() + + await store.receive(.userDidTakeScreenshotNotification) { $0.screenshotCount = 1 } - store.send(.onDisappear) + // Simulate screen going away + await task.cancel() - // Simulate a screenshot being taken to show no effects - // are executed. - notificationCenter.post(name: UIApplication.userDidTakeScreenshotNotification, object: nil) + // Simulate a screenshot being taken to show no effects are executed. + takeScreenshot.yield() } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift index 7273d4008..c4ea401dc 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-RefreshableTests.swift @@ -4,71 +4,64 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class RefreshableTests: XCTestCase { - func testHappyPath() { + func testHappyPath() async { let store = TestStore( initialState: RefreshableState(), reducer: refreshableReducer, environment: .unimplemented ) - store.environment.fact.fetch = { Effect(value: "\($0) is a good number.") } + store.environment.fact.fetch = { "\($0) is a good number." } store.environment.mainQueue = ImmediateScheduler() - store.send(.incrementButtonTapped) { + await store.send(.incrementButtonTapped) { $0.count = 1 } - store.send(.refresh) { - $0.isLoading = true - } - store.receive(.factResponse(.success("1 is a good number."))) { - $0.isLoading = false + await store.send(.refresh) + await store.receive(.factResponse(.success("1 is a good number."))) { $0.fact = "1 is a good number." } } - func testUnhappyPath() { + func testUnhappyPath() async { + struct FactError: Equatable, Error {} let store = TestStore( initialState: RefreshableState(), reducer: refreshableReducer, environment: .unimplemented ) - store.environment.fact.fetch = { _ in Effect(error: FactClient.Failure()) } + store.environment.fact.fetch = { _ in throw FactError() } store.environment.mainQueue = ImmediateScheduler() - store.send(.incrementButtonTapped) { + await store.send(.incrementButtonTapped) { $0.count = 1 } - store.send(.refresh) { - $0.isLoading = true - } - store.receive(.factResponse(.failure(FactClient.Failure()))) { - $0.isLoading = false - } + await store.send(.refresh) + await store.receive(.factResponse(.failure(FactError()))) } - func testCancellation() { - let mainQueue = TestScheduler() - + func testCancellation() async { let store = TestStore( initialState: RefreshableState(), reducer: refreshableReducer, environment: .unimplemented ) - store.environment.fact.fetch = { Effect(value: "\($0) is a good number.") } - store.environment.mainQueue = mainQueue + store.environment.mainQueue = ImmediateScheduler() - store.send(.incrementButtonTapped) { - $0.count = 1 - } - store.send(.refresh) { - $0.isLoading = true + store.environment.fact.fetch = { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return "\($0) is a good number." } - store.send(.cancelButtonTapped) { - $0.isLoading = false + + await store.send(.incrementButtonTapped) { + $0.count = 1 } + await store.send(.refresh) + await store.send(.cancelButtonTapped) } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift index 5e66423c6..feb0942a7 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-TimersTests.swift @@ -4,42 +4,43 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class TimersTests: XCTestCase { - let mainQueue = TestScheduler() + func testStart() async { + let mainQueue = TestScheduler() - func testStart() { let store = TestStore( initialState: TimersState(), reducer: timersReducer, environment: TimersEnvironment( - mainQueue: self.mainQueue + mainQueue: mainQueue ) ) - store.send(.toggleTimerButtonTapped) { + await store.send(.toggleTimerButtonTapped) { $0.isTimerActive = true } - self.mainQueue.advance(by: 1) - store.receive(.timerTicked) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.timerTicked) { $0.secondsElapsed = 1 } - self.mainQueue.advance(by: 5) - store.receive(.timerTicked) { + await mainQueue.advance(by: .seconds(5)) + await store.receive(.timerTicked) { $0.secondsElapsed = 2 } - store.receive(.timerTicked) { + await store.receive(.timerTicked) { $0.secondsElapsed = 3 } - store.receive(.timerTicked) { + await store.receive(.timerTicked) { $0.secondsElapsed = 4 } - store.receive(.timerTicked) { + await store.receive(.timerTicked) { $0.secondsElapsed = 5 } - store.receive(.timerTicked) { + await store.receive(.timerTicked) { $0.secondsElapsed = 6 } - store.send(.toggleTimerButtonTapped) { + await store.send(.toggleTimerButtonTapped) { $0.isTimerActive = false } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift index 205c1e406..a6b52891e 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift @@ -4,16 +4,17 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class WebSocketTests: XCTestCase { - func testWebSocketHappyPath() { - let socketSubject = Signal.pipe() - let receiveSubject = Signal.pipe() + func testWebSocketHappyPath() async { + let actions = AsyncStream.streamWithContinuation() + let messages = AsyncStream>.streamWithContinuation() var webSocket = WebSocketClient.unimplemented - webSocket.open = { _, _, _ in socketSubject.output.producer } - webSocket.receive = { _ in receiveSubject.output.producer } - webSocket.send = { _, _ in Effect(value: nil) } - webSocket.sendPing = { _ in .none } + webSocket.open = { _, _, _ in actions.stream } + webSocket.send = { _, _ in } + webSocket.receive = { _ in messages.stream } + webSocket.sendPing = { _ in try await Task.never() } let store = TestStore( initialState: WebSocketState(), @@ -25,44 +26,53 @@ class WebSocketTests: XCTestCase { ) // Connect to the socket - store.send(.connectButtonTapped) { + await store.send(.connectButtonTapped) { $0.connectivityState = .connecting } - socketSubject.input.send(value: .didOpenWithProtocol(nil)) - store.receive(.webSocket(.didOpenWithProtocol(nil))) { + actions.continuation.yield(.didOpen(protocol: nil)) + await store.receive(.webSocket(.didOpen(protocol: nil))) { $0.connectivityState = .connected } + // Receive a message + messages.continuation.yield(.success(.string("Welcome to echo.pointfree.co"))) + await store.receive(.receivedSocketMessage(.success(.string("Welcome to echo.pointfree.co")))) { + $0.receivedMessages = ["Welcome to echo.pointfree.co"] + } + // Send a message - store.send(.messageToSendChanged("Hi")) { + await store.send(.messageToSendChanged("Hi")) { $0.messageToSend = "Hi" } - store.send(.sendButtonTapped) { + await store.send(.sendButtonTapped) { $0.messageToSend = "" } - store.receive(.sendResponse(nil)) + await store.receive(.sendResponse(didSucceed: true)) // Receive a message - receiveSubject.input.send(value: .string("Hi")) - store.receive(.receivedSocketMessage(.success(.string("Hi")))) { - $0.receivedMessages = ["Hi"] + messages.continuation.yield(.success(.string("Hi"))) + await store.receive(.receivedSocketMessage(.success(.string("Hi")))) { + $0.receivedMessages = ["Welcome to echo.pointfree.co", "Hi"] } // Disconnect from the socket - store.send(.connectButtonTapped) { + await store.send(.connectButtonTapped) { $0.connectivityState = .disconnected } } - func testWebSocketSendFailure() { - let socketSubject = Signal.pipe() - let receiveSubject = Signal.pipe() + func testWebSocketSendFailure() async { + let actions = AsyncStream.streamWithContinuation() + let messages = AsyncStream>.streamWithContinuation() var webSocket = WebSocketClient.unimplemented - webSocket.open = { _, _, _ in socketSubject.output.producer } - webSocket.receive = { _ in receiveSubject.output.producer } - webSocket.send = { _, _ in Effect(value: NSError(domain: "", code: 1)) } - webSocket.sendPing = { _ in .none } + webSocket.open = { _, _, _ in actions.stream } + webSocket.receive = { _ in messages.stream } + webSocket.send = { _, _ in + struct SendFailure: Error, Equatable {} + throw SendFailure() + } + webSocket.sendPing = { _ in try await Task.never() } let store = TestStore( initialState: WebSocketState(), @@ -74,78 +84,77 @@ class WebSocketTests: XCTestCase { ) // Connect to the socket - store.send(.connectButtonTapped) { + await store.send(.connectButtonTapped) { $0.connectivityState = .connecting } - socketSubject.input.send(value: .didOpenWithProtocol(nil)) - store.receive(.webSocket(.didOpenWithProtocol(nil))) { + actions.continuation.yield(.didOpen(protocol: nil)) + await store.receive(.webSocket(.didOpen(protocol: nil))) { $0.connectivityState = .connected } // Send a message - store.send(.messageToSendChanged("Hi")) { + await store.send(.messageToSendChanged("Hi")) { $0.messageToSend = "Hi" } - store.send(.sendButtonTapped) { + await store.send(.sendButtonTapped) { $0.messageToSend = "" } - store.receive(.sendResponse(NSError(domain: "", code: 1))) { + await store.receive(.sendResponse(didSucceed: false)) { $0.alert = AlertState(title: TextState("Could not send socket message. Try again.")) } // Disconnect from the socket - store.send(.connectButtonTapped) { + await store.send(.connectButtonTapped) { $0.connectivityState = .disconnected } } - func testWebSocketPings() { - let socketSubject = Signal.pipe() - let pingSubject = Signal.pipe() + func testWebSocketPings() async { + let actions = AsyncStream.streamWithContinuation() + let pingsCount = ActorIsolated(0) var webSocket = WebSocketClient.unimplemented - webSocket.open = { _, _, _ in socketSubject.output.producer } - webSocket.receive = { _ in .none } - webSocket.sendPing = { _ in pingSubject.output.producer } + webSocket.open = { _, _, _ in actions.stream } + webSocket.receive = { _ in try await Task.never() } + webSocket.sendPing = { _ in await pingsCount.withValue { $0 += 1 } } - let mainQueue = TestScheduler() + let scheduler = TestScheduler() let store = TestStore( initialState: WebSocketState(), reducer: webSocketReducer, environment: WebSocketEnvironment( - mainQueue: mainQueue, + mainQueue: scheduler, webSocket: webSocket ) ) - store.send(.connectButtonTapped) { + // Connect to the socket + await store.send(.connectButtonTapped) { $0.connectivityState = .connecting } - - socketSubject.input.send(value: .didOpenWithProtocol(nil)) - mainQueue.advance() - store.receive(.webSocket(.didOpenWithProtocol(nil))) { + actions.continuation.yield(.didOpen(protocol: nil)) + await store.receive(.webSocket(.didOpen(protocol: nil))) { $0.connectivityState = .connected } - pingSubject.input.send(value: nil) - mainQueue.advance(by: 5) - mainQueue.advance(by: 5) - store.receive(.pingResponse(nil)) + // Wait for ping + await pingsCount.withValue { XCTAssertEqual($0, 0) } + await scheduler.advance(by: .seconds(10)) + await pingsCount.withValue { XCTAssertEqual($0, 1) } - store.send(.connectButtonTapped) { + // Disconnect from the socket + await store.send(.connectButtonTapped) { $0.connectivityState = .disconnected } } - func testWebSocketConnectError() { - let socketSubject = Signal.pipe() + func testWebSocketConnectError() async { + let actions = AsyncStream.streamWithContinuation() var webSocket = WebSocketClient.unimplemented - webSocket.cancel = { _, _, _ in .fireAndForget { socketSubject.input.sendCompleted() } } - webSocket.open = { _, _, _ in socketSubject.output.producer } - webSocket.receive = { _ in .none } - webSocket.sendPing = { _ in .none } + webSocket.open = { _, _, _ in actions.stream } + webSocket.receive = { _ in try await Task.never() } + webSocket.sendPing = { _ in try await Task.never() } let store = TestStore( initialState: WebSocketState(), @@ -156,23 +165,13 @@ class WebSocketTests: XCTestCase { ) ) - store.send(.connectButtonTapped) { + // Attempt to connect to the socket + await store.send(.connectButtonTapped) { $0.connectivityState = .connecting } - - socketSubject.input.send(value: .didClose(code: .internalServerError, reason: nil)) - store.receive(.webSocket(.didClose(code: .internalServerError, reason: nil))) { + actions.continuation.yield(.didClose(code: .internalServerError, reason: nil)) + await store.receive(.webSocket(.didClose(code: .internalServerError, reason: nil))) { $0.connectivityState = .disconnected } } } - -extension WebSocketClient { - static let unimplemented = Self( - cancel: { _, _, _ in .unimplemented("\(Self.self).cancel") }, - open: { _, _, _ in .unimplemented("\(Self.self).open") }, - receive: { _ in .unimplemented("\(Self.self).receive") }, - send: { _, _ in .unimplemented("\(Self.self).send") }, - sendPing: { _ in .unimplemented("\(Self.self).sendPing") } - ) -} diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-LifecycleTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-LifecycleTests.swift index f933c26b0..32d241728 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-LifecycleTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-LifecycleTests.swift @@ -5,8 +5,9 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class LifecycleTests: XCTestCase { - func testLifecycle() { + func testLifecycle() async { let mainQueue = TestScheduler() let store = TestStore( @@ -17,34 +18,34 @@ class LifecycleTests: XCTestCase { ) ) - store.send(.toggleTimerButtonTapped) { + await store.send(.toggleTimerButtonTapped) { $0.count = 0 } - store.send(.timer(.onAppear)) + await store.send(.timer(.onAppear)) - mainQueue.advance(by: 1) - store.receive(.timer(.action(.tick))) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.timer(.action(.tick))) { $0.count = 1 } - mainQueue.advance(by: 1) - store.receive(.timer(.action(.tick))) { + await mainQueue.advance(by: .seconds(1)) + await store.receive(.timer(.action(.tick))) { $0.count = 2 } - store.send(.timer(.action(.incrementButtonTapped))) { + await store.send(.timer(.action(.incrementButtonTapped))) { $0.count = 3 } - store.send(.timer(.action(.decrementButtonTapped))) { + await store.send(.timer(.action(.decrementButtonTapped))) { $0.count = 2 } - store.send(.toggleTimerButtonTapped) { + await store.send(.toggleTimerButtonTapped) { $0.count = nil } - store.send(.timer(.onDisappear)) + await store.send(.timer(.onDisappear)) } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift index d00fe4663..0ad1756ea 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift @@ -4,10 +4,11 @@ import XCTest @testable import SwiftUICaseStudies +@MainActor class ReusableComponentsFavoritingTests: XCTestCase { - let mainQueue = TestScheduler() + func testFavoriteButton() async { + let scheduler = TestScheduler() - func testFavoriteButton() { let episodes: IdentifiedArrayOf = [ EpisodeState( id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, @@ -29,45 +30,48 @@ class ReusableComponentsFavoritingTests: XCTestCase { initialState: EpisodesState(episodes: episodes), reducer: episodesReducer, environment: EpisodesEnvironment( - favorite: { _, isFavorite in Effect.future { $0(.success(isFavorite)) } }, - mainQueue: mainQueue + favorite: { _, isFavorite in + try await scheduler.sleep(for: .seconds(1)) + return isFavorite + } ) ) - let error = NSError(domain: "co.pointfree", code: -1, userInfo: nil) - store.send(.episode(id: episodes[0].id, action: .favorite(.buttonTapped))) { + await store.send(.episode(id: episodes[0].id, action: .favorite(.buttonTapped))) { $0.episodes[id: episodes[0].id]?.isFavorite = true } + await scheduler.advance(by: .seconds(1)) + await store.receive(.episode(id: episodes[0].id, action: .favorite(.response(.success(true))))) - self.mainQueue.advance() - store.receive(.episode(id: episodes[0].id, action: .favorite(.response(.success(true))))) - - store.send(.episode(id: episodes[1].id, action: .favorite(.buttonTapped))) { + await store.send(.episode(id: episodes[1].id, action: .favorite(.buttonTapped))) { $0.episodes[id: episodes[1].id]?.isFavorite = true } - store.send(.episode(id: episodes[1].id, action: .favorite(.buttonTapped))) { + await store.send(.episode(id: episodes[1].id, action: .favorite(.buttonTapped))) { $0.episodes[id: episodes[1].id]?.isFavorite = false } + await scheduler.advance(by: .seconds(1)) + await store.receive(.episode(id: episodes[1].id, action: .favorite(.response(.success(false))))) - self.mainQueue.advance() - store.receive(.episode(id: episodes[1].id, action: .favorite(.response(.success(false))))) - - store.environment.favorite = { _, _ in .future { $0(.failure(error)) } } - store.send(.episode(id: episodes[2].id, action: .favorite(.buttonTapped))) { + struct FavoriteError: Equatable, LocalizedError { + var errorDescription: String? { + "Favoriting failed." + } + } + store.environment.favorite = { _, _ in throw FavoriteError() } + await store.send(.episode(id: episodes[2].id, action: .favorite(.buttonTapped))) { $0.episodes[id: episodes[2].id]?.isFavorite = true } - self.mainQueue.advance() - store.receive( + await store.receive( .episode( - id: episodes[2].id, action: .favorite(.response(.failure(FavoriteError(error: error))))) + id: episodes[2].id, action: .favorite(.response(.failure(FavoriteError())))) ) { $0.episodes[id: episodes[2].id]?.alert = AlertState( - title: TextState("The operation couldn’t be completed. (co.pointfree error -1.)") + title: TextState("Favoriting failed.") ) } - store.send(.episode(id: episodes[2].id, action: .favorite(.alertDismissed))) { + await store.send(.episode(id: episodes[2].id, action: .favorite(.alertDismissed))) { $0.episodes[id: episodes[2].id]?.alert = nil $0.episodes[id: episodes[2].id]?.isFavorite = false } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift index 4f5a2c99d..eef7b0280 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift @@ -1,13 +1,17 @@ import ComposableArchitecture import ReactiveSwift import XCTest +import XCTestDynamicOverlay @testable import SwiftUICaseStudies +@MainActor class ReusableComponentsDownloadComponentTests: XCTestCase { - let downloadSubject = Signal.pipe() + let download = AsyncThrowingStream.streamWithContinuation() let reducer = Reducer< - DownloadComponentState, DownloadComponentAction, DownloadComponentEnvironment + DownloadComponentState, + DownloadComponentAction, + DownloadComponentEnvironment > .empty .downloadable( @@ -17,9 +21,9 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { ) let mainQueue = TestScheduler() - func testDownloadFlow() { + func testDownloadFlow() async { var downloadClient = DownloadClient.unimplemented - downloadClient.download = { _ in self.downloadSubject.output.producer } + downloadClient.download = { _ in self.download.stream } let store = TestStore( initialState: DownloadComponentState( @@ -28,34 +32,28 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { url: URL(string: "https://www.pointfree.co")! ), reducer: reducer, - environment: DownloadComponentEnvironment( - downloadClient: downloadClient, - mainQueue: self.mainQueue - ) + environment: DownloadComponentEnvironment(downloadClient: downloadClient) ) - store.send(.buttonTapped) { + await store.send(.buttonTapped) { $0.mode = .startingToDownload } - self.downloadSubject.input.send(value: .updateProgress(0.2)) - self.mainQueue.advance() - store.receive(.downloadClient(.success(.updateProgress(0.2)))) { + self.download.continuation.yield(.updateProgress(0.2)) + await store.receive(.downloadClient(.success(.updateProgress(0.2)))) { $0.mode = .downloading(progress: 0.2) } - self.downloadSubject.input.send(value: .response(Data())) - self.mainQueue.advance(by: 1) - store.receive(.downloadClient(.success(.response(Data())))) { + self.download.continuation.yield(.response(Data())) + self.download.continuation.finish() + await store.receive(.downloadClient(.success(.response(Data())))) { $0.mode = .downloaded } - self.downloadSubject.input.sendCompleted() - self.mainQueue.advance() } - func testDownloadThrottling() { + func testCancelDownloadFlow() async { var downloadClient = DownloadClient.unimplemented - downloadClient.download = { _ in self.downloadSubject.output.producer } + downloadClient.download = { _ in self.download.stream } let store = TestStore( initialState: DownloadComponentState( @@ -64,75 +62,38 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { url: URL(string: "https://www.pointfree.co")! ), reducer: reducer, - environment: DownloadComponentEnvironment( - downloadClient: downloadClient, - mainQueue: self.mainQueue - ) + environment: DownloadComponentEnvironment(downloadClient: downloadClient) ) - store.send(.buttonTapped) { + await store.send(.buttonTapped) { $0.mode = .startingToDownload } - self.downloadSubject.input.send(value: .updateProgress(0.5)) - self.mainQueue.advance() - store.receive(.downloadClient(.success(.updateProgress(0.5)))) { - $0.mode = .downloading(progress: 0.5) - } - - self.downloadSubject.input.send(value: .updateProgress(0.6)) - self.mainQueue.advance(by: 0.5) - - self.downloadSubject.input.send(value: .updateProgress(0.7)) - self.mainQueue.advance(by: 0.5) - store.receive(.downloadClient(.success(.updateProgress(0.7)))) { - $0.mode = .downloading(progress: 0.7) - } - - self.downloadSubject.input.sendCompleted() - self.mainQueue.run() - } - - func testCancelDownloadFlow() { - var downloadClient = DownloadClient.unimplemented - downloadClient.download = { _ in self.downloadSubject.output.producer } - - let store = TestStore( - initialState: DownloadComponentState( - id: 1, - mode: .notDownloaded, - url: URL(string: "https://www.pointfree.co")! - ), - reducer: reducer, - environment: DownloadComponentEnvironment( - downloadClient: downloadClient, - mainQueue: self.mainQueue - ) - ) - - store.send(.buttonTapped) { - $0.mode = .startingToDownload + self.download.continuation.yield(.updateProgress(0.2)) + await store.receive(.downloadClient(.success(.updateProgress(0.2)))) { + $0.mode = .downloading(progress: 0.2) } - store.send(.buttonTapped) { + await store.send(.buttonTapped) { $0.alert = AlertState( title: TextState("Do you want to stop downloading this map?"), - primaryButton: .destructive(TextState("Stop"), action: .send(.stopButtonTapped)), + primaryButton: .destructive( + TextState("Stop"), + action: .send(.stopButtonTapped, animation: .default) + ), secondaryButton: .cancel(TextState("Nevermind"), action: .send(.nevermindButtonTapped)) ) } - store.send(.alert(.stopButtonTapped)) { + await store.send(.alert(.stopButtonTapped)) { $0.alert = nil $0.mode = .notDownloaded } - - self.mainQueue.run() } - func testDownloadFinishesWhileTryingToCancel() { + func testDownloadFinishesWhileTryingToCancel() async { var downloadClient = DownloadClient.unimplemented - downloadClient.download = { _ in self.downloadSubject.output.producer } + downloadClient.download = { _ in self.download.stream } let store = TestStore( initialState: DownloadComponentState( @@ -141,37 +102,37 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { url: URL(string: "https://www.pointfree.co")! ), reducer: reducer, - environment: DownloadComponentEnvironment( - downloadClient: downloadClient, - mainQueue: self.mainQueue - ) + environment: DownloadComponentEnvironment(downloadClient: downloadClient) ) - store.send(.buttonTapped) { + let task = await store.send(.buttonTapped) { $0.mode = .startingToDownload } - store.send(.buttonTapped) { + await store.send(.buttonTapped) { $0.alert = AlertState( title: TextState("Do you want to stop downloading this map?"), - primaryButton: .destructive(TextState("Stop"), action: .send(.stopButtonTapped)), + primaryButton: .destructive( + TextState("Stop"), + action: .send(.stopButtonTapped, animation: .default) + ), secondaryButton: .cancel(TextState("Nevermind"), action: .send(.nevermindButtonTapped)) ) } - self.downloadSubject.input.send(value: .response(Data())) - self.mainQueue.advance(by: 1) - store.receive(.downloadClient(.success(.response(Data())))) { + self.download.continuation.yield(.response(Data())) + self.download.continuation.finish() + await store.receive(.downloadClient(.success(.response(Data())))) { $0.alert = nil $0.mode = .downloaded } - self.downloadSubject.input.sendCompleted() - self.mainQueue.advance() + + await task.finish() } - func testDeleteDownloadFlow() { + func testDeleteDownloadFlow() async { var downloadClient = DownloadClient.unimplemented - downloadClient.download = { _ in self.downloadSubject.output.producer } + downloadClient.download = { _ in self.download.stream } let store = TestStore( initialState: DownloadComponentState( @@ -180,21 +141,21 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { url: URL(string: "https://www.pointfree.co")! ), reducer: reducer, - environment: DownloadComponentEnvironment( - downloadClient: downloadClient, - mainQueue: self.mainQueue - ) + environment: DownloadComponentEnvironment(downloadClient: downloadClient) ) - store.send(.buttonTapped) { + await store.send(.buttonTapped) { $0.alert = AlertState( title: TextState("Do you want to delete this map from your offline storage?"), - primaryButton: .destructive(TextState("Delete"), action: .send(.deleteButtonTapped)), + primaryButton: .destructive( + TextState("Delete"), + action: .send(.deleteButtonTapped, animation: .default) + ), secondaryButton: .cancel(TextState("Nevermind"), action: .send(.nevermindButtonTapped)) ) } - store.send(.alert(.deleteButtonTapped)) { + await store.send(.alert(.deleteButtonTapped)) { $0.alert = nil $0.mode = .notDownloaded } @@ -203,6 +164,6 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { extension DownloadClient { static let unimplemented = Self( - download: { _ in .unimplemented("\(Self.self).download") } + download: XCTUnimplemented("\(Self.self).asyncDownload") ) } diff --git a/Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift b/Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift index 6903fc7fd..863a8b100 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/LoadThenNavigate.swift @@ -32,23 +32,29 @@ let lazyNavigationReducer = LazyNavigationState, LazyNavigationAction, LazyNavigationEnvironment > { state, action, environment in - enum CancelId {} + enum CancelID {} switch action { case .onDisappear: - return .cancel(id: CancelId.self) + return .cancel(id: CancelID.self) + case .setNavigation(isActive: true): state.isActivityIndicatorHidden = false - return Effect(value: .setNavigationIsActiveDelayCompleted) - .delay(1, on: environment.mainQueue) - .cancellable(id: CancelId.self) + return .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .setNavigationIsActiveDelayCompleted + } + .cancellable(id: CancelID.self) + case .setNavigation(isActive: false): state.optionalCounter = nil return .none + case .setNavigationIsActiveDelayCompleted: state.isActivityIndicatorHidden = true state.optionalCounter = CounterState() return .none + case .optionalCounter: return .none } diff --git a/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift b/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift index 04b959347..2ca5c42b2 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/NavigateAndLoad.swift @@ -31,21 +31,26 @@ let eagerNavigationReducer = EagerNavigationState, EagerNavigationAction, EagerNavigationEnvironment > { state, action, environment in - enum CancelId {} + enum CancelID {} switch action { case .setNavigation(isActive: true): state.isNavigationActive = true - return Effect(value: .setNavigationIsActiveDelayCompleted) - .delay(1, on: environment.mainQueue) - .cancellable(id: CancelId.self) + return .task { + try await environment.mainQueue.sleep(for: .seconds(1)) + return .setNavigationIsActiveDelayCompleted + } + .cancellable(id: CancelID.self) + case .setNavigation(isActive: false): state.isNavigationActive = false state.optionalCounter = nil - return .cancel(id: CancelId.self) + return .cancel(id: CancelID.self) + case .setNavigationIsActiveDelayCompleted: state.optionalCounter = CounterState() return .none + case .optionalCounter: return .none } diff --git a/Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift b/Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift index eecd96bf5..c7306bd83 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/RootViewController.swift @@ -13,6 +13,7 @@ struct CaseStudy { } } +@MainActor let dataSource: [CaseStudy] = [ CaseStudy( title: "Basics", diff --git a/Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift b/Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift index 2f9fdc72b..3a01d3e9a 100644 --- a/Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift +++ b/Examples/CaseStudies/UIKitCaseStudies/SceneDelegate.swift @@ -9,7 +9,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { - self.window = (scene as? UIWindowScene).map(UIWindow.init(windowScene:)) + self.window = (scene as? UIWindowScene).map { UIWindow(windowScene: $0) } self.window?.rootViewController = UINavigationController( rootViewController: RootViewController()) self.window?.makeKeyAndVisible() diff --git a/Examples/CaseStudies/UIKitCaseStudiesTests/UIKitCaseStudiesTests.swift b/Examples/CaseStudies/UIKitCaseStudiesTests/UIKitCaseStudiesTests.swift index 153b5ad9b..2461aa421 100644 --- a/Examples/CaseStudies/UIKitCaseStudiesTests/UIKitCaseStudiesTests.swift +++ b/Examples/CaseStudies/UIKitCaseStudiesTests/UIKitCaseStudiesTests.swift @@ -3,23 +3,24 @@ import XCTest @testable import UIKitCaseStudies +@MainActor final class UIKitCaseStudiesTests: XCTestCase { - func testCountDown() { + func testCountDown() async { let store = TestStore( initialState: CounterState(), reducer: counterReducer, environment: CounterEnvironment() ) - store.send(.incrementButtonTapped) { + await store.send(.incrementButtonTapped) { $0.count = 1 } - store.send(.decrementButtonTapped) { + await store.send(.decrementButtonTapped) { $0.count = 0 } } - func testCountDownList() { + func testCountDownList() async { let firstState = CounterState() let secondState = CounterState() let thirdState = CounterState() @@ -32,24 +33,24 @@ final class UIKitCaseStudiesTests: XCTestCase { environment: CounterListEnvironment() ) - store.send(.counter(id: firstState.id, action: .incrementButtonTapped)) { + await store.send(.counter(id: firstState.id, action: .incrementButtonTapped)) { $0.counters[id: firstState.id]?.count = 1 } - store.send(.counter(id: firstState.id, action: .decrementButtonTapped)) { + await store.send(.counter(id: firstState.id, action: .decrementButtonTapped)) { $0.counters[id: firstState.id]?.count = 0 } - store.send(.counter(id: secondState.id, action: .incrementButtonTapped)) { + await store.send(.counter(id: secondState.id, action: .incrementButtonTapped)) { $0.counters[id: secondState.id]?.count = 1 } - store.send(.counter(id: secondState.id, action: .decrementButtonTapped)) { + await store.send(.counter(id: secondState.id, action: .decrementButtonTapped)) { $0.counters[id: secondState.id]?.count = 0 } - store.send(.counter(id: thirdState.id, action: .incrementButtonTapped)) { + await store.send(.counter(id: thirdState.id, action: .incrementButtonTapped)) { $0.counters[id: thirdState.id]?.count = 1 } - store.send(.counter(id: thirdState.id, action: .decrementButtonTapped)) { + await store.send(.counter(id: thirdState.id, action: .decrementButtonTapped)) { $0.counters[id: thirdState.id]?.count = 0 } } diff --git a/Examples/Search/Search.xcodeproj/project.pbxproj b/Examples/Search/Search.xcodeproj/project.pbxproj index ee6c3cb1e..7450537c6 100644 --- a/Examples/Search/Search.xcodeproj/project.pbxproj +++ b/Examples/Search/Search.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ CA86E49924253C2500357AD9 /* Search */, CA86E4B024253C2700357AD9 /* SearchTests */, ); + indentWidth = 2; sourceTree = ""; }; CA86E49824253C2500357AD9 /* Products */ = { @@ -383,6 +384,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Search; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -408,6 +410,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Search; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; diff --git a/Examples/Search/Search/SearchView.swift b/Examples/Search/Search/SearchView.swift index baed1a566..3c5f544f0 100644 --- a/Examples/Search/Search/SearchView.swift +++ b/Examples/Search/Search/SearchView.swift @@ -31,9 +31,9 @@ struct SearchState: Equatable { } enum SearchAction: Equatable { - case forecastResponse(Search.Result.ID, Result) + case forecastResponse(Search.Result.ID, TaskResult) case searchQueryChanged(String) - case searchResponse(Result) + case searchResponse(TaskResult) case searchResultTapped(Search.Result) } @@ -69,7 +69,7 @@ let searchReducer = Reducer { return .none case let .searchQueryChanged(query): - enum SearchLocationId {} + enum SearchLocationID {} state.searchQuery = query @@ -78,13 +78,13 @@ let searchReducer = Reducer { guard !query.isEmpty else { state.results = [] state.weather = nil - return .cancel(id: SearchLocationId.self) + return .cancel(id: SearchLocationID.self) } - return environment.weatherClient - .search(query) - .debounce(id: SearchLocationId.self, for: 0.3, scheduler: environment.mainQueue) - .catchToEffect(SearchAction.searchResponse) + return .task { + await .searchResponse(TaskResult { try await environment.weatherClient.search(query) }) + } + .debounce(id: SearchLocationID.self, for: 0.3, scheduler: environment.mainQueue) case .searchResponse(.failure): state.results = [] @@ -95,16 +95,17 @@ let searchReducer = Reducer { return .none case let .searchResultTapped(location): - enum SearchWeatherId {} + enum SearchWeatherID {} state.resultForecastRequestInFlight = location - return environment.weatherClient - .forecast(location) - .observe(on: environment.mainQueue) - .catchToEffect() - .map { .forecastResponse(location.id, $0) } - .cancellable(id: SearchWeatherId.self, cancelInFlight: true) + return .task { + await .forecastResponse( + location.id, + TaskResult { try await environment.weatherClient.forecast(location) } + ) + } + .cancellable(id: SearchWeatherID.self, cancelInFlight: true) } } @@ -211,23 +212,18 @@ private let dateFormatter: DateFormatter = { struct SearchView_Previews: PreviewProvider { static var previews: some View { - let store = Store( - initialState: SearchState(), - reducer: searchReducer, - environment: SearchEnvironment( - weatherClient: WeatherClient( - forecast: { _ in Effect(value: .mock) }, - search: { _ in Effect(value: .mock) } - ), - mainQueue: QueueScheduler.main + SearchView( + store: Store( + initialState: SearchState(), + reducer: searchReducer, + environment: SearchEnvironment( + weatherClient: WeatherClient( + forecast: { _ in .mock }, + search: { _ in .mock } + ), + mainQueue: QueueScheduler.main + ) ) ) - - return Group { - SearchView(store: store) - - SearchView(store: store) - .environment(\.colorScheme, .dark) - } } } diff --git a/Examples/Search/Search/WeatherClient.swift b/Examples/Search/Search/WeatherClient.swift index 31120255f..101cf8abb 100644 --- a/Examples/Search/Search/WeatherClient.swift +++ b/Examples/Search/Search/WeatherClient.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import Foundation +import XCTestDynamicOverlay // MARK: - API models @@ -38,10 +39,8 @@ struct Forecast: Decodable, Equatable { // This allows the search feature to compile faster since it only depends on the interface. struct WeatherClient { - var forecast: (Search.Result) -> Effect - var search: (String) -> Effect - - struct Failure: Error, Equatable {} + var forecast: @Sendable (Search.Result) async throws -> Forecast + var search: @Sendable (String) async throws -> Search } // MARK: - Live API implementation @@ -57,23 +56,15 @@ extension WeatherClient { URLQueryItem(name: "timezone", value: TimeZone.autoupdatingCurrent.identifier), ] - return URLSession.shared.reactive.data(with: URLRequest(url: components.url!)) - .map { data, _ in data } - .attemptMap { data in - try jsonDecoder.decode(Forecast.self, from: data) - } - .mapError { _ in Failure() } + let (data, _) = try await URLSession.shared.data(from: components.url!) + return try jsonDecoder.decode(Forecast.self, from: data) }, search: { query in var components = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search")! components.queryItems = [URLQueryItem(name: "name", value: query)] - return URLSession.shared.reactive.data(with: URLRequest(url: components.url!)) - .map { data, _ in data } - .attemptMap { data in - try jsonDecoder.decode(Search.self, from: data) - } - .mapError { _ in Failure() } + let (data, _) = try await URLSession.shared.data(from: components.url!) + return try jsonDecoder.decode(Search.self, from: data) } ) } @@ -82,8 +73,8 @@ extension WeatherClient { extension WeatherClient { static let unimplemented = Self( - forecast: { _ in .unimplemented("\(Self.self).forecast") }, - search: { _ in .unimplemented("\(Self.self).search") } + forecast: XCTUnimplemented("\(Self.self).forecast"), + search: XCTUnimplemented("\(Self.self).search") ) } diff --git a/Examples/Search/SearchTests/SearchTests.swift b/Examples/Search/SearchTests/SearchTests.swift index 10f630085..fedd0722e 100644 --- a/Examples/Search/SearchTests/SearchTests.swift +++ b/Examples/Search/SearchTests/SearchTests.swift @@ -4,10 +4,11 @@ import XCTest @testable import Search +@MainActor class SearchTests: XCTestCase { let mainQueue = TestScheduler() - func testSearchAndClearQuery() { + func testSearchAndClearQuery() async { let store = TestStore( initialState: SearchState(), reducer: searchReducer, @@ -16,22 +17,22 @@ class SearchTests: XCTestCase { mainQueue: self.mainQueue ) ) + store.environment.weatherClient.search = { _ in .mock } - store.environment.weatherClient.search = { _ in Effect(value: .mock) } - store.send(.searchQueryChanged("S")) { + await store.send(.searchQueryChanged("S")) { $0.searchQuery = "S" } - self.mainQueue.advance(by: 0.3) - store.receive(.searchResponse(.success(.mock))) { + await self.mainQueue.advance(by: 0.3) + await store.receive(.searchResponse(.success(.mock))) { $0.results = Search.mock.results } - store.send(.searchQueryChanged("")) { + await store.send(.searchQueryChanged("")) { $0.results = [] $0.searchQuery = "" } } - func testSearchFailure() { + func testSearchFailure() async { let store = TestStore( initialState: SearchState(), reducer: searchReducer, @@ -41,17 +42,17 @@ class SearchTests: XCTestCase { ) ) - store.environment.weatherClient.search = { _ in Effect(error: WeatherClient.Failure()) } - store.send(.searchQueryChanged("S")) { + store.environment.weatherClient.search = { _ in throw SomethingWentWrong() } + await store.send(.searchQueryChanged("S")) { $0.searchQuery = "S" } - self.mainQueue.advance(by: 0.3) - store.receive(.searchResponse(.failure(WeatherClient.Failure()))) + await self.mainQueue.advance(by: 0.3) + await store.receive(.searchResponse(.failure(SomethingWentWrong()))) } - func testClearQueryCancelsInFlightSearchRequest() { + func testClearQueryCancelsInFlightSearchRequest() async { var weatherClient = WeatherClient.unimplemented - weatherClient.search = { _ in Effect(value: .mock) } + weatherClient.search = { _ in .mock } let store = TestStore( initialState: SearchState(), @@ -62,17 +63,17 @@ class SearchTests: XCTestCase { ) ) - store.send(.searchQueryChanged("S")) { + await store.send(.searchQueryChanged("S")) { $0.searchQuery = "S" } - self.mainQueue.advance(by: 0.2) - store.send(.searchQueryChanged("")) { + await self.mainQueue.advance(by: 0.2) + await store.send(.searchQueryChanged("")) { $0.searchQuery = "" } - self.mainQueue.run() + await self.mainQueue.run() } - func testTapOnLocation() { + func testTapOnLocation() async { let specialResult = Search.Result( country: "Special Country", latitude: 0, @@ -85,7 +86,7 @@ class SearchTests: XCTestCase { results.append(specialResult) var weatherClient = WeatherClient.unimplemented - weatherClient.forecast = { _ in Effect(value: .mock) } + weatherClient.forecast = { _ in .mock } let store = TestStore( initialState: SearchState(results: results), @@ -96,11 +97,11 @@ class SearchTests: XCTestCase { ) ) - store.send(.searchResultTapped(specialResult)) { + await store.send(.searchResultTapped(specialResult)) { $0.resultForecastRequestInFlight = specialResult } - self.mainQueue.advance() - store.receive(.forecastResponse(42, .success(.mock))) { + await self.mainQueue.advance() + await store.receive(.forecastResponse(42, .success(.mock))) { $0.resultForecastRequestInFlight = nil $0.weather = SearchState.Weather( id: 42, @@ -131,7 +132,7 @@ class SearchTests: XCTestCase { } } - func testTapOnLocationCancelsInFlightRequest() { + func testTapOnLocationCancelsInFlightRequest() async { let specialResult = Search.Result( country: "Special Country", latitude: 0, @@ -144,7 +145,10 @@ class SearchTests: XCTestCase { results.append(specialResult) var weatherClient = WeatherClient.unimplemented - weatherClient.forecast = { _ in Effect(value: .mock) } + weatherClient.forecast = { _ in + try await self.mainQueue.sleep(for: .seconds(0)) + return .mock + } let store = TestStore( initialState: SearchState(results: results), @@ -155,14 +159,14 @@ class SearchTests: XCTestCase { ) ) - store.send(.searchResultTapped(results.first!)) { + await store.send(.searchResultTapped(results.first!)) { $0.resultForecastRequestInFlight = results.first! } - store.send(.searchResultTapped(specialResult)) { + await store.send(.searchResultTapped(specialResult)) { $0.resultForecastRequestInFlight = specialResult } - self.mainQueue.advance() - store.receive(.forecastResponse(42, .success(.mock))) { + await self.mainQueue.advance() + await store.receive(.forecastResponse(42, .success(.mock))) { $0.resultForecastRequestInFlight = nil $0.weather = SearchState.Weather( id: 42, @@ -193,9 +197,9 @@ class SearchTests: XCTestCase { } } - func testTapOnLocationFailure() { + func testTapOnLocationFailure() async { var weatherClient = WeatherClient.unimplemented - weatherClient.forecast = { _ in Effect(error: WeatherClient.Failure()) } + weatherClient.forecast = { _ in throw SomethingWentWrong() } let results = Search.mock.results @@ -208,12 +212,14 @@ class SearchTests: XCTestCase { ) ) - store.send(.searchResultTapped(results.first!)) { + await store.send(.searchResultTapped(results.first!)) { $0.resultForecastRequestInFlight = results.first! } - self.mainQueue.advance() - store.receive(.forecastResponse(1, .failure(WeatherClient.Failure()))) { + await self.mainQueue.advance() + await store.receive(.forecastResponse(1, .failure(SomethingWentWrong()))) { $0.resultForecastRequestInFlight = nil } } } + +private struct SomethingWentWrong: Equatable, Error {} diff --git a/Examples/SpeechRecognition/README.md b/Examples/SpeechRecognition/README.md index 127975b2d..a3a087040 100644 --- a/Examples/SpeechRecognition/README.md +++ b/Examples/SpeechRecognition/README.md @@ -2,4 +2,4 @@ This application demonstrates how to work with a complex dependency in the Composable Architecture. It uses the `SFSpeechRecognizer` API from the `Speech` framework to listen to audio on the device and live-transcribe it to the UI. -The `SFSpeechRecognizer` class is a complex dependency, and if we used it freely in our application we wouldn't be able to test any of that code. So, instead, we wrap the API in a `SpeechClient` type that exposes `Effect`s for accessing the underlying `SFSpeechRecognizer` class. Then we can use it in the reducer in an understandable way, _and_ we can write tests for the reducer. +The `SFSpeechRecognizer` class is a complex dependency, and if we used it freely in our application we wouldn't be able to test any of that code. So, instead, we wrap the API in a `SpeechClient` type that exposes asynchronous endpoints for accessing the underlying `SFSpeechRecognizer` class. Then we can use it in the reducer in an understandable way, _and_ we can write tests for the reducer. diff --git a/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.pbxproj b/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.pbxproj index fe45e6634..8aa667391 100644 --- a/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.pbxproj +++ b/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ CA23320C2447ACFA00B818EB /* SpeechRecognition */, CA2332232447ACFC00B818EB /* SpeechRecognitionTests */, ); + indentWidth = 2; sourceTree = ""; }; CA23320B2447ACFA00B818EB /* Products */ = { @@ -396,6 +397,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-Xfrontend -enable-actor-data-race-checks -Xfrontend -warn-concurrency"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SpeechRecognition; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -414,6 +416,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-Xfrontend -enable-actor-data-race-checks -Xfrontend -warn-concurrency"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SpeechRecognition; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift index bb9b6df8b..0f361fff9 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift @@ -1,13 +1,14 @@ -import Combine -import ComposableArchitecture import Speech struct SpeechClient { - var finishTask: () -> Effect - var requestAuthorization: () -> Effect - var startTask: (SFSpeechAudioBufferRecognitionRequest) -> Effect + var finishTask: @Sendable () async -> Void + var requestAuthorization: @Sendable () async -> SFSpeechRecognizerAuthorizationStatus + var startTask: + @Sendable (SFSpeechAudioBufferRecognitionRequest) async -> AsyncThrowingStream< + SpeechRecognitionResult, Error + > - enum Error: Swift.Error, Equatable { + enum Failure: Error, Equatable { case taskError case couldntStartAudioEngine case couldntConfigureAudioSession diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift index 4096fab42..12e73f347 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift @@ -1,77 +1,99 @@ import ComposableArchitecture -import ReactiveSwift import Speech extension SpeechClient { static var live: Self { - var audioEngine: AVAudioEngine? - var recognitionTask: SFSpeechRecognitionTask? + let speech = Speech() return Self( finishTask: { - .fireAndForget { - audioEngine?.stop() - audioEngine?.inputNode.removeTap(onBus: 0) - recognitionTask?.finish() - } + await speech.finishTask() }, requestAuthorization: { - .future { callback in + await withCheckedContinuation { continuation in SFSpeechRecognizer.requestAuthorization { status in - callback(.success(status)) + continuation.resume(returning: status) } } }, startTask: { request in - Effect { subscriber, lifetime in - audioEngine = AVAudioEngine() - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) - try audioSession.setActive(true, options: .notifyOthersOnDeactivation) - } catch { - subscriber.send(error: .couldntConfigureAudioSession) - return - } + let request = UncheckedSendable(request) + return await speech.startTask(request: request) + } + ) + } +} - let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! - recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in - switch (result, error) { - case let (.some(result), _): - subscriber.send(value: SpeechRecognitionResult(result)) - case (_, .some): - subscriber.send(error: .taskError) - case (.none, .none): - fatalError("It should not be possible to have both a nil result and nil error.") - } - } +private actor Speech { + var audioEngine: AVAudioEngine? = nil + var recognitionTask: SFSpeechRecognitionTask? = nil + var recognitionContinuation: AsyncThrowingStream.Continuation? - let cancellable = AnyDisposable { - audioEngine?.stop() - audioEngine?.inputNode.removeTap(onBus: 0) - recognitionTask?.cancel() - } - lifetime += cancellable + func finishTask() { + self.audioEngine?.stop() + self.audioEngine?.inputNode.removeTap(onBus: 0) + self.recognitionTask?.finish() + self.recognitionContinuation?.finish() + } - audioEngine?.inputNode.installTap( - onBus: 0, - bufferSize: 1024, - format: audioEngine?.inputNode.outputFormat(forBus: 0) - ) { buffer, when in - request.append(buffer) - } + func startTask( + request: UncheckedSendable + ) -> AsyncThrowingStream { + let request = request.wrappedValue - audioEngine!.prepare() - do { - try audioEngine!.start() - } catch { - subscriber.send(error: .couldntStartAudioEngine) - return - } + return AsyncThrowingStream { continuation in + self.recognitionContinuation = continuation + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + continuation.finish(throwing: SpeechClient.Failure.couldntConfigureAudioSession) + return + } - return + self.audioEngine = AVAudioEngine() + let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! + self.recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in + switch (result, error) { + case let (.some(result), _): + continuation.yield(SpeechRecognitionResult(result)) + case (_, .some): + continuation.finish(throwing: SpeechClient.Failure.taskError) + case (.none, .none): + fatalError("It should not be possible to have both a nil result and nil error.") } } - ) + + continuation.onTermination = { + [ + speechRecognizer = UncheckedSendable(speechRecognizer), + audioEngine = UncheckedSendable(audioEngine), + recognitionTask = UncheckedSendable(recognitionTask) + ] + _ in + + _ = speechRecognizer + audioEngine.wrappedValue?.stop() + audioEngine.wrappedValue?.inputNode.removeTap(onBus: 0) + recognitionTask.wrappedValue?.finish() + } + + self.audioEngine?.inputNode.installTap( + onBus: 0, + bufferSize: 1024, + format: self.audioEngine?.inputNode.outputFormat(forBus: 0) + ) { buffer, when in + request.append(buffer) + } + + self.audioEngine?.prepare() + do { + try self.audioEngine?.start() + } catch { + continuation.finish(throwing: SpeechClient.Failure.couldntStartAudioEngine) + return + } + } } } diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Models.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Models.swift index 18779f85b..6347cc7da 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Models.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Models.swift @@ -1,7 +1,7 @@ import Speech // The core data types in the Speech framework are reference types and are not constructible by us, -// and so they aren't super testable out the box. We define struct versions of those types to make +// and so they aren't testable out the box. We define struct versions of those types to make // them easier to use and test. struct SpeechRecognitionMetadata: Equatable { @@ -27,7 +27,6 @@ struct TranscriptionSegment: Equatable { var confidence: Float var duration: TimeInterval var substring: String - var substringRange: NSRange var timestamp: TimeInterval } @@ -74,7 +73,6 @@ extension TranscriptionSegment { self.confidence = transcriptionSegment.confidence self.duration = transcriptionSegment.duration self.substring = transcriptionSegment.substring - self.substringRange = transcriptionSegment.substringRange self.timestamp = transcriptionSegment.timestamp } } diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Unimplemented.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Unimplemented.swift index 8e4ed9627..c7dd6ef73 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Unimplemented.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Unimplemented.swift @@ -1,13 +1,17 @@ -import Combine import ComposableArchitecture import Speech +import XCTestDynamicOverlay #if DEBUG extension SpeechClient { static let unimplemented = Self( - finishTask: { .unimplemented("\(Self.self).finishTask") }, - requestAuthorization: { .unimplemented("\(Self.self).requestAuthorization") }, - startTask: { _ in .unimplemented("\(Self.self).recognitionTask") } + finishTask: XCTUnimplemented("\(Self.self).finishTask"), + requestAuthorization: XCTUnimplemented( + "\(Self.self).requestAuthorization", placeholder: .notDetermined + ), + startTask: XCTUnimplemented( + "\(Self.self).recognitionTask", placeholder: AsyncThrowingStream.never + ) ) } #endif diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift index f1d807be1..5f89cfae6 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift @@ -1,12 +1,11 @@ import ComposableArchitecture -import ReactiveSwift import Speech -import SwiftUI +@preconcurrency import SwiftUI private let readMe = """ This application demonstrates how to work with a complex dependency in the Composable \ - Architecture. It uses the SFSpeechRecognizer API from the Speech framework to listen to audio on \ - the device and live-transcribe it to the UI. + Architecture. It uses the `SFSpeechRecognizer` API from the Speech framework to listen to audio \ + on the device and live-transcribe it to the UI. """ struct AppState: Equatable { @@ -18,12 +17,11 @@ struct AppState: Equatable { enum AppAction: Equatable { case authorizationStateAlertDismissed case recordButtonTapped - case speech(Result) + case speech(TaskResult) case speechRecognizerAuthorizationStatusResponse(SFSpeechRecognizerAuthorizationStatus) } struct AppEnvironment { - var mainQueue: DateScheduler var speechClient: SpeechClient } @@ -33,42 +31,53 @@ let appReducer = Reducer { state, action, e state.alert = nil return .none - case .speech(.failure(.couldntConfigureAudioSession)), - .speech(.failure(.couldntStartAudioEngine)): - state.alert = AlertState(title: TextState("Problem with audio device. Please try again.")) - return .none - case .recordButtonTapped: state.isRecording.toggle() - if state.isRecording { - return environment.speechClient.requestAuthorization() - .observe(on: environment.mainQueue) - .map(AppAction.speechRecognizerAuthorizationStatusResponse) - } else { - return environment.speechClient.finishTask() - .fireAndForget() + + guard state.isRecording + else { + return .fireAndForget { + await environment.speechClient.finishTask() + } } - case let .speech(.success(transcribedText)): - state.transcribedText = transcribedText + return .run { send in + let status = await environment.speechClient.requestAuthorization() + await send(.speechRecognizerAuthorizationStatusResponse(status)) + + guard status == .authorized + else { return } + + let request = SFSpeechAudioBufferRecognitionRequest() + for try await result in await environment.speechClient.startTask(request) { + await send(.speech(.success(result.bestTranscription.formattedString)), animation: .linear) + } + } catch: { error, send in + await send(.speech(.failure(error))) + } + + case .speech(.failure(SpeechClient.Failure.couldntConfigureAudioSession)), + .speech(.failure(SpeechClient.Failure.couldntStartAudioEngine)): + state.alert = AlertState(title: TextState("Problem with audio device. Please try again.")) return .none - case let .speech(.failure(error)): + case .speech(.failure): state.alert = AlertState( title: TextState("An error occurred while transcribing. Please try again.") ) - return environment.speechClient.finishTask() - .fireAndForget() + return .none - case let .speechRecognizerAuthorizationStatusResponse(status): - state.isRecording = status == .authorized + case let .speech(.success(transcribedText)): + state.transcribedText = transcribedText + return .none + case let .speechRecognizerAuthorizationStatusResponse(status): switch status { - case .notDetermined: - state.alert = AlertState(title: TextState("Try again.")) + case .authorized: return .none case .denied: + state.isRecording = false state.alert = AlertState( title: TextState( """ @@ -78,32 +87,23 @@ let appReducer = Reducer { state, action, e ) return .none + case .notDetermined: + state.isRecording = false + return .none + case .restricted: + state.isRecording = false state.alert = AlertState(title: TextState("Your device does not allow speech recognition.")) return .none - case .authorized: - let request = SFSpeechAudioBufferRecognitionRequest() - request.shouldReportPartialResults = true - request.requiresOnDeviceRecognition = false - return environment.speechClient.startTask(request) - .map(\.bestTranscription.formattedString) - .animation() - .catchToEffect(AppAction.speech) - @unknown default: + state.isRecording = false return .none } } } .debug() -struct AuthorizationStateAlert: Equatable, Identifiable { - var title: String - - var id: String { self.title } -} - struct SpeechRecognitionView: View { let store: Store @@ -150,7 +150,6 @@ struct SpeechRecognitionView_Previews: PreviewProvider { initialState: AppState(transcribedText: "Test test 123"), reducer: appReducer, environment: AppEnvironment( - mainQueue: QueueScheduler.main, speechClient: .lorem ) ) @@ -160,40 +159,37 @@ struct SpeechRecognitionView_Previews: PreviewProvider { extension SpeechClient { static var lorem: Self { - var isRunning = false + let isRecording = ActorIsolated(false) + return Self( - finishTask: { - .fireAndForget { - isRunning = false - } - }, - requestAuthorization: { - .init(value: .authorized) - }, + finishTask: { await isRecording.setValue(false) }, + requestAuthorization: { .authorized }, startTask: { _ in - isRunning = true - var finalText = """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure \ - dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. \ - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt \ - mollit anim id est laborum. - """ - var text = "" - - return Effect { subscriber, lifetime in - let disposable = Effect.timer(interval: .milliseconds(330), on: QueueScheduler.main) - .take(while: { _ in !finalText.isEmpty && isRunning }) - .startWithValues { _ in + .init { c in + Task { + await isRecording.setValue(true) + var finalText = """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui \ + officia deserunt mollit anim id est laborum. + """ + var text = "" + while await isRecording.value { let word = finalText.prefix { $0 != " " } + try await Task.sleep( + nanoseconds: UInt64(word.count) * NSEC_PER_MSEC * 50 + + .random(in: 0...(NSEC_PER_MSEC * 200)) + ) finalText.removeFirst(word.count) if finalText.first == " " { finalText.removeFirst() } text += word + " " - subscriber.send( - value: .init( + c.yield( + .init( bestTranscription: .init( formattedString: text, segments: [] @@ -203,8 +199,7 @@ extension SpeechClient { ) ) } - - lifetime += disposable + } } } ) diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognitionApp.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognitionApp.swift index df693d2d7..ad31c34ac 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognitionApp.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognitionApp.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import ReactiveSwift import SwiftUI @main @@ -11,7 +10,6 @@ struct SpeechRecognitionApp: App { initialState: AppState(), reducer: appReducer, environment: AppEnvironment( - mainQueue: QueueScheduler.main, speechClient: .live ) ) diff --git a/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift b/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift index def4b7332..6f8770f64 100644 --- a/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift +++ b/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift @@ -1,26 +1,25 @@ import ComposableArchitecture -import ReactiveSwift import XCTest @testable import SpeechRecognition +@MainActor class SpeechRecognitionTests: XCTestCase { - let recognitionTaskSubject = Signal.pipe() + let recognitionTask = AsyncThrowingStream.streamWithContinuation() - func testDenyAuthorization() { + func testDenyAuthorization() async { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: .unimplemented ) - store.environment.mainQueue = ImmediateScheduler() - store.environment.speechClient.requestAuthorization = { Effect(value: .denied) } + store.environment.speechClient.requestAuthorization = { .denied } - store.send(.recordButtonTapped) { + await store.send(.recordButtonTapped) { $0.isRecording = true } - store.receive(.speechRecognizerAuthorizationStatusResponse(.denied)) { + await store.receive(.speechRecognizerAuthorizationStatusResponse(.denied)) { $0.alert = AlertState( title: TextState( """ @@ -32,38 +31,34 @@ class SpeechRecognitionTests: XCTestCase { } } - func testRestrictedAuthorization() { + func testRestrictedAuthorization() async { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: .unimplemented ) - store.environment.mainQueue = ImmediateScheduler() - store.environment.speechClient.requestAuthorization = { Effect(value: .restricted) } + store.environment.speechClient.requestAuthorization = { .restricted } - store.send(.recordButtonTapped) { + await store.send(.recordButtonTapped) { $0.isRecording = true } - store.receive(.speechRecognizerAuthorizationStatusResponse(.restricted)) { + await store.receive(.speechRecognizerAuthorizationStatusResponse(.restricted)) { $0.alert = AlertState(title: TextState("Your device does not allow speech recognition.")) $0.isRecording = false } } - func testAllowAndRecord() { + func testAllowAndRecord() async { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: .unimplemented ) - store.environment.mainQueue = ImmediateScheduler() - store.environment.speechClient.finishTask = { - .fireAndForget { self.recognitionTaskSubject.input.sendCompleted() } - } - store.environment.speechClient.requestAuthorization = { Effect(value: .authorized) } - store.environment.speechClient.startTask = { _ in self.recognitionTaskSubject.output.producer } + store.environment.speechClient.finishTask = { self.recognitionTask.continuation.finish() } + store.environment.speechClient.startTask = { _ in self.recognitionTask.stream } + store.environment.speechClient.requestAuthorization = { .authorized } let firstResult = SpeechRecognitionResult( bestTranscription: Transcription( @@ -76,81 +71,74 @@ class SpeechRecognitionTests: XCTestCase { var secondResult = firstResult secondResult.bestTranscription.formattedString = "Hello world" - store.send(.recordButtonTapped) { + await store.send(.recordButtonTapped) { $0.isRecording = true } - store.receive(.speechRecognizerAuthorizationStatusResponse(.authorized)) + await store.receive(.speechRecognizerAuthorizationStatusResponse(.authorized)) - self.recognitionTaskSubject.input.send(value: firstResult) - store.receive(.speech(.success("Hello"))) { + self.recognitionTask.continuation.yield(firstResult) + await store.receive(.speech(.success("Hello"))) { $0.transcribedText = "Hello" } - self.recognitionTaskSubject.input.send(value: secondResult) - store.receive(.speech(.success("Hello world"))) { + self.recognitionTask.continuation.yield(secondResult) + await store.receive(.speech(.success("Hello world"))) { $0.transcribedText = "Hello world" } - store.send(.recordButtonTapped) { + await store.send(.recordButtonTapped) { $0.isRecording = false } + + await store.finish() } - func testAudioSessionFailure() { + func testAudioSessionFailure() async { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: .unimplemented ) - store.environment.mainQueue = ImmediateScheduler() - store.environment.speechClient.startTask = { _ in self.recognitionTaskSubject.output.producer } - store.environment.speechClient.requestAuthorization = { Effect(value: .authorized) } + store.environment.speechClient.startTask = { _ in self.recognitionTask.stream } + store.environment.speechClient.requestAuthorization = { .authorized } - store.send(.recordButtonTapped) { + await store.send(.recordButtonTapped) { $0.isRecording = true } - store.receive(.speechRecognizerAuthorizationStatusResponse(.authorized)) + await store.receive(.speechRecognizerAuthorizationStatusResponse(.authorized)) - self.recognitionTaskSubject.input.send(error: .couldntConfigureAudioSession) - store.receive(.speech(.failure(.couldntConfigureAudioSession))) { + recognitionTask.continuation.finish(throwing: SpeechClient.Failure.couldntConfigureAudioSession) + await store.receive(.speech(.failure(SpeechClient.Failure.couldntConfigureAudioSession))) { $0.alert = AlertState(title: TextState("Problem with audio device. Please try again.")) } - - self.recognitionTaskSubject.input.sendCompleted() } - func testAudioEngineFailure() { + func testAudioEngineFailure() async { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: .unimplemented ) - store.environment.mainQueue = ImmediateScheduler() - store.environment.speechClient.startTask = { _ in self.recognitionTaskSubject.output.producer } - store.environment.speechClient.requestAuthorization = { Effect(value: .authorized) } + store.environment.speechClient.startTask = { _ in self.recognitionTask.stream } + store.environment.speechClient.requestAuthorization = { .authorized } - store.send(.recordButtonTapped) { + await store.send(.recordButtonTapped) { $0.isRecording = true } - store.receive(.speechRecognizerAuthorizationStatusResponse(.authorized)) + await store.receive(.speechRecognizerAuthorizationStatusResponse(.authorized)) - self.recognitionTaskSubject.input.send(error: .couldntStartAudioEngine) - store.receive(.speech(.failure(.couldntStartAudioEngine))) { + recognitionTask.continuation.finish(throwing: SpeechClient.Failure.couldntStartAudioEngine) + await store.receive(.speech(.failure(SpeechClient.Failure.couldntStartAudioEngine))) { $0.alert = AlertState(title: TextState("Problem with audio device. Please try again.")) } - - self.recognitionTaskSubject.input.sendCompleted() } } extension AppEnvironment { - static let unimplemented = Self( - mainQueue: UnimplementedScheduler(), - speechClient: .unimplemented - ) + static let unimplemented = Self(speechClient: .unimplemented) } diff --git a/Examples/TicTacToe/App/RootView.swift b/Examples/TicTacToe/App/RootView.swift index bc9e0030f..cbb294e64 100644 --- a/Examples/TicTacToe/App/RootView.swift +++ b/Examples/TicTacToe/App/RootView.swift @@ -3,7 +3,6 @@ import AppSwiftUI import AppUIKit import AuthenticationClientLive import ComposableArchitecture -import ReactiveSwift import SwiftUI private let readMe = """ @@ -33,8 +32,7 @@ struct RootView: View { initialState: AppState(), reducer: appReducer, environment: AppEnvironment( - authenticationClient: .live, - mainQueue: QueueScheduler.main + authenticationClient: .live ) ) diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj b/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj index d54bd24ea..990b0dd09 100644 --- a/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj +++ b/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ CA6AC25C2450FDB800C71CB3 /* App */, DC9193F72420104100A5BE1F /* Products */, ); + indentWidth = 2; sourceTree = ""; }; DC9193F72420104100A5BE1F /* Products */ = { @@ -168,6 +169,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -193,6 +195,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; diff --git a/Examples/TicTacToe/tic-tac-toe/Package.swift b/Examples/TicTacToe/tic-tac-toe/Package.swift index 098c20505..1e0dc6f01 100644 --- a/Examples/TicTacToe/tic-tac-toe/Package.swift +++ b/Examples/TicTacToe/tic-tac-toe/Package.swift @@ -180,3 +180,12 @@ let package = Package( ), ] ) + +for target in package.targets { + target.swiftSettings = [ + .unsafeFlags([ + "-Xfrontend", "-enable-actor-data-race-checks", + "-Xfrontend", "-warn-concurrency", + ]) + ] +} diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift index 55d33a195..50fc10454 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift @@ -3,7 +3,6 @@ import ComposableArchitecture import Dispatch import LoginCore import NewGameCore -import ReactiveSwift public enum AppState: Equatable { case login(LoginState) @@ -19,14 +18,11 @@ public enum AppAction: Equatable { public struct AppEnvironment { public var authenticationClient: AuthenticationClient - public var mainQueue: DateScheduler public init( - authenticationClient: AuthenticationClient, - mainQueue: DateScheduler + authenticationClient: AuthenticationClient ) { self.authenticationClient = authenticationClient - self.mainQueue = mainQueue } } @@ -36,8 +32,7 @@ public let appReducer = Reducer.combine( action: /AppAction.login, environment: { LoginEnvironment( - authenticationClient: $0.authenticationClient, - mainQueue: $0.mainQueue + authenticationClient: $0.authenticationClient ) } ), diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift index 362a6a5db..2dd692e9d 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift @@ -1,7 +1,8 @@ import ComposableArchitecture import Foundation +import XCTestDynamicOverlay -public struct LoginRequest { +public struct LoginRequest: Sendable { public var email: String public var password: String @@ -27,7 +28,7 @@ public struct TwoFactorRequest { } } -public struct AuthenticationResponse: Equatable { +public struct AuthenticationResponse: Equatable, Sendable { public var token: String public var twoFactorRequired: Bool @@ -40,7 +41,7 @@ public struct AuthenticationResponse: Equatable { } } -public enum AuthenticationError: Equatable, LocalizedError { +public enum AuthenticationError: Equatable, LocalizedError, Sendable { case invalidUserPassword case invalidTwoFactor case invalidIntermediateToken @@ -57,13 +58,13 @@ public enum AuthenticationError: Equatable, LocalizedError { } } -public struct AuthenticationClient { - public var login: (LoginRequest) -> Effect - public var twoFactor: (TwoFactorRequest) -> Effect +public struct AuthenticationClient: Sendable { + public var login: @Sendable (LoginRequest) async throws -> AuthenticationResponse + public var twoFactor: @Sendable (TwoFactorRequest) async throws -> AuthenticationResponse public init( - login: @escaping (LoginRequest) -> Effect, - twoFactor: @escaping (TwoFactorRequest) -> Effect + login: @escaping @Sendable (LoginRequest) async throws -> AuthenticationResponse, + twoFactor: @escaping @Sendable (TwoFactorRequest) async throws -> AuthenticationResponse ) { self.login = login self.twoFactor = twoFactor @@ -73,8 +74,8 @@ public struct AuthenticationClient { #if DEBUG extension AuthenticationClient { public static let unimplemented = Self( - login: { _ in .unimplemented("\(Self.self).login") }, - twoFactor: { _ in .unimplemented("\(Self.self).twoFactor") } + login: XCTUnimplemented("\(Self.self).login"), + twoFactor: XCTUnimplemented("\(Self.self).twoFactor") ) } #endif diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClientLive/LiveAuthenticationClient.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClientLive/LiveAuthenticationClient.swift index 892e8b58b..2dfc118de 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClientLive/LiveAuthenticationClient.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClientLive/LiveAuthenticationClient.swift @@ -1,41 +1,26 @@ import AuthenticationClient -import ComposableArchitecture import Foundation -import ReactiveSwift extension AuthenticationClient { public static let live = AuthenticationClient( login: { request in - var effect: Effect { - if request.email.contains("@") && request.password == "password" { - return Effect( - value: AuthenticationResponse( - token: "deadbeef", twoFactorRequired: request.email.contains("2fa") - ) - ) - } else { - return Effect(error: .invalidUserPassword) - } - } - return - effect - .delay(1, on: QueueScheduler(qos: .default, name: "auth", targeting: queue)) + guard request.email.contains("@") && request.password == "password" + else { throw AuthenticationError.invalidUserPassword } + + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return AuthenticationResponse( + token: "deadbeef", twoFactorRequired: request.email.contains("2fa") + ) }, twoFactor: { request in - var effect: Effect { - if request.token != "deadbeef" { - return Effect(error: .invalidIntermediateToken) - } else if request.code != "1234" { - return Effect(error: .invalidTwoFactor) - } else { - return Effect( - value: AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) - ) - } - } - return - effect - .delay(1, on: QueueScheduler(qos: .default, name: "auth", targeting: queue)) + guard request.token == "deadbeef" + else { throw AuthenticationError.invalidIntermediateToken } + + guard request.code == "1234" + else { throw AuthenticationError.invalidTwoFactor } + + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) } ) } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift index f485da411..400e62c5a 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift @@ -39,7 +39,7 @@ public struct GameState: Equatable { } } -public enum GameAction: Equatable { +public enum GameAction: Equatable, Sendable { case cellTapped(row: Int, column: Int) case playAgainButtonTapped case quitButtonTapped diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift index 0fa74e6bf..6c2f92352 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift @@ -5,7 +5,7 @@ import SwiftUI public struct GameView: View { let store: Store - struct ViewState: Equatable { + struct ViewState: Equatable, Sendable { var board: [[String]] var isGameDisabled: Bool var isPlayAgainButtonVisible: Bool diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift index a0edd4160..6ec70ad0e 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift @@ -1,7 +1,6 @@ import AuthenticationClient import ComposableArchitecture import Dispatch -import ReactiveSwift import TwoFactorCore public struct LoginState: Equatable { @@ -20,21 +19,18 @@ public enum LoginAction: Equatable { case emailChanged(String) case passwordChanged(String) case loginButtonTapped - case loginResponse(Result) + case loginResponse(TaskResult) case twoFactor(TwoFactorAction) case twoFactorDismissed } -public struct LoginEnvironment { +public struct LoginEnvironment: Sendable { public var authenticationClient: AuthenticationClient - public var mainQueue: DateScheduler public init( - authenticationClient: AuthenticationClient, - mainQueue: DateScheduler + authenticationClient: AuthenticationClient ) { self.authenticationClient = authenticationClient - self.mainQueue = mainQueue } } @@ -46,8 +42,7 @@ public let loginReducer = Reducer.com action: /LoginAction.twoFactor, environment: { TwoFactorEnvironment( - authenticationClient: $0.authenticationClient, - mainQueue: $0.mainQueue + authenticationClient: $0.authenticationClient ) } ), @@ -83,10 +78,15 @@ public let loginReducer = Reducer.com case .loginButtonTapped: state.isLoginRequestInFlight = true - return environment.authenticationClient - .login(LoginRequest(email: state.email, password: state.password)) - .observe(on: environment.mainQueue) - .catchToEffect(LoginAction.loginResponse) + return .task { [email = state.email, password = state.password] in + await .loginResponse( + TaskResult { + try await environment.authenticationClient.login( + .init(email: email, password: password) + ) + } + ) + } case .twoFactor: return .none diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift index 434d469a5..09d994235 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift @@ -1,7 +1,6 @@ import AuthenticationClient import ComposableArchitecture import LoginCore -import ReactiveSwift import SwiftUI import TwoFactorCore import TwoFactorSwiftUI @@ -128,14 +127,11 @@ struct LoginView_Previews: PreviewProvider { reducer: loginReducer, environment: LoginEnvironment( authenticationClient: AuthenticationClient( - login: { _ in - Effect(value: AuthenticationResponse(token: "deadbeef", twoFactorRequired: false)) - }, + login: { _ in AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) }, twoFactor: { _ in - Effect(value: AuthenticationResponse(token: "deadbeef", twoFactorRequired: false)) + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) } - ), - mainQueue: QueueScheduler.main + ) ) ) ) diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift index 5c4c9948d..fcd4d8efb 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift @@ -1,7 +1,6 @@ import AuthenticationClient import ComposableArchitecture import Dispatch -import ReactiveSwift public struct TwoFactorState: Equatable { public var alert: AlertState? @@ -19,21 +18,18 @@ public enum TwoFactorAction: Equatable { case alertDismissed case codeChanged(String) case submitButtonTapped - case twoFactorResponse(Result) + case twoFactorResponse(TaskResult) } public enum TwoFactorTearDownToken {} -public struct TwoFactorEnvironment { +public struct TwoFactorEnvironment: Sendable { public var authenticationClient: AuthenticationClient - public var mainQueue: DateScheduler public init( - authenticationClient: AuthenticationClient, - mainQueue: DateScheduler + authenticationClient: AuthenticationClient ) { self.authenticationClient = authenticationClient - self.mainQueue = mainQueue } } @@ -52,11 +48,14 @@ public let twoFactorReducer = Reducer UUID + var uuid: @Sendable () -> UUID } let appReducer = Reducer.combine( @@ -80,17 +80,19 @@ let appReducer = Reducer.combine( state.todos.move(fromOffsets: source, toOffset: destination) - return Effect(value: .sortCompletedTodos) - .delay(0.1, on: environment.mainQueue) + return .task { + try await environment.mainQueue.sleep(for: .milliseconds(100)) + return .sortCompletedTodos + } case .sortCompletedTodos: state.todos.sort { $1.isComplete && !$0.isComplete } return .none case .todo(id: _, action: .checkBoxToggled): - enum TodoCompletionId {} - return Effect(value: .sortCompletedTodos) - .debounce(id: TodoCompletionId.self, for: 1, scheduler: environment.mainQueue.animation()) + enum TodoCompletionID {} + return .task { .sortCompletedTodos } + .debounce(id: TodoCompletionID.self, for: 1, scheduler: environment.mainQueue.animation()) case .todo: return .none @@ -192,7 +194,7 @@ struct AppView_Previews: PreviewProvider { reducer: appReducer, environment: AppEnvironment( mainQueue: QueueScheduler.main, - uuid: UUID.init + uuid: { UUID() } ) ) ) diff --git a/Examples/Todos/Todos/TodosApp.swift b/Examples/Todos/Todos/TodosApp.swift index 7dc302552..9d96a84b3 100644 --- a/Examples/Todos/Todos/TodosApp.swift +++ b/Examples/Todos/Todos/TodosApp.swift @@ -12,7 +12,7 @@ struct TodosApp: App { reducer: appReducer, environment: AppEnvironment( mainQueue: QueueScheduler.main, - uuid: UUID.init + uuid: { UUID() } ) ) ) diff --git a/Examples/Todos/TodosTests/TodosTests.swift b/Examples/Todos/TodosTests/TodosTests.swift index 640bd11c4..d3e67de78 100644 --- a/Examples/Todos/TodosTests/TodosTests.swift +++ b/Examples/Todos/TodosTests/TodosTests.swift @@ -4,20 +4,21 @@ import XCTest @testable import Todos +@MainActor class TodosTests: XCTestCase { let mainQueue = TestScheduler() - func testAddTodo() { + func testAddTodo() async { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: AppEnvironment( mainQueue: self.mainQueue, - uuid: UUID.incrementing + uuid: { UUID.incrementing() } ) ) - store.send(.addTodoButtonTapped) { + await store.send(.addTodoButtonTapped) { $0.todos.insert( Todo( description: "", @@ -29,7 +30,7 @@ class TodosTests: XCTestCase { } } - func testEditTodo() { + func testEditTodo() async { let state = AppState( todos: [ Todo( @@ -44,18 +45,18 @@ class TodosTests: XCTestCase { reducer: appReducer, environment: AppEnvironment( mainQueue: self.mainQueue, - uuid: UUID.incrementing + uuid: { UUID.incrementing() } ) ) - store.send( + await store.send( .todo(id: state.todos[0].id, action: .textFieldChanged("Learn Composable Architecture")) ) { $0.todos[id: state.todos[0].id]?.description = "Learn Composable Architecture" } } - func testCompleteTodo() { + func testCompleteTodo() async { let state = AppState( todos: [ Todo( @@ -75,15 +76,15 @@ class TodosTests: XCTestCase { reducer: appReducer, environment: AppEnvironment( mainQueue: self.mainQueue, - uuid: UUID.incrementing + uuid: { UUID.incrementing() } ) ) - store.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { + await store.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { $0.todos[id: state.todos[0].id]?.isComplete = true } - self.mainQueue.advance(by: 1) - store.receive(.sortCompletedTodos) { + await self.mainQueue.advance(by: 1) + await store.receive(.sortCompletedTodos) { $0.todos = [ $0.todos[1], $0.todos[0], @@ -91,7 +92,7 @@ class TodosTests: XCTestCase { } } - func testCompleteTodoDebounces() { + func testCompleteTodoDebounces() async { let state = AppState( todos: [ Todo( @@ -111,22 +112,22 @@ class TodosTests: XCTestCase { reducer: appReducer, environment: AppEnvironment( mainQueue: self.mainQueue, - uuid: UUID.incrementing + uuid: { UUID.incrementing() } ) ) - store.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { + await store.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { $0.todos[id: state.todos[0].id]?.isComplete = true } - self.mainQueue.advance(by: 0.5) - store.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { + await self.mainQueue.advance(by: 0.5) + await store.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { $0.todos[id: state.todos[0].id]?.isComplete = false } - self.mainQueue.advance(by: 1) - store.receive(.sortCompletedTodos) + await self.mainQueue.advance(by: 1) + await store.receive(.sortCompletedTodos) } - func testClearCompleted() { + func testClearCompleted() async { let state = AppState( todos: [ Todo( @@ -146,18 +147,18 @@ class TodosTests: XCTestCase { reducer: appReducer, environment: AppEnvironment( mainQueue: self.mainQueue, - uuid: UUID.incrementing + uuid: { UUID.incrementing() } ) ) - store.send(.clearCompletedButtonTapped) { + await store.send(.clearCompletedButtonTapped) { $0.todos = [ $0.todos[0] ] } } - func testDelete() { + func testDelete() async { let state = AppState( todos: [ Todo( @@ -182,11 +183,11 @@ class TodosTests: XCTestCase { reducer: appReducer, environment: AppEnvironment( mainQueue: self.mainQueue, - uuid: UUID.incrementing + uuid: { UUID.incrementing() } ) ) - store.send(.delete([1])) { + await store.send(.delete([1])) { $0.todos = [ $0.todos[0], $0.todos[2], @@ -194,7 +195,7 @@ class TodosTests: XCTestCase { } } - func testEditModeMoving() { + func testEditModeMoving() async { let state = AppState( todos: [ Todo( @@ -219,25 +220,25 @@ class TodosTests: XCTestCase { reducer: appReducer, environment: AppEnvironment( mainQueue: self.mainQueue, - uuid: UUID.incrementing + uuid: { UUID.incrementing() } ) ) - store.send(.editModeChanged(.active)) { + await store.send(.editModeChanged(.active)) { $0.editMode = .active } - store.send(.move([0], 2)) { + await store.send(.move([0], 2)) { $0.todos = [ $0.todos[1], $0.todos[0], $0.todos[2], ] } - self.mainQueue.advance(by: 0.1) - store.receive(.sortCompletedTodos) + await self.mainQueue.advance(by: .milliseconds(100)) + await store.receive(.sortCompletedTodos) } - func testEditModeMovingWithFilter() { + func testEditModeMovingWithFilter() async { let state = AppState( todos: [ Todo( @@ -267,17 +268,17 @@ class TodosTests: XCTestCase { reducer: appReducer, environment: AppEnvironment( mainQueue: self.mainQueue, - uuid: UUID.incrementing + uuid: { UUID.incrementing() } ) ) - store.send(.editModeChanged(.active)) { + await store.send(.editModeChanged(.active)) { $0.editMode = .active } - store.send(.filterPicked(.completed)) { + await store.send(.filterPicked(.completed)) { $0.filter = .completed } - store.send(.move([0], 1)) { + await store.send(.move([0], 1)) { $0.todos = [ $0.todos[0], $0.todos[2], @@ -285,11 +286,11 @@ class TodosTests: XCTestCase { $0.todos[3], ] } - self.mainQueue.advance(by: .milliseconds(100)) - store.receive(.sortCompletedTodos) + await self.mainQueue.advance(by: .milliseconds(100)) + await store.receive(.sortCompletedTodos) } - func testFilteredEdit() { + func testFilteredEdit() async { let state = AppState( todos: [ Todo( @@ -309,14 +310,14 @@ class TodosTests: XCTestCase { reducer: appReducer, environment: AppEnvironment( mainQueue: self.mainQueue, - uuid: UUID.incrementing + uuid: { UUID.incrementing() } ) ) - store.send(.filterPicked(.completed)) { + await store.send(.filterPicked(.completed)) { $0.filter = .completed } - store.send(.todo(id: state.todos[1].id, action: .textFieldChanged("Did this already"))) { + await store.send(.todo(id: state.todos[1].id, action: .textFieldChanged("Did this already"))) { $0.todos[id: state.todos[1].id]?.description = "Did this already" } } diff --git a/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.pbxproj b/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.pbxproj index d3dca02ea..e09574e9b 100644 --- a/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.pbxproj +++ b/Examples/VoiceMemos/VoiceMemos.xcodeproj/project.pbxproj @@ -18,8 +18,8 @@ DC5BDF3A245893C1009C65A3 /* LiveAudioRecorderClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDF39245893C1009C65A3 /* LiveAudioRecorderClient.swift */; }; DC5BDF3D245893E6009C65A3 /* AudioPlayerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDF3C245893E6009C65A3 /* AudioPlayerClient.swift */; }; DC5BDF3F24589406009C65A3 /* LiveAudioPlayerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5BDF3E24589406009C65A3 /* LiveAudioPlayerClient.swift */; }; - DC94F0102458E09900082DE9 /* FailingAudioPlayerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC94F00F2458E09900082DE9 /* FailingAudioPlayerClient.swift */; }; - DC94F0122458E0AA00082DE9 /* FailingAudioRecorderClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC94F0112458E0AA00082DE9 /* FailingAudioRecorderClient.swift */; }; + DC94F0102458E09900082DE9 /* UnimplementedAudioPlayerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC94F00F2458E09900082DE9 /* UnimplementedAudioPlayerClient.swift */; }; + DC94F0122458E0AA00082DE9 /* UnimplementedAudioRecorderClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC94F0112458E0AA00082DE9 /* UnimplementedAudioRecorderClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -57,7 +57,7 @@ /* Begin PBXFileReference section */ 23EDBE6B271CD8DD004F7430 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; - CA93D05B249BF42500A6F65D /* VoiceMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMemo.swift; sourceTree = ""; }; + CA93D05B249BF42500A6F65D /* VoiceMemo.swift */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = VoiceMemo.swift; sourceTree = ""; }; CA93D05D249BF46E00A6F65D /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; DC5BDCAA24589177009C65A3 /* VoiceMemos.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceMemos.app; sourceTree = BUILT_PRODUCTS_DIR; }; DC5BDCAF24589177009C65A3 /* VoiceMemosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMemosApp.swift; sourceTree = ""; }; @@ -71,8 +71,8 @@ DC5BDF39245893C1009C65A3 /* LiveAudioRecorderClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveAudioRecorderClient.swift; sourceTree = ""; }; DC5BDF3C245893E6009C65A3 /* AudioPlayerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerClient.swift; sourceTree = ""; }; DC5BDF3E24589406009C65A3 /* LiveAudioPlayerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveAudioPlayerClient.swift; sourceTree = ""; }; - DC94F00F2458E09900082DE9 /* FailingAudioPlayerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailingAudioPlayerClient.swift; sourceTree = ""; }; - DC94F0112458E0AA00082DE9 /* FailingAudioRecorderClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailingAudioRecorderClient.swift; sourceTree = ""; }; + DC94F00F2458E09900082DE9 /* UnimplementedAudioPlayerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnimplementedAudioPlayerClient.swift; sourceTree = ""; }; + DC94F0112458E0AA00082DE9 /* UnimplementedAudioRecorderClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnimplementedAudioRecorderClient.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -103,6 +103,7 @@ DC5BDCAC24589177009C65A3 /* VoiceMemos */, DC5BDCC324589179009C65A3 /* VoiceMemosTests */, ); + indentWidth = 2; sourceTree = ""; }; DC5BDCAB24589177009C65A3 /* Products */ = { @@ -142,7 +143,7 @@ children = ( DC5BDF352458939C009C65A3 /* AudioRecorderClient.swift */, DC5BDF39245893C1009C65A3 /* LiveAudioRecorderClient.swift */, - DC94F0112458E0AA00082DE9 /* FailingAudioRecorderClient.swift */, + DC94F0112458E0AA00082DE9 /* UnimplementedAudioRecorderClient.swift */, ); path = AudioRecorderClient; sourceTree = ""; @@ -152,7 +153,7 @@ children = ( DC5BDF3C245893E6009C65A3 /* AudioPlayerClient.swift */, DC5BDF3E24589406009C65A3 /* LiveAudioPlayerClient.swift */, - DC94F00F2458E09900082DE9 /* FailingAudioPlayerClient.swift */, + DC94F00F2458E09900082DE9 /* UnimplementedAudioPlayerClient.swift */, ); path = AudioPlayerClient; sourceTree = ""; @@ -265,12 +266,12 @@ buildActionMask = 2147483647; files = ( DC5BDF362458939C009C65A3 /* AudioRecorderClient.swift in Sources */, - DC94F0122458E0AA00082DE9 /* FailingAudioRecorderClient.swift in Sources */, + DC94F0122458E0AA00082DE9 /* UnimplementedAudioRecorderClient.swift in Sources */, DC5BDF3D245893E6009C65A3 /* AudioPlayerClient.swift in Sources */, CA93D05E249BF46E00A6F65D /* Helpers.swift in Sources */, DC5BDF3A245893C1009C65A3 /* LiveAudioRecorderClient.swift in Sources */, DC5BDF3F24589406009C65A3 /* LiveAudioPlayerClient.swift in Sources */, - DC94F0102458E09900082DE9 /* FailingAudioPlayerClient.swift in Sources */, + DC94F0102458E09900082DE9 /* UnimplementedAudioPlayerClient.swift in Sources */, CA93D05C249BF42500A6F65D /* VoiceMemo.swift in Sources */, DC5BDCB024589177009C65A3 /* VoiceMemosApp.swift in Sources */, DC5BDCB224589177009C65A3 /* VoiceMemos.swift in Sources */, @@ -424,6 +425,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.VoiceMemos; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -442,6 +444,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks"; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.VoiceMemos; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; diff --git a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift index a0d4a37ba..3cb5d2a61 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift @@ -2,15 +2,5 @@ import ComposableArchitecture import Foundation struct AudioPlayerClient { - var play: (URL) -> Effect - var stop: () -> Effect - - enum Action: Equatable { - case didFinishPlaying(successfully: Bool) - } - - enum Failure: Equatable, Error { - case couldntCreateAudioPlayer - case decodeErrorDidOccur - } + var play: @Sendable (URL) async throws -> Bool } diff --git a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift index 55cb78301..d54f132c2 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift @@ -1,52 +1,41 @@ -import AVFoundation +@preconcurrency import AVFoundation import ComposableArchitecture extension AudioPlayerClient { - static var live: Self { - var delegate: AudioPlayerClientDelegate? - return Self( - play: { url in - .future { callback in - delegate?.player.stop() - delegate = nil - do { - delegate = try AudioPlayerClientDelegate( - url: url, - didFinishPlaying: { flag in - callback(.success(.didFinishPlaying(successfully: flag))) - delegate = nil - }, - decodeErrorDidOccur: { _ in - callback(.failure(.decodeErrorDidOccur)) - delegate = nil - } - ) - - delegate?.player.play() - } catch { - callback(.failure(.couldntCreateAudioPlayer)) + static let live = Self { url in + let stream = AsyncThrowingStream { continuation in + do { + let delegate = try Delegate( + url: url, + didFinishPlaying: { successful in + continuation.yield(successful) + continuation.finish() + }, + decodeErrorDidOccur: { error in + continuation.finish(throwing: error) } + ) + delegate.player.play() + continuation.onTermination = { _ in + delegate.player.stop() } - }, - stop: { - .fireAndForget { - delegate?.player.stop() - delegate = nil - } + } catch { + continuation.finish(throwing: error) } - ) + } + return try await stream.first(where: { _ in true }) ?? false } } -private class AudioPlayerClientDelegate: NSObject, AVAudioPlayerDelegate { - let didFinishPlaying: (Bool) -> Void - let decodeErrorDidOccur: (Error?) -> Void +private final class Delegate: NSObject, AVAudioPlayerDelegate, Sendable { + let didFinishPlaying: @Sendable (Bool) -> Void + let decodeErrorDidOccur: @Sendable (Error?) -> Void let player: AVAudioPlayer init( url: URL, - didFinishPlaying: @escaping (Bool) -> Void, - decodeErrorDidOccur: @escaping (Error?) -> Void + didFinishPlaying: @escaping @Sendable (Bool) -> Void, + decodeErrorDidOccur: @escaping @Sendable (Error?) -> Void ) throws { self.didFinishPlaying = didFinishPlaying self.decodeErrorDidOccur = decodeErrorDidOccur diff --git a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/FailingAudioPlayerClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/UnimplementedAudioPlayerClient.swift similarity index 56% rename from Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/FailingAudioPlayerClient.swift rename to Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/UnimplementedAudioPlayerClient.swift index 3e82dd47f..50486b9cf 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/FailingAudioPlayerClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/UnimplementedAudioPlayerClient.swift @@ -1,11 +1,11 @@ import ComposableArchitecture import Foundation +import XCTestDynamicOverlay #if DEBUG extension AudioPlayerClient { static let unimplemented = Self( - play: { _ in .unimplemented("\(Self.self).play") }, - stop: { .unimplemented("\(Self.self).stop") } + play: XCTUnimplemented("\(Self.self).play") ) } #endif diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift index 5fde36cef..b42116c22 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift @@ -2,19 +2,8 @@ import ComposableArchitecture import Foundation struct AudioRecorderClient { - var currentTime: () -> Effect - var requestRecordPermission: () -> Effect - var startRecording: (URL) -> Effect - var stopRecording: () -> Effect - - enum Action: Equatable { - case didFinishRecording(successfully: Bool) - } - - enum Failure: Equatable, Error { - case couldntCreateAudioRecorder - case couldntActivateAudioSession - case couldntSetAudioSessionCategory - case encodeErrorDidOccur - } + var currentTime: @Sendable () async -> TimeInterval? + var requestRecordPermission: @Sendable () async -> Bool + var startRecording: @Sendable (URL) async throws -> Bool + var stopRecording: @Sendable () async -> Void } diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/FailingAudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/FailingAudioRecorderClient.swift deleted file mode 100644 index 2bfc4c796..000000000 --- a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/FailingAudioRecorderClient.swift +++ /dev/null @@ -1,13 +0,0 @@ -import ComposableArchitecture -import Foundation - -#if DEBUG - extension AudioRecorderClient { - static let unimplemented = Self( - currentTime: { .unimplemented("\(Self.self).currentTime") }, - requestRecordPermission: { .unimplemented("\(Self.self).requestRecordPermission") }, - startRecording: { _ in .unimplemented("\(Self.self).startRecording") }, - stopRecording: { .unimplemented("\(Self.self).stopRecording") } - ) - } -#endif diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift index 8011d2108..42aca54a7 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift @@ -1,100 +1,99 @@ import AVFoundation import ComposableArchitecture -import ReactiveSwift +import Foundation extension AudioRecorderClient { static var live: Self { - var delegate: AudioRecorderClientDelegate? - + let audioRecorder = AudioRecorder() return Self( - currentTime: { - .result { - guard - let recorder = delegate?.recorder, - recorder.isRecording - else { return .success(nil) } - return .success(recorder.currentTime) - } - }, - requestRecordPermission: { - .future { callback in - AVAudioSession.sharedInstance().requestRecordPermission { granted in - callback(.success(granted)) - } - } - }, - startRecording: { url in - .future { callback in - delegate?.recorder.stop() - delegate = nil - do { - delegate = try AudioRecorderClientDelegate( - url: url, - didFinishRecording: { flag in - callback(.success(.didFinishRecording(successfully: flag))) - delegate = nil - try? AVAudioSession.sharedInstance().setActive(false) - }, - encodeErrorDidOccur: { _ in - callback(.failure(.encodeErrorDidOccur)) - delegate = nil - try? AVAudioSession.sharedInstance().setActive(false) - } - ) - } catch { - callback(.failure(.couldntCreateAudioRecorder)) - return - } + currentTime: { await audioRecorder.currentTime }, + requestRecordPermission: { await AudioRecorder.requestPermission() }, + startRecording: { url in try await audioRecorder.start(url: url) }, + stopRecording: { await audioRecorder.stop() } + ) + } +} - do { - try AVAudioSession.sharedInstance().setCategory(.record, mode: .default) - } catch { - callback(.failure(.couldntActivateAudioSession)) - return - } +private actor AudioRecorder { + var delegate: Delegate? + var recorder: AVAudioRecorder? + + var currentTime: TimeInterval? { + guard + let recorder = self.recorder, + recorder.isRecording + else { return nil } + return recorder.currentTime + } - do { - try AVAudioSession.sharedInstance().setActive(true) - } catch { - callback(.failure(.couldntSetAudioSessionCategory)) - return + static func requestPermission() async -> Bool { + await withUnsafeContinuation { continuation in + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + } + + func stop() { + self.recorder?.stop() + try? AVAudioSession.sharedInstance().setActive(false) + } + + func start(url: URL) async throws -> Bool { + self.stop() + + let stream = AsyncThrowingStream { continuation in + do { + self.delegate = Delegate( + didFinishRecording: { flag in + continuation.yield(flag) + continuation.finish() + try? AVAudioSession.sharedInstance().setActive(false) + }, + encodeErrorDidOccur: { error in + continuation.finish(throwing: error) + try? AVAudioSession.sharedInstance().setActive(false) } + ) + let recorder = try AVAudioRecorder( + url: url, + settings: [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, + ]) + self.recorder = recorder + recorder.delegate = self.delegate - delegate?.recorder.record() - } - }, - stopRecording: { - .fireAndForget { - delegate?.recorder.stop() - try? AVAudioSession.sharedInstance().setActive(false) + continuation.onTermination = { [recorder = UncheckedSendable(recorder)] _ in + recorder.wrappedValue.stop() } + + try AVAudioSession.sharedInstance().setCategory(.record, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + self.recorder?.record() + } catch { + continuation.finish(throwing: error) } - ) + } + + guard let action = try await stream.first(where: { @Sendable _ in true }) + else { throw CancellationError() } + return action } } -private class AudioRecorderClientDelegate: NSObject, AVAudioRecorderDelegate { - let recorder: AVAudioRecorder - let didFinishRecording: (Bool) -> Void - let encodeErrorDidOccur: (Error?) -> Void +private final class Delegate: NSObject, AVAudioRecorderDelegate, Sendable { + let didFinishRecording: @Sendable (Bool) -> Void + let encodeErrorDidOccur: @Sendable (Error?) -> Void init( - url: URL, - didFinishRecording: @escaping (Bool) -> Void, - encodeErrorDidOccur: @escaping (Error?) -> Void - ) throws { - self.recorder = try AVAudioRecorder( - url: url, - settings: [ - AVFormatIDKey: Int(kAudioFormatMPEG4AAC), - AVSampleRateKey: 44100, - AVNumberOfChannelsKey: 1, - AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, - ]) + didFinishRecording: @escaping @Sendable (Bool) -> Void, + encodeErrorDidOccur: @escaping @Sendable (Error?) -> Void + ) { self.didFinishRecording = didFinishRecording self.encodeErrorDidOccur = encodeErrorDidOccur - super.init() - self.recorder.delegate = self } func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/UnimplementedAudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/UnimplementedAudioRecorderClient.swift new file mode 100644 index 000000000..c727e3b52 --- /dev/null +++ b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/UnimplementedAudioRecorderClient.swift @@ -0,0 +1,16 @@ +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay + +#if DEBUG + extension AudioRecorderClient { + static let unimplemented = Self( + currentTime: XCTUnimplemented("\(Self.self).currentTime", placeholder: nil), + requestRecordPermission: XCTUnimplemented( + "\(Self.self).requestRecordPermission", placeholder: false + ), + startRecording: XCTUnimplemented("\(Self.self).startRecording", placeholder: false), + stopRecording: XCTUnimplemented("\(Self.self).stopRecording") + ) + } +#endif diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift index 3b67b5c20..92440c73e 100644 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift +++ b/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift @@ -29,9 +29,9 @@ struct VoiceMemo: Equatable, Identifiable { } enum VoiceMemoAction: Equatable { - case audioPlayerClient(Result) - case playButtonTapped + case audioPlayerClient(TaskResult) case delete + case playButtonTapped case timerUpdated(TimeInterval) case titleTextFieldChanged(String) } @@ -42,43 +42,41 @@ struct VoiceMemoEnvironment { } let voiceMemoReducer = Reducer< - VoiceMemo, VoiceMemoAction, VoiceMemoEnvironment + VoiceMemo, + VoiceMemoAction, + VoiceMemoEnvironment > { memo, action, environment in - enum TimerId {} + enum PlayID {} switch action { - case .audioPlayerClient(.success(.didFinishPlaying)), .audioPlayerClient(.failure): + case .audioPlayerClient: memo.mode = .notPlaying - return .cancel(id: TimerId.self) + return .cancel(id: PlayID.self) case .delete: - return .merge( - environment.audioPlayerClient.stop().fireAndForget(), - Effect.cancel(id: TimerId.self) - ) + return .cancel(id: PlayID.self) case .playButtonTapped: switch memo.mode { case .notPlaying: memo.mode = .playing(progress: 0) - let start = environment.mainRunLoop.currentDate - return .merge( - Effect.timer(id: TimerId.self, every: .milliseconds(500), on: environment.mainRunLoop) - .map { .timerUpdated($0.timeIntervalSince1970 - start.timeIntervalSince1970) }, + return .run { [url = memo.url] send in + let start = environment.mainRunLoop.currentDate - environment.audioPlayerClient - .play(memo.url) - .catchToEffect(VoiceMemoAction.audioPlayerClient) - ) + async let playAudio: Void = send( + .audioPlayerClient(TaskResult { try await environment.audioPlayerClient.play(url) }) + ) + + for try await tick in environment.mainRunLoop.timer(interval: .milliseconds(500)) { + await send(.timerUpdated(tick.timeIntervalSince(start))) + } + } + .cancellable(id: PlayID.self, cancelInFlight: true) case .playing: memo.mode = .notPlaying - - return .concatenate( - .cancel(id: TimerId.self), - environment.audioPlayerClient.stop().fireAndForget() - ) + return .cancel(id: PlayID.self) } case let .timerUpdated(time): diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift index 9d23be863..7b5efdedf 100644 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift +++ b/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift @@ -1,5 +1,6 @@ import AVFoundation import ComposableArchitecture +import Foundation import ReactiveSwift import SwiftUI @@ -30,7 +31,7 @@ struct VoiceMemosState: Equatable { enum VoiceMemosAction: Equatable { case alertDismissed - case audioRecorder(Result) + case audioRecorderDidFinish(TaskResult) case currentRecordingTimerUpdated case finalRecordingTime(TimeInterval) case openSettingsButtonTapped @@ -43,9 +44,9 @@ struct VoiceMemosEnvironment { var audioPlayer: AudioPlayerClient var audioRecorder: AudioRecorderClient var mainRunLoop: DateScheduler - var openSettings: Effect - var temporaryDirectory: () -> URL - var uuid: () -> UUID + var openSettings: @Sendable () async -> Void + var temporaryDirectory: @Sendable () -> URL + var uuid: @Sendable () -> UUID } let voiceMemosReducer = Reducer.combine( @@ -56,8 +57,8 @@ let voiceMemosReducer = Reducer Effect { let url = environment.temporaryDirectory() @@ -68,12 +69,17 @@ let voiceMemosReducer = Reducer.pipe() + func testRecordMemoHappyPath() async { + // NB: Combine's concatenation behavior is different in 13.3 + guard #available(iOS 13.4, *) else { return } + + let didFinish = AsyncThrowingStream.streamWithContinuation() var environment = VoiceMemosEnvironment.unimplemented - environment.audioRecorder.currentTime = { Effect(value: 2.5) } - environment.audioRecorder.requestRecordPermission = { Effect(value: true) } + environment.audioRecorder.currentTime = { 2.5 } + environment.audioRecorder.requestRecordPermission = { true } environment.audioRecorder.startRecording = { _ in - audioRecorderSubject.output.producer + try await didFinish.stream.first { _ in true }! } environment.audioRecorder.stopRecording = { - .fireAndForget { - audioRecorderSubject.input.send(value: .didFinishRecording(successfully: true)) - audioRecorderSubject.input.sendCompleted() - } + didFinish.continuation.yield(true) + didFinish.continuation.finish() } environment.mainRunLoop = mainRunLoop environment.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } @@ -36,32 +36,32 @@ class VoiceMemosTests: XCTestCase { environment: environment ) - store.send(.recordButtonTapped) - mainRunLoop.advance() - store.receive(.recordPermissionResponse(true)) { + let recordButtonTappedTask = await store.send(.recordButtonTapped) + await self.mainRunLoop.advance() + await store.receive(.recordPermissionResponse(true)) { $0.audioRecorderPermission = .allowed $0.currentRecording = VoiceMemosState.CurrentRecording( date: Date(timeIntervalSinceReferenceDate: 0), mode: .recording, - url: URL(string: "file:///tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a")! + url: URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") ) } - mainRunLoop.advance(by: 1) - store.receive(.currentRecordingTimerUpdated) { - $0.currentRecording!.duration = 1 + await self.mainRunLoop.advance(by: 1) + await store.receive(.currentRecordingTimerUpdated) { + $0.currentRecording?.duration = 1 } - mainRunLoop.advance(by: 1) - store.receive(.currentRecordingTimerUpdated) { - $0.currentRecording!.duration = 2 + await self.mainRunLoop.advance(by: 1) + await store.receive(.currentRecordingTimerUpdated) { + $0.currentRecording?.duration = 2 } - mainRunLoop.advance(by: 0.5) - store.send(.recordButtonTapped) { - $0.currentRecording!.mode = .encoding + await self.mainRunLoop.advance(by: 0.5) + await store.send(.recordButtonTapped) { + $0.currentRecording?.mode = .encoding } - store.receive(.finalRecordingTime(2.5)) { - $0.currentRecording!.duration = 2.5 + await store.receive(.finalRecordingTime(2.5)) { + $0.currentRecording?.duration = 2.5 } - store.receive(.audioRecorder(.success(.didFinishRecording(successfully: true)))) { + await store.receive(.audioRecorderDidFinish(.success(true))) { $0.currentRecording = nil $0.voiceMemos = [ VoiceMemo( @@ -69,19 +69,20 @@ class VoiceMemosTests: XCTestCase { duration: 2.5, mode: .notPlaying, title: "", - url: URL(string: "file:///tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a")! + url: URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") ) ] } + await recordButtonTappedTask.finish() } - func testPermissionDenied() { - var didOpenSettings = false + func testPermissionDenied() async { + let didOpenSettings = ActorIsolated(false) var environment = VoiceMemosEnvironment.unimplemented - environment.audioRecorder.requestRecordPermission = { Effect(value: false) } - environment.mainRunLoop = ImmediateScheduler() - environment.openSettings = .fireAndForget { didOpenSettings = true } + environment.audioRecorder.requestRecordPermission = { false } + environment.mainRunLoop = mainRunLoop + environment.openSettings = { await didOpenSettings.setValue(true) } let store = TestStore( initialState: VoiceMemosState(), @@ -89,30 +90,29 @@ class VoiceMemosTests: XCTestCase { environment: environment ) - store.send(.recordButtonTapped) - store.receive(.recordPermissionResponse(false)) { + await store.send(.recordButtonTapped) + await store.receive(.recordPermissionResponse(false)) { $0.alert = AlertState(title: TextState("Permission is required to record voice memos.")) $0.audioRecorderPermission = .denied } - store.send(.alertDismissed) { + await store.send(.alertDismissed) { $0.alert = nil } - store.send(.openSettingsButtonTapped) - XCTAssert(didOpenSettings) + await store.send(.openSettingsButtonTapped).finish() + await didOpenSettings.withValue { XCTAssert($0) } } - func testRecordMemoFailure() { - let audioRecorderSubject = Signal< - AudioRecorderClient.Action, AudioRecorderClient.Failure - >.pipe() + func testRecordMemoFailure() async { + struct SomeError: Error, Equatable {} + let didFinish = AsyncThrowingStream.streamWithContinuation() var environment = VoiceMemosEnvironment.unimplemented - environment.audioRecorder.currentTime = { Effect(value: 2.5) } - environment.audioRecorder.requestRecordPermission = { Effect(value: true) } + environment.audioRecorder.currentTime = { 2.5 } + environment.audioRecorder.requestRecordPermission = { true } environment.audioRecorder.startRecording = { _ in - audioRecorderSubject.output.producer + try await didFinish.stream.first { _ in true }! } - environment.mainRunLoop = ImmediateScheduler() + environment.mainRunLoop = TestScheduler(startDate: Date(timeIntervalSince1970: 0)) environment.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } environment.uuid = { UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")! } @@ -122,40 +122,40 @@ class VoiceMemosTests: XCTestCase { environment: environment ) - store.send(.recordButtonTapped) - store.receive(.recordPermissionResponse(true)) { + await store.send(.recordButtonTapped) + await self.mainRunLoop.advance(by: 0.5) + await store.receive(.recordPermissionResponse(true)) { $0.audioRecorderPermission = .allowed $0.currentRecording = VoiceMemosState.CurrentRecording( date: Date(timeIntervalSince1970: 0), mode: .recording, - url: URL(string: "file:///tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a")! + url: URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") ) } - audioRecorderSubject.input.send(error: .couldntActivateAudioSession) - store.receive(.currentRecordingTimerUpdated) { - $0.currentRecording?.duration = 1.0 - } - store.receive(.audioRecorder(.failure(.couldntActivateAudioSession))) { + + didFinish.continuation.finish(throwing: SomeError()) + await self.mainRunLoop.advance(by: 0.5) + await store.receive(.audioRecorderDidFinish(.failure(SomeError()))) { $0.alert = AlertState(title: TextState("Voice memo recording failed.")) $0.currentRecording = nil } } - func testPlayMemoHappyPath() { + func testPlayMemoHappyPath() async { var environment = VoiceMemosEnvironment.unimplemented environment.audioPlayer.play = { _ in - Effect(value: .didFinishPlaying(successfully: true)) - .delay(1.1, on: self.mainRunLoop) + try await self.mainRunLoop.sleep(for: .milliseconds(1250)) + return true } environment.mainRunLoop = mainRunLoop - let url = URL(string: "https://www.pointfree.co/functions")! + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") let store = TestStore( initialState: VoiceMemosState( voiceMemos: [ VoiceMemo( - date: Date(timeIntervalSinceNow: 0), - duration: 1, + date: Date(), + duration: 1.25, mode: .notPlaying, title: "", url: url @@ -166,39 +166,37 @@ class VoiceMemosTests: XCTestCase { environment: environment ) - store.send(.voiceMemo(id: url, action: .playButtonTapped)) { - $0.voiceMemos[id: url]?.mode = VoiceMemo.Mode.playing(progress: 0) + let task = await store.send(.voiceMemo(id: url, action: .playButtonTapped)) { + $0.voiceMemos[id: url]?.mode = .playing(progress: 0) } - mainRunLoop.advance(by: 0.5) - store.receive(VoiceMemosAction.voiceMemo(id: url, action: VoiceMemoAction.timerUpdated(0.5))) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0.5) + await self.mainRunLoop.advance(by: 0.5) + await store.receive(.voiceMemo(id: url, action: .timerUpdated(0.5))) { + $0.voiceMemos[id: url]?.mode = .playing(progress: 0.4) } - mainRunLoop.advance(by: 0.5) - store.receive(VoiceMemosAction.voiceMemo(id: url, action: VoiceMemoAction.timerUpdated(1))) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 1) + await self.mainRunLoop.advance(by: 0.5) + await store.receive(.voiceMemo(id: url, action: .timerUpdated(1))) { + $0.voiceMemos[id: url]?.mode = .playing(progress: 0.8) } - mainRunLoop.advance(by: 0.1) - store.receive( - .voiceMemo( - id: url, - action: .audioPlayerClient(.success(.didFinishPlaying(successfully: true))) - ) - ) { + await self.mainRunLoop.advance(by: 0.25) + await store.receive(.voiceMemo(id: url, action: .audioPlayerClient(.success(true)))) { $0.voiceMemos[id: url]?.mode = .notPlaying } + await task.cancel() } - func testPlayMemoFailure() { + func testPlayMemoFailure() async { + struct SomeError: Error, Equatable {} + var environment = VoiceMemosEnvironment.unimplemented - environment.audioPlayer.play = { _ in Effect(error: .decodeErrorDidOccur) } - environment.mainRunLoop = ImmediateScheduler() + environment.audioPlayer.play = { _ in throw SomeError() } + environment.mainRunLoop = mainRunLoop - let url = URL(string: "https://www.pointfree.co/functions")! + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") let store = TestStore( initialState: VoiceMemosState( voiceMemos: [ VoiceMemo( - date: Date(timeIntervalSinceNow: 0), + date: Date(), duration: 30, mode: .notPlaying, title: "", @@ -210,27 +208,23 @@ class VoiceMemosTests: XCTestCase { environment: environment ) - store.send(.voiceMemo(id: url, action: .playButtonTapped)) { + let task = await store.send(.voiceMemo(id: url, action: .playButtonTapped)) { $0.voiceMemos[id: url]?.mode = .playing(progress: 0) } - store.receive(.voiceMemo(id: url, action: .timerUpdated(0))) - store.receive(.voiceMemo(id: url, action: .audioPlayerClient(.failure(.decodeErrorDidOccur)))) { + await store.receive(.voiceMemo(id: url, action: .audioPlayerClient(.failure(SomeError())))) { $0.alert = AlertState(title: TextState("Voice memo playback failed.")) $0.voiceMemos[id: url]?.mode = .notPlaying } + await task.cancel() } - func testStopMemo() { - var didStopAudioPlayerClient = false - var environment = VoiceMemosEnvironment.unimplemented - environment.audioPlayer.stop = { .fireAndForget { didStopAudioPlayerClient = true } } - - let url = URL(string: "https://www.pointfree.co/functions")! + func testStopMemo() async { + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") let store = TestStore( initialState: VoiceMemosState( voiceMemos: [ VoiceMemo( - date: Date(timeIntervalSinceNow: 0), + date: Date(), duration: 30, mode: .playing(progress: 0.3), title: "", @@ -239,26 +233,21 @@ class VoiceMemosTests: XCTestCase { ] ), reducer: voiceMemosReducer, - environment: environment + environment: .unimplemented ) - store.send(.voiceMemo(id: url, action: .playButtonTapped)) { + await store.send(.voiceMemo(id: url, action: .playButtonTapped)) { $0.voiceMemos[id: url]?.mode = .notPlaying } - XCTAssert(didStopAudioPlayerClient) } - func testDeleteMemo() { - var didStopAudioPlayerClient = false - var environment = VoiceMemosEnvironment.unimplemented - environment.audioPlayer.stop = { .fireAndForget { didStopAudioPlayerClient = true } } - - let url = URL(string: "https://www.pointfree.co/functions")! + func testDeleteMemo() async { + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") let store = TestStore( initialState: VoiceMemosState( voiceMemos: [ VoiceMemo( - date: Date(timeIntervalSinceNow: 0), + date: Date(), duration: 30, mode: .playing(progress: 0.3), title: "", @@ -267,27 +256,25 @@ class VoiceMemosTests: XCTestCase { ] ), reducer: voiceMemosReducer, - environment: environment + environment: .unimplemented ) - store.send(.voiceMemo(id: url, action: .delete)) { + await store.send(.voiceMemo(id: url, action: .delete)) { $0.voiceMemos = [] - XCTAssertNoDifference(didStopAudioPlayerClient, true) } } - func testDeleteMemoWhilePlaying() { - let url = URL(string: "https://www.pointfree.co/functions")! + func testDeleteMemoWhilePlaying() async { + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") var environment = VoiceMemosEnvironment.unimplemented - environment.audioPlayer.play = { _ in .none } - environment.audioPlayer.stop = { .none } - environment.mainRunLoop = ImmediateScheduler() + environment.audioPlayer.play = { _ in try await Task.never() } + environment.mainRunLoop = mainRunLoop let store = TestStore( initialState: VoiceMemosState( voiceMemos: [ VoiceMemo( - date: Date(timeIntervalSinceNow: 0), + date: Date(), duration: 10, mode: .notPlaying, title: "", @@ -299,11 +286,10 @@ class VoiceMemosTests: XCTestCase { environment: environment ) - store.send(.voiceMemo(id: url, action: .playButtonTapped)) { + await store.send(.voiceMemo(id: url, action: .playButtonTapped)) { $0.voiceMemos[id: url]?.mode = .playing(progress: 0) } - store.receive(.voiceMemo(id: url, action: .timerUpdated(0))) - store.send(.voiceMemo(id: url, action: .delete)) { + await store.send(.voiceMemo(id: url, action: .delete)) { $0.voiceMemos = [] } } @@ -314,9 +300,10 @@ extension VoiceMemosEnvironment { audioPlayer: .unimplemented, audioRecorder: .unimplemented, mainRunLoop: UnimplementedScheduler(), - openSettings: .unimplemented("\(Self.self).openSettings"), + openSettings: XCTUnimplemented("\(Self.self).openSettings"), temporaryDirectory: XCTUnimplemented( - "\(Self.self).temporaryDirectory", placeholder: URL(fileURLWithPath: NSTemporaryDirectory()) + "\(Self.self).temporaryDirectory", + placeholder: URL(fileURLWithPath: NSTemporaryDirectory()) ), uuid: XCTUnimplemented("\(Self.self).uuid", placeholder: UUID()) ) diff --git a/Package.swift b/Package.swift index 7b10580a7..ad17e8b62 100644 --- a/Package.swift +++ b/Package.swift @@ -18,11 +18,11 @@ let package = Package( ], dependencies: [ .package(name: "Benchmark", url: "https://github.com/google/swift-benchmark", from: "0.1.0"), - .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift", from: "6.7.0"), + .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift", branch: "7.1.0"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.8.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.3.0"), .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "0.3.2"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.3.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.3.2"), ], targets: [ .target( diff --git a/README.md b/README.md index ec55102fe..f42600af7 100644 --- a/README.md +++ b/README.md @@ -118,18 +118,15 @@ enum AppAction: Equatable { case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped - case numberFactResponse(Result) + case numberFactResponse(TaskResult) } - -struct ApiError: Error, Equatable {} ``` -Next we model the environment of dependencies this feature needs to do its job. In particular, to fetch a number fact we need to construct an `Effect` value that encapsulates the network request. So that dependency is a function from `Int` to `Effect`, where `String` represents the response from the request. Further, the effect will typically do its work on a background thread (as is the case with `URLSession`), and so we need a way to receive the effect's values on the main queue. We do this via a main queue scheduler, which is a dependency that is important to control so that we can write tests. We must use an `DateScheduler` so that we can use a live `QueueScheduler` in production and a test scheduler in tests. +Next we model the environment of dependencies this feature needs to do its job. In particular, to fetch a number fact we can model an async throwing function from `Int` to `String`: ```swift struct AppEnvironment { - var mainQueue: DateScheduler - var numberFact: (Int) -> Effect + var numberFact: (Int) async throws -> String } ``` @@ -151,9 +148,9 @@ let appReducer = Reducer { state, action, e return .none case .numberFactButtonTapped: - return environment.numberFact(state.count) - .observe(on: environment.mainQueue) - .catchToEffect(AppAction.numberFactResponse) + return .task { + await .numberFactResponse(TaskResult { try await environment.numberFact(state.count) }) + } case let .numberFactResponse(.success(fact)): state.numberFactAlert = fact @@ -263,38 +260,45 @@ It is also straightforward to have a UIKit controller driven off of this store. ``` -Once we are ready to display this view, for example in the scene delegate, we can construct a store. This is the moment where we need to supply the dependencies, and for now we can just use an effect that immediately returns a mocked string: +Once we are ready to display this view, for example in the app's entry point, we can construct a store. This is the moment where we need to supply the dependencies, including the `numberFact` endpoint that actually reaches out into the real world to fetch the fact: ```swift -let appView = AppView( +@main +struct CaseStudiesApp: App { +var body: some Scene { + AppView( store: Store( initialState: AppState(), reducer: appReducer, environment: AppEnvironment( - mainQueue: .main, - numberFact: { number in Effect(value: "\(number) is a good number Brent") } + numberFact: { number in + let (data, _) = try await URLSession.shared + .data(from: .init(string: "http://numbersapi.com/\(number)")!) + return String(decoding: data, using: UTF8.self) + } ) ) ) +} ``` And that is enough to get something on the screen to play around with. It's definitely a few more steps than if you were to do this in a vanilla SwiftUI way, but there are a few benefits. It gives us a consistent manner to apply state mutations, instead of scattering logic in some observable objects and in various action closures of UI components. It also gives us a concise way of expressing side effects. And we can immediately test this logic, including the effects, without doing much additional work. ### Testing -To test, you first create a `TestStore` with the same information that you would to create a regular `Store`, except this time we can supply test-friendly dependencies. In particular, we use a test scheduler instead of the live `QueueScheduler.main` scheduler because that allows us to control when work is executed, and we don't have to artificially wait for queues to catch up. +To test, you first create a `TestStore` with the same information that you would to create a regular `Store`, except this time we can supply test-friendly dependencies. In particular, we can now use a `numberFact` implementation that immediately returns a value we control rather than reaching out into the real world: ```swift -let mainQueue = TestScheduler() - +@MainActor +func testFeature() async { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: AppEnvironment( - mainQueue: mainQueue, - numberFact: { number in Effect(value: "\(number) is a good number Brent") } + numberFact: { "\($0) is a good number Brent" } ) ) +} ``` Once the test store is created we can use it to make an assertion of an entire user flow of steps. Each step of the way we need to prove that state changed how we expect. Further, if a step causes an effect to be executed, which feeds data back into the store, we must assert that those actions were received properly. @@ -303,24 +307,24 @@ The test below has the user increment and decrement the count, then they ask for ```swift // Test that tapping on the increment/decrement buttons changes the count -store.send(.incrementButtonTapped) { +await store.send(.incrementButtonTapped) { $0.count = 1 } -store.send(.decrementButtonTapped) { +await store.send(.decrementButtonTapped) { $0.count = 0 } // Test that tapping the fact button causes us to receive a response from the effect. Note -// that we have to advance the scheduler because we used `.receive(on:)` in the reducer. -store.send(.numberFactButtonTapped) +// that we have to await the receive because the effect is asynchronous and so takes a small +// amount of time to emit. +await store.send(.numberFactButtonTapped) -mainQueue.advance() -store.receive(.numberFactResponse(.success("0 is a good number Brent"))) { +await store.receive(.numberFactResponse(.success("0 is a good number Brent"))) { $0.numberFactAlert = "0 is a good number Brent" } // And finally dismiss the alert -store.send(.factAlertDismissed) { +await store.send(.factAlertDismissed) { $0.numberFactAlert = nil } ``` @@ -368,38 +372,6 @@ The Composable Architecture comes with a number of tools to aid in debugging. And then there are certain things that TCA prioritizes highly that are not points of focus for Redux, Elm, or most other libraries. For example, composition is very important aspect of TCA, which is the process of breaking down large features into smaller units that can be glued together. This is accomplished with the `pullback` and `combine` operators on reducers, and it aids in handling complex features as well as modularization for a better-isolated code base and improved compile times. -* Why isn't `Store` thread-safe?
Why isn't `send` queued?
Why isn't `send` run on the main thread? -
- Expand to see answer - - All interactions with an instance of `Store` (including all of its scopes and derived `ViewStore`s) must be done on the same thread. If the store is powering a SwiftUI or UIKit view then, all interactions must be done on the _main_ thread. - - When an action is sent to the `Store`, a reducer is run on the current state, and this process cannot be done from multiple threads. A possible work around is to use a queue in `send`s implementation, but this introduces a few new complications: - - 1. If done simply with `DispatchQueue.main.async` you will incur a thread hop even when you are already on the main thread. This can lead to unexpected behavior in UIKit and SwiftUI, where sometimes you are required to do work synchronously, such as in animation blocks. - - 2. It is possible to create a scheduler that performs its work immediately when on the main thread and otherwise uses `DispatchQueue.main.async` (_e.g._ see [CombineScheduler](https://github.com/pointfreeco/combine-schedulers)'s [`UIScheduler`](https://github.com/pointfreeco/combine-schedulers/blob/main/Sources/CombineSchedulers/UIScheduler.swift)). This introduces a lot more complexity, and should probably not be adopted without having a very good reason. - - This is why we require all actions be sent from the same thread. This requirement is in the same spirit of how `URLSession` and other Apple APIs are designed. Those APIs tend to deliver their outputs on whatever thread is most convenient for them, and then it is your responsibility to dispatch back to the main queue if that's what you need. The Composable Architecture makes you responsible for making sure to send actions on the main thread. If you are using an effect that may deliver its output on a non-main thread, you must explicitly perform `.observe(on:)` in order to force it back on the main thread. - - This approach makes the fewest number of assumptions about how effects are created and transformed, and prevents unnecessary thread hops and re-dispatching. It also provides some testing benefits. If your effects are not responsible for their own scheduling, then in tests all of the effects would run synchronously and immediately. You would not be able to test how multiple in-flight effects interleave with each other and affect the state of your application. However, by leaving scheduling out of the `Store` we get to test these aspects of our effects if we so desire, or we can ignore if we prefer. We have that flexibility. - - However, if you are still not a fan of our choice, then never fear! The Composable Architecture is flexible enough to allow you to introduce this functionality yourself if you so desire. It is possible to create a higher-order reducer that can force all effects to deliver their output on the main thread, regardless of where the effect does its work: - - ```swift - extension Reducer { - func receive(on scheduler: S) -> Self { - Self { state, action, environment in - self(&state, action, environment) - .observe(on: scheduler) - } - } - } - ``` - - You would probably still want something like a `UIScheduler` so that you don't needlessly perform thread hops. -
- ## Requirements This fork of The Composable Architecture uses the ReactiveSwift framework, it currently requires minimum deployment targets of iOS 12, macOS 10.14, Mac Catalyst 14, tvOS 14, and watchOS 5, although it may be possible to support earlier versions too. Since release 0.8.1, Linux is also supported. @@ -419,12 +391,13 @@ You can add ComposableArchitecture to an Xcode project by adding it as a package The documentation for releases and `main` are available here: * [`main`](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture) -* [0.38.0](https://pointfreeco.github.io/swift-composable-architecture/0.38.0/documentation/composablearchitecture/) +* [0.39.0](https://pointfreeco.github.io/swift-composable-architecture/0.39.0/documentation/composablearchitecture/)
Other versions + * [0.38.0](https://pointfreeco.github.io/swift-composable-architecture/0.38.0/documentation/composablearchitecture/) * [0.37.0](https://pointfreeco.github.io/swift-composable-architecture/0.37.0/documentation/composablearchitecture) * [0.36.0](https://pointfreeco.github.io/swift-composable-architecture/0.36.0/documentation/composablearchitecture) * [0.35.0](https://pointfreeco.github.io/swift-composable-architecture/0.35.0/documentation/composablearchitecture) diff --git a/Sources/ComposableArchitecture/Debugging/ReducerInstrumentation.swift b/Sources/ComposableArchitecture/Debugging/ReducerInstrumentation.swift index 521fcf6a6..1d99d3be0 100644 --- a/Sources/ComposableArchitecture/Debugging/ReducerInstrumentation.swift +++ b/Sources/ComposableArchitecture/Debugging/ReducerInstrumentation.swift @@ -53,33 +53,35 @@ } } - extension Effect where Error == Never { + extension Effect where Failure == Never { func effectSignpost( _ prefix: String, log: OSLog, actionOutput: String - ) -> Effect { + ) -> Effect { let sid = OSSignpostID(log: log) - return self.on( - starting: { - os_signpost( - .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, - actionOutput - ) - }, - completed: { - os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) - }, - disposed: { - os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) - }, - value: { value in - os_signpost( - .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput - ) - } - ) + return self.producer + .on( + starting: { + os_signpost( + .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, + actionOutput + ) + }, + completed: { + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) + }, + disposed: { + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) + }, + value: { value in + os_signpost( + .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput + ) + } + ) + .eraseToEffect() } } #endif diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingReadyForSwiftConcurrency.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingReadyForSwiftConcurrency.md new file mode 100644 index 000000000..c97a00aed --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingReadyForSwiftConcurrency.md @@ -0,0 +1,97 @@ +# Getting ready for Swift concurrency + +Learn how to write safe, concurrent effects using Swift's structured concurrency. + +As of version 5.6, Swift can provide many warnings for situations in which you might be using types +and functions that are not thread-safe in concurrent contexts. Many of these warnings can be ignored +for the time being, but in Swift 6 most (if not all) of these warnings will become errors, and so +you will need to know how to prove to the compiler that your types are safe to use concurrently. + +There are 3 primary ways to create an ``Effect`` in the library: + + * ``Effect/task(priority:operation:catch:file:fileID:line:)`` + * ``Effect/run(priority:operation:catch:file:fileID:line:)`` + * ``Effect/fireAndForget(priority:_:)`` + +Each of these constructors takes a `@Sendable`, asynchronous closure, which restricts the types of +closures you can use for your effects. In particular, the closure can only capture `Sendable` +variables that are bound with `let`. Mutable variables and non-`Sendable` types are simply not +allowed to be passed to `@Sendable` closures. + +There are two primary ways you will run into this restriction when building a feature in the +Composable Architecture: accessing state from within an effect, and accessing a dependency from +within an effect. + +### Accessing state in an effect + +Reducers are executed with a mutable, `inout` state variable, and such variables cannot be accessed +from within `@Sendable` closures: + +```swift +Reducer { state, action, environment in + switch action { + case .buttonTapped: + return .task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return .delayed(state.count) + // 🛑 Mutable capture of 'inout' parameter 'state' is + // not allowed in concurrently-executing code + } + + … + } +} +``` + +To work around this you must explicitly capture the state as an immutable value for the scope of the +closure: + +```swift +return .task { [state] in + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return .delayed(state.count) // ✅ +} +``` + +You can also capture just the minimal parts of the state you need for the effect by binding a new +variable name for the capture: + +```swift +return .task { [count = state.count] in + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return .delayed(count) // ✅ +} +``` + +### Accessing dependencies in an effect + +In the Composable Architecture, one designs an environment of dependencies that your feature needs to +do its job. These are all the clients and objects that interact with the messy, unpredictable +outside world, but provide an interface that is easy to control so that we can still write tests. + +Dependencies are typically accessed inside the effect, which means it must be `Sendable`, otherwise +we will get the following warning (and error in Swift 6): + +```swift +case .numberFactButtonTapped: + return .task { [count = state.count] in + await .numberFactResponse( + TaskResult { try await environment.factClient.fetch(count) } + ) + // ⚠️ Capture of 'environment' with non-sendable type 'AppEnvironment' + // in a `@Sendable` closure + } +``` + +To fix this we need to make each dependency held in the environment `Sendable`. This usually just +means making sure that the interface type only holds onto `Sendable` data, and in particular, any +closure-based endpoints should be annotated as `@Sendable`: + +```swift +struct FactClient { + var fetch: @Sendable (Int) async throws -> String +} +``` + +This will restrict the kinds of closures that can be used when construct `FactClient` values, thus +making the entire `FactClient` sendable itself. diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md index dea34f0e0..f78140ec9 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/GettingStarted.md @@ -1,3 +1,5 @@ +# Getting started + Learn how to integrate the Composable Architecture into your project and write your first application. @@ -12,7 +14,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - from: "0.34.0" + from: "0.39.0" ), ], targets: [ @@ -77,25 +79,16 @@ enum AppAction: Equatable { case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped - case numberFactResponse(Result) + case numberFactResponse(TaskResult) } - -struct ApiError: Error, Equatable {} ``` Next we model the environment of dependencies this feature needs to do its job. In particular, to -fetch a number fact we need to construct an `Effect` value that encapsulates the network request. -So that dependency is a function from `Int` to `Effect`, where `String` represents -the response from the request. Further, the effect will typically do its work on a background thread -(as is the case with `URLSession`), and so we need a way to receive the effect's values on the main -queue. We do this via a main queue scheduler, which is a dependency that is important to control so -that we can write tests. We must use an `AnyScheduler` so that we can use a live `DispatchQueue` in -production and a test scheduler in tests. +fetch a number fact we can model an async throwing function from `Int` to `String`: ```swift struct AppEnvironment { - var mainQueue: AnySchedulerOf - var numberFact: (Int) -> Effect + var numberFact: (Int) async throws -> String } ``` @@ -104,7 +97,11 @@ the current state to the next state, and describes what effects need to be execu don't need to execute effects, and they can return `.none` to represent that: ```swift -let appReducer = Reducer { state, action, environment in +let appReducer = Reducer< + AppState, + AppAction, + AppEnvironment +> { state, action, environment in switch action { case .factAlertDismissed: state.numberFactAlert = nil @@ -119,9 +116,11 @@ let appReducer = Reducer { state, action, e return .none case .numberFactButtonTapped: - return environment.numberFact(state.count) - .receive(on: environment.mainQueue) - .catchToEffect(AppAction.numberFactResponse) + return .task { + await .numberFactResponse( + TaskResult { try await environment.numberFact(state.count) } + ) + } case let .numberFactResponse(.success(fact)): state.numberFactAlert = fact @@ -175,23 +174,28 @@ It's important to note that we were able to implement this entire feature withou live effect at hand. This is important because it means features can be built in isolation without building their dependencies, which can help compile times. -Once we are ready to display this view, for example in the scene delegate, we can construct a store. -This is the moment where we need to supply the dependencies, and for now we can just use an effect -that immediately returns a mocked string: +Once we are ready to display this view, for example in the app's entry point, we can construct a +store. This is the moment where we need to supply the dependencies, including the `numberFact` +endpoint that actually reaches out into the real world to fetch the fact: ```swift -let appView = AppView( +@main +struct CaseStudiesApp: App { +var body: some Scene { + AppView( store: Store( initialState: AppState(), reducer: appReducer, environment: AppEnvironment( - mainQueue: .main, numberFact: { number in - Effect(value: "\(number) is a good number Brent") + let (data, _) = try await URLSession.shared + .data(from: .init(string: "http://numbersapi.com/\(number)")!) + return String(decoding: data, using: UTF8.self) } ) ) ) +} ``` And that is enough to get something on the screen to play around with. It's definitely a few more @@ -204,21 +208,20 @@ doing much additional work. ## Testing your feature To test, you first create a `TestStore` with the same information that you would to create a regular -`Store`, except this time we can supply test-friendly dependencies. In particular, we use a test -scheduler instead of the live `DispatchQueue.main` scheduler because that allows us to control when -work is executed, and we don't have to artificially wait for queues to catch up. +`Store`, except this time we can supply test-friendly dependencies. In particular, we can now use a +`numberFact` implementation that immediately returns a value we control rather than reaching out +into the real world: ```swift -let scheduler = DispatchQueue.test - +func testFeature() async { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: AppEnvironment( - mainQueue: mainQueue.eraseToAnyScheduler(), - numberFact: { number in Effect(value: "\(number) is a good number Brent") } + numberFact: { "\($0) is a good number Brent" } ) ) +} ``` Once the test store is created we can use it to make an assertion of an entire user flow of steps. @@ -232,25 +235,24 @@ alert to go away. ```swift // Test that tapping on the increment/decrement buttons changes the count -store.send(.incrementButtonTapped) { +await store.send(.incrementButtonTapped) { $0.count = 1 } -store.send(.decrementButtonTapped) { +await store.send(.decrementButtonTapped) { $0.count = 0 } -// Test that tapping the fact button causes us to receive a response from the -// effect. Note that we have to advance the scheduler because we used -// `.receive(on:)` in the reducer. -store.send(.numberFactButtonTapped) +// Test that tapping the fact button causes us to receive a response from the effect. Note +// that we have to await the receive because the effect is asynchronous and so takes a small +// amount of time to emit. +await store.send(.numberFactButtonTapped) -mainQueue.advance() -store.receive(.numberFactResponse(.success("0 is a good number Brent"))) { +await store.receive(.numberFactResponse(.success("0 is a good number Brent"))) { $0.numberFactAlert = "0 is a good number Brent" } // And finally dismiss the alert -store.send(.factAlertDismissed) { +await store.send(.factAlertDismissed) { $0.numberFactAlert = nil } ``` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md new file mode 100644 index 000000000..5c0497581 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md @@ -0,0 +1,225 @@ +# Performance + +Learn how to improve the performance of features built in the Composable Architecture. + +As your features and application grow you may run into performance problems, such as reducers +becoming slow to execute, SwiftUI view bodies executing more often than expected, and more. + + +* [View stores](#View-stores) +* [CPU-intensive calculations](#CPU-intensive-calculations) +* [High-frequency actions](#High-frequency-actions) + + +### View stores + +A common performance pitfall when using the library comes from constructing ``ViewStore``s. When +constructed naively, using either view store's initializer ``ViewStore/init(_:)-1pfeq`` or the +SwiftUI helper ``WithViewStore``, it will observe every change to state in the store: + +```swift +WithViewStore(self.store) { viewStore in + // This is executed for every action sent into the system + // that causes self.store.state to change. +} +``` + +Most of the time this observes far too much state. A typical feature in the Composable Architecture +holds onto not only the state the view needs to present UI, but also state that the feature only +needs internally, as well as state of child features embedded in the feature. Changes to the +internal and child state should not cause the view's body to re-compute since that state is not +needed in the view. + +For example, if the root of our application was a tab view, then we could model that in state as a +struct that holds each tab's state as a property: + +```swift +struct AppState { + var activity: ActivityState + var search: SearchState + var profile: ProfileState +} +``` + +If the view only needs to construct the views for each tab, then no view store is even needed +because we can pass scoped stores to each child feature view: + +```swift +struct AppView: View { + let store: Store + + var body: some View { + // No need to observe state changes because the view does + // not need access to the state. + + TabView { + ActivityView( + store: self.store + .scope(state: \.activity, action: AppAction.activity) + ) + SearchView( + store: self.store + .scope(state: \.search, action: AppAction.search) + ) + ProfileView( + store: self.store + .scope(state: \.profile, action: AppAction.profile) + ) + } + } +} +``` + +This means `AppView` does not actually need to observe any state changes. This view will only be +created a single time, whereas if we observed the store then it would re-compute every time a single +thing changed in either the activity, search or profile child features. + +If sometime in the future we do actually need some state from the store, we can create a localized +"view state" struct that holds only the bare essentials of state that the view needs to do its +job. For example, suppose the activity state holds an integer that represents the number of +unread activities. Then we could observe changes to only that piece of state like so: + +```swift +struct AppView: View { + let store: Store + + struct ViewState { + let unreadActivityCount: Int + init(state: AppState) { + self.unreadActivityCount = state.activity.unreadCount + } + } + + var body: some View { + WithViewStore( + self.store.scope(state: ViewState.init) + ) { viewStore in + TabView { + ActivityView( + store: self.store + .scope(state: \.activity, action: AppAction.activity + ) + .badge("\(viewStore.unreadActivityCount)") + + … + } + } + } +} +``` + +Now the `AppView` will re-compute its body only when `activity.unreadCount` changes. In particular, +no changes to the search or profile features will cause the view to re-compute, and that greatly +reduces how often the view must re-compute. + +This technique for reducing view re-computations is most effective towards the root of your app +hierarchy and least effective towards the leaf nodes of your app. Root features tend to hold lots +of state that its view does not need, such as child features, and leaf features tend to only hold +what's necessary. If you are going to employ this technique you will get the most benefit by +applying it to views closer to the root. + +### CPU intensive calculations + +Reducers are run on the main thread and so they are not appropriate for performing intense CPU +work. If you need to perform lots of CPU-bound work, then it is more appropriate to use an +``Effect``, which will operate in the cooperative thread pool, and then send it's output back into +the system via an action. You should also make sure to perform your CPU intensive work in a +cooperative manner by periodically suspending with `Task.yield()` so that you do not block a thread +in the cooperative pool for too long. + +So, instead of performing intense work like this in your reducer: + +```swift +case .buttonTapped: + var result = … + for value in someLargeCollection { + // Some intense computation with value + } + state.result = result +``` + +...you should return an effect to perform that work, sprinkling in some yields every once in awhile, +and then delivering the result in an action: + +```swift +case .buttonTapped: + return .task { + var result = … + for (index, value) in someLargeCollection.enumerated() { + // Some intense computation with value + + // Yield every once in awhile to cooperate in the thread pool. + if index.isMultiple(of: 1_000) { + await Task.yield() + } + } + return .response(result) + } + +case let .response(result): + state.result = result +``` + +This will keep CPU intense work from being performed in the reducer, and hence not on the main +thread. + +### High-frequency actions + +Sending actions in a Composable Architecture application should not be thought as simple method +calls that one does with classes, such as `ObservableObject` conformances. When an action is sent +into the system there are multiple layers of features that can intercept and interpret it, and +the resulting state changes can reverberate throughout the entire application. + +Because of this, sending actions do come with a cost. You should aim to only send "significant" +actions into the system, that is, actions that cause the execution of important logic and effects +for your application. High-frequency actions, such as sending dozens of actions per second, +should be avoided unless your application truly needs that volume of actions in order to implement +its logic. + +However, there are often times that actions are sent at a high frequency but the reducer doesn't +actually need that volume of information. For example, say you were constructing an effect that +wanted to report its progress back to the system for each step of its work. You could choose to send +the progress for literally every step: + +```swift +case .startButtonTapped: + return .run { send in + var count = 0 + let max = await environment.eventCount() + + for await event in environment.eventSource() { + defer { count += 1 } + send(.progress(Double(count) / Double(max))) + } + } +} +``` + +However, what if the effect required 10,000 steps to finish? Or 100,000? Or more? It would be +immensely wasteful to send 100,000 actions into the system to report a progress value that is only +going to vary from 0.0 to 1.0. + +Instead, you can choose to report the progress every once in awhile. You can even do the math +to make it so that you report the progress at most 100 times: + +```swift +case .startButtonTapped: + return .run { send in + var count = 0 + let max = await environment.eventCount() + let interval = max / 100 + + for await event in environment.eventSource() { + defer { count += 1 } + if count.isMultiple(of: interval) { + send(.progress(Double(count) / Double(max))) + } + } + } +} +``` + +This greatly reduces the bandwidth of actions being sent into the system so that you are not +incurring unnecessary costs for sending actions. + + diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/SwiftUI.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/SwiftUI.md index 8f3fbb881..083344dfa 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/SwiftUI.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/SwiftUI.md @@ -28,7 +28,7 @@ The Composable Architecture can be used to power applications built in many fram - ``BindableAction`` - ``BindingAction`` - ``Reducer/binding()`` -- ``ViewStore/binding(_:file:line:)`` +- ``ViewStore/binding(_:file:fileID:line:)`` ### View State diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md new file mode 100644 index 000000000..dd4ade336 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md @@ -0,0 +1,561 @@ +# Testing + +Learn how to write comprehensive and exhaustive tests for your features built in the Composable +Architecture. + +The testability of features built in the Composable Architecture is the #1 priority of the library. +We never want to introduce new capabilities to the library that make testing more difficult. + +* [Testing state changes][Testing-state-changes] +* [Testing effects][Testing-effects] +* [Designing dependencies][Designing-dependencies] +* [Unimplemented dependencies][Unimplemented-dependencies] + +## Testing state changes + +State changes are by far the simplest thing to test in features built with the library. A +``Reducer``'s first responsibility is to mutate the current state based on the action received into +the system. To test this we can technically run a piece of mutable state through the reducer and +then assert on how it changed after, like this: + +```swift +struct State: Equatable { var count = 0 } +enum Action { case incrementButtonTapped, decrementButtonTapped } +struct Environment {} + +let counter = Reducer { state, action, environment in + switch action { + case .incrementButtonTapped: + state.count += 1 + return .none + case .decrementButtonTapped: + state.count -= 1 + return .none + } +} + +let environment = Environment() +var currentState = State(count: 0) + +_ = reducer(¤tState, .incrementButtonTapped, environment) + +XCTAssertEqual( + currentState, + State(count: 1) +) + +_ = reducer(¤tState, .decrementButtonTapped, environment) + +XCTAssertEqual( + currentState, + State(count: 0) +) +``` + +This will technically work, but it's a lot boilerplate for something that should be quite simple. + +The library comes with a tool specifically designed to make testing like this much simpler and more +concise. It's called ``TestStore``, and it is constructed similarly to ``Store`` by providing the +initial state of the feature, the ``Reducer`` that run's the feature's logic, and an environment of +dependencies for the feature to use: + +```swift +@MainActor +class CounterTests: XCTestCase { + func testBasics() async { + let store = TestStore( + initialState: State(count: 0), + reducer: counter, + environment: Environment() + ) + } +} +``` + +> Test cases that use ``TestStore`` should be annotated as `@MainActor` and test methods should be +> marked as `async` since most assertion helpers on ``TestStore`` can suspend. + +Test stores have a ``TestStore/send(_:_:file:line:)-7vwv9`` method, but it behaves differently from +stores and view stores. You provide an action to send into the system, but then you must also +provide a trailing closure to describe how the state of the feature changed after sending the +action: + +```swift +await store.send(.incrementButtonTapped) { + … +} +``` + +This closure is handed a mutable variable that represents the state of the feature _before_ sending +the action, and it is your job to make the appropriate mutations to it to get it into the shape +it should be after sending the action: + +```swift +await store.send(.incrementButtonTapped) { + $0.count = 1 +} +``` + +> The ``TestStore/send(_:_:file:line:)-7vwv9`` method is `async` for technical reasons that we do +not have to worry about right now. + +If your mutation is incorrect, meaning you perform a mutation that is different from what happened +in the ``Reducer``, then you will get a test failure with a nicely formatted message showing exactly +what part of the state does not match: + +```swift +await store.send(.incrementButtonTapped) { + $0.count = 999 +} +``` + +``` +🛑 testSomething(): A state change does not match expectation: … + + − TestStoreTests.State(count: 999) + + TestStoreTests.State(count: 1) + +(Expected: −, Actual: +) +``` + +You can also send multiple actions to emulate a script of user actions and assert each step of the +way how the state evolved: + +```swift +await store.send(.incrementButtonTapped) { + $0.count = 1 +} +await store.send(.incrementButtonTapped) { + $0.count = 2 +} +await store.send(.decrementButtonTapped) { + $0.count = 1 +} +``` + +> Note: Technically we could have written the mutation block in the following manner: +> +> ```swift +> await store.send(.incrementButtonTapped) { +> $0.count += 1 +> } +> ``` +> +> …and the test would have still passed. +> +> However, this does not produce as strong of an assertion. It shows that the count did increment +> by one, but we haven't proven we know the precise value of `count` at each step of the way. +> +> In general, the less logic you have in the trailing closure of +> ``TestStore/send(_:_:file:line:)-7vwv9``, the stronger your assertion will be. It is best to use +> simple, hard coded data for the mutation. + +Test stores do expose a ``TestStore/state`` property, which can be useful for performing assertions +on computed properties you might have defined on your state. However, when inside the trailing +closure of ``TestStore/send(_:_:file:line:)-7vwv9``, the ``TestStore/state`` property is equal +to the state _before_ sending the action, not after. That prevents you from being able to use an +escape hatch to get around needing to actually describe the state mutation, like so: + +```swift +store.send(.incrementButtonTapped) { + $0 = store.state // 🛑 store.state is the previous state, not new state. +} +``` + +## Testing effects + +Testing state mutations as shown in the previous section is powerful, but is only half the story +when it comes to testing features built in the Composable Architecture. The second responsibility of +``Reducer``s, after mutating state from an action, is to return an ``Effect`` that encapsulates a +unit of work that runs in the outside world and feeds data back into the system. + +Effects form a major part of a feature's logic. They can perform network requests to external +services, load and save data to disk, start and stop timers, interact with Apple frameworks (Core +Location, Core Motion, Speech Recognition, etc.), and more. + +As a simple example, suppose we have a feature with a button such that when you tap it it starts +a timer that counts up until you reach 5, and then stops. This can be accomplished using the +``Effect/run(priority:operation:catch:file:fileID:line:)`` helper, which provides you an +asynchronous context to operate in and can send multiple actions back into the system: + +```swift +struct State: Equatable { var count = 0 } +enum Action { case startTimerButtonTapped, timerTick } +struct Environment {} + +let reducer = Reducer { state, action, environment in + enum TimerID {} + + switch action { + case .startTimerButtonTapped: + state.count = 0 + return .run { send in + for _ in 1...5 { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + await send(.timerTick) + } + } + + case .timerTick: + state.count += 1 + return .none + } +} +``` + +To test this we can start off similar to how we did in the [previous section][Testing-state-changes] +when testing state mutations: + +```swift +@MainActor +class TimerTests: XCTestCase { + func testBasics() async { + let store = TestStore( + initialState: State(count: 0), + reducer: reducer, + environment: Environment() + ) + } +} +``` + +With the basics set up, we can send an action into the system to assert on what happens, such as the +`.startTimerButtonTapped` action. This time we don't actually expect state to change at first +because when starting the timer we don't change state, and so in this case we can leave off the +trailer closure: + +```swift +await store.send(.startTimerButtonTapped) +``` + +However, if we run the test as-is with no further interactions with the test store, we get a +failure: + +``` +🛑 testSomething(): An effect returned for this action is still running. + It must complete before the end of the test. … +``` + +This is happening because ``TestStore`` requires you to exhaustively prove how the entire system +of your feature evolves over time. If an effect is still running when the test finishes and the +test store does _not_ fail then it could be hiding potential bugs. Perhaps the effect is not +supposed to be running, or perhaps the data it feeds into the system later is wrong. The test store +requires all effects to finish. + +To get this test passing we need to assert on the actions that are sent back into the system +by the effect. We do this by using the ``TestStore/receive(_:timeout:_:file:line:)-88eyr`` method, +which allows you to assert which action you expect to receive from an effect, as well as how the +state changes after receiving that effect: + +```swift +await store.receive(.timerTick) { + $0.count = 1 +} +``` + +However, if we run this test we still get a failure because we asserted a `timerTick` action was +going to be received, but after waiting around for a small amount of time no action was received: + +``` +🛑 testSomething(): Expected to receive an action, but received none after 0.1 seconds. +``` + +This is because our timer is on a 1 second interval, and by default +``TestStore/receive(_:timeout:_:file:line:)-88eyr`` only waits for a fraction of a second. This is +because typically you should not be performing real time-based asynchrony in effects, and instead +using a controlled entity, such as a scheduler or clock, that can be sped up in tests. We will +demonstrate this in a moment, so for now let's increase the timeout: + +```swift +await store.receive(.timerTick, timeout: 2*NSEC_PER_SEC) { + $0.count = 1 +} +``` + +This assertion now passes, but the overall test is still failing because there are still more +actions to receive. The timer should tick 5 times in total, so we need five `receive` assertions: + +```swift +await store.receive(.timerTick, timeout: 2*NSEC_PER_SEC) { + $0.count = 1 +} +await store.receive(.timerTick, timeout: 2*NSEC_PER_SEC) { + $0.count = 2 +} +await store.receive(.timerTick, timeout: 2*NSEC_PER_SEC) { + $0.count = 3 +} +await store.receive(.timerTick, timeout: 2*NSEC_PER_SEC) { + $0.count = 4 +} +await store.receive(.timerTick, timeout: 2*NSEC_PER_SEC) { + $0.count = 5 +} +``` + +Now the full test suite passes, and we have exhaustively proven how effects are executed in this +feature. If in the future we tweak the logic of the effect, like say have it emit some number of +times different from 5, then we will immediately get a test failure letting us know that we have +not properly asserted on how the features evolves over time. + +However, there is something not ideal about how this feature is structured, and that is the fact +that we are doing actual, uncontrolled time-based asynchrony in the effect: + +```swift +return .run { send in + for _ in 1...5 { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + await send(.timerTick) + } +} +``` + +This means for our test to run we must actually wait for 5 real world seconds to pass so that we +can receive all of the actions from the timer. This makes our test suite far too slow. What if in +the future we need to test a feature that has a timer that emits hundreds or thousands of times? +We cannot hold up our test suite for minutes or hours just to test that one feature. + +To fix this we need to hold onto a dependency in the feature's environment that aids in performing +time-based asynchrony, but in a way that is controllable. One way to do this is to add a Combine +scheduler to the environment: + +```swift +import ReactiveSwift + +struct Environment { + var mainQueue: DateScheduler +} +``` + +> To make use of controllable schedulers you must use the +[Combine Schedulers][gh-combine-schedulers] library, which is automatically included with the +Composable Architecture. + +And then the timer effect in the reducer can make use of the scheduler to sleep rather than reaching +out to the uncontrollable `Task.sleep` method: + +```swift +return .run { send in + for _ in 1...5 { + try await environment.mainQueue.sleep(for: .seconds(1)) + await send(.timerTick) + } +} +``` + +> The `sleep(for:)` method on `Scheduler` is provided by the +[Combine Schedulers][gh-combine-schedulers] library. + +By having a scheduler in the environment we can supply a controlled value in tests, such as an +immediate scheduler that does not suspend at all when you ask it to sleep: + +```swift +let store = TestStore( + initialState: State(count: 0), + reducer: reducer, + environment: Environment(mainQueue: .immediate) +) +``` + +With that small change we can drop the `timeout` arguments from the +``TestStore/receive(_:timeout:_:file:line:)-88eyr`` invocations: + +```swift +await store.receive(.timerTick) { + $0.count = 1 +} +await store.receive(.timerTick) { + $0.count = 1 +} +await store.receive(.timerTick) { + $0.count = 2 +} +await store.receive(.timerTick) { + $0.count = 3 +} +await store.receive(.timerTick) { + $0.count = 4 +} +await store.receive(.timerTick) { + $0.count = 5 +} +``` + +…and the test still passes, but now does so immediately. + +## Designing dependencies + +The [previous section][Testing-effects] shows the basics of testing effects in features, but only +for a simple time-based effect which was testable thanks to the tools that the +[Combine Schedulers][gh-combine-schedulers] library provides. + +However, in general, the testability of a feature's effects is correlated with how easy it is to +control the dependencies your feature needs to do its job. If your feature needs to make network +requests, access to a location manager, or generate random numbers, then all of those dependencies +need to be designed in such a way that you can control them during tests so that you can make +assertions on how your feature interacts with those clients. + +There are many ways to design controllable dependencies, and you can feel free to use any techniques +you feel comfortable with, but we will quickly sketch one such pattern. + +Most dependencies can be modeled as an abstract interface to some endpoints that perform work +and return some data. Protocols are a common way to model such interfaces, but simple structs with +function properties can also work, and can reduce boilerplate. + +For example, suppose you had a dependency that could make API requests to a server for fetching +a fact about a number. This can be modeled as a simple struct with a single function property: + +```swift +struct NumberFactClient { + var fetch: (Int) async throws -> String +} +``` + +This defines the interface to fetching a number fact, and we can create a "live" implementation of +the interface that makes an actual network request by constructing an instance, like so: + +```swift +extension NumberFactClient { + static let live = Self( + fetch: { number in + let (data, _) = try await URLSession.shared + .data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!) + return String(decoding: data, as: UTF8.self) + } + ) +} +``` + +This live implementation is appropriate to use when running the app in the simulator or on an actual +device. + +We can also create a "mock" implementation of the interface that doesn't make a network request at +all and instead immediately returns a predictable string: + +```swift +extension NumberFactClient { + static let mock = Self( + fetch: { number in "\(number) is a good number." } + ) +} +``` + +This mock implementation is appropriate to use in tests (and sometimes even previews) where you +do not want to make live network requests since that leaves you open to the vagaries of the outside +world that you cannot possibly predict. + +For example, if you had a simple feature that allows you to increment and decrement a counter, +as well as fetch a fact for the current count, then you could test is roughly like so: + +```swift +let store = TestStore( + initialState: State(), + reducer: reducer, + environment: Environment(numberFact: .mock) +) + +await store.send(.incrementButtonTapped) { + $0.count = 1 +} +await store.send(.factButtonTapped) +await store.receive(.factResponse("1 is a good number.")) { + $0.fact = "1 is a good number." +} +``` + +Such a test can run immediately without making a network request to the outside world, and it will +pass deterministically 100% of the time. + +Most, if not all, dependencies can be designed in this way, from API clients to location managers. +The Composable Architecture repo has [many examples][tca-examples] that demonstrate how to design +clients for very complex dependencies, such as network requests, download managers, web sockets, +speech recognition, and more. + +## Unimplemented dependencies + +Once you have designed your dependency in such a way that makes it easy to control, there is a +particular implementation of the dependency that can increase the strength of your tests. In the +[previous section][Designing-dependencies] we saw that we always want at least a "live" +implementation for using in the production version of the app, and a "mock" implementation for using +in tests, but there is another implementation that can be useful. + +We call this the "unimplemented" implementation, which constructs an instance of the dependency +client whose endpoints have all been stubbed to invoke `XCTFail` so that if the endpoint is ever +used in a test it will trigger a test failure. This allows you to prove what parts of your +dependency is actually used in a test. + +Not every test needs to use every endpoint of every dependency your feature has access to. By +providing the bare essentials of dependency endpoints that your test actually needs we can catch +in the future when a certain execution path of the feature starts using a new dependency that we +did not expect. This could either be due to a bug in the logic, or it could mean there is more logic +that we need to assert on in the test. + +For example, suppose we were designing a client that could interface with a speech recognition API. +There would be an endpoint for requesting authorization to recognize speech on the device, an +endpoint for starting a new speech recognition task, and an endpoint for finishing the task: + +```swift +struct SpeechClient { + var finishTask: () async -> Void + var requestAuthorization: @Sendable () async -> SpeechAuthorizationStatus + var startTask: (Request) async -> AsyncThrowingStream +} +``` + +We can construct an instance of this client that stubs each endpoint as a function that simply +calls `XCTFail` under the hood: + +```swift +import XCTestDynamicOverlay + +extension SpeechClient { + static let unimplemented = Self( + finishTask: XCTUnimplemented("\(Self.self).finishTask"), + requestAuthorization: XCTUnimplemented("\(Self.self).requestAuthorization"), + startTask: XCTUnimplemented("\(Self.self).recognitionTask") + ) +} +``` + +> Note: In general, `XCTest` APIs cannot be used in code that is run in the simulator or on devices. +> To get around this we make use of our [XCTest Dynamic Overlay][gh-xctest-dynamic-overlay] library, +> which dynamically loads `XCTFail` to be available in all execution environments, not only tests. + +Then in tests we start the store's environment with the unimplemented client, and override the bare +essentials of endpoints we expect to be called. + +For example, if we were testing the flow in the feature where the user denies speech recognition +access, then we would not expect the `startTask` or `finishTask` endpoints to ever be called. That +would probably be a logical error, after all when the user denies permission those endpoints can't +do anything useful. + +We can prove that this is the case by using the `.unimplemented` speech client in the test, and then +overriding only the `requestAuthorization` endpoint with an actual implementation: + +```swift +func testDeniedAuthorization() async { + let store = TestStore( + initialState: State(), + reducer: reducer, + environment: Environment(speech: .unimplemented) + ) + + store.environment.speech.requestAuthorization = { .denied } + + … +} +``` + +You can make your tests much stronger by starting all dependencies in an "unimplemented" state, and +then only implementing the bare essentials of endpoints that your feature needs for the particular +flow you are testing. Then in the future, if your feature starts using new dependency endpoints you +will be instantly notified in tests and can figure out if that is expected or if a bug has been +introduced. + +[Testing-state-changes]: #Testing-state-changes +[Testing-effects]: #Testing-effects +[Designing-dependencies]: #Designing-dependencies +[Unimplemented-dependencies]: #Designing-dependencies +[gh-combine-schedulers]: http://github.com/pointfreeco/combine-schedulers +[gh-xctest-dynamic-overlay]: http://github.com/pointfreeco/xctest-dynamic-overlay +[tca-examples]: https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples diff --git a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md index b1b7de31a..bfb8f4bcb 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md +++ b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md @@ -1,6 +1,9 @@ ### Essentials - +- +- +- ### State Management @@ -17,6 +20,7 @@ ### Testing - ``TestStore`` +- ``ActorIsolated`` ## See Also diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md index b1de926f0..282e795ad 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md @@ -5,15 +5,10 @@ ### Creating an Effect - ``none`` -- ``init(value:)`` -- ``init(error:)`` -- ``run(_:)`` -- ``future(_:)`` -- ``catching(_:)`` -- ``result(_:)`` -- ``fireAndForget(_:)`` +- ``task(priority:operation:catch:file:fileID:line:)`` +- ``run(priority:operation:catch:file:fileID:line:)`` - ``fireAndForget(priority:_:)`` -- ``task(priority:operation:)-7lrdd`` +- ``TaskResult`` ### Cancellation @@ -23,25 +18,18 @@ - ``cancellable(id:cancelInFlight:)-17skv`` - ``cancel(id:)-iun1`` - ``cancel(ids:)-dmwy`` +- ``withTaskCancellation(id:cancelInFlight:operation:)-88kxz`` +- ``withTaskCancellation(id:cancelInFlight:operation:)-4dtr6`` ### Composition - - +- ``map(_:)-28ghh`` - ``merge(_:)-3al9f`` - ``merge(_:)-4n451`` -- ``concatenate(_:)-3awnj`` -- ``concatenate(_:)-8x6rz`` -### Timing +### Concurrency -- ``deferred(for:scheduler:options:)`` -- ``debounce(id:for:scheduler:options:)-8x633`` -- ``debounce(id:for:scheduler:options:)-76yye`` -- ``throttle(id:for:scheduler:latest:)-9kwd5`` -- ``throttle(id:for:scheduler:latest:)-5jfpx`` -- ``timer(id:every:tolerance:on:options:)-4exe6`` -- ``timer(id:every:tolerance:on:options:)-7po0d`` +- ``UncheckedSendable`` ### Testing @@ -51,14 +39,6 @@ - ``animation(_:)`` -### Combine Integration - -- ``receive(subscriber:)`` -- ``init(_:)`` -- ``upstream`` -- ``Subscriber`` - - ### Deprecations - diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectDeprecations.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectDeprecations.md index 1f05a142a..04552c238 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectDeprecations.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectDeprecations.md @@ -10,12 +10,39 @@ Avoid using deprecated APIs in your app. Select a method to see the replacement ### Creating an Effect -- ``Effect/task(priority:operation:)-2czg0`` +- ``Effect/task(priority:operation:)`` ### Cancellation - ``Effect/cancel(ids:)-9tnmm`` +### Composition + +- ``Effect/concatenate(_:)-3awnj`` +- ``Effect/concatenate(_:)-8x6rz`` + ### Testing - ``Effect/failing(_:)`` + +### Combine Integration + +- ``Effect/init(_:)`` +- ``Effect/init(value:)`` +- ``Effect/init(error:)`` +- ``Effect/upstream`` +- ``Effect/catching(_:)`` +- ``Effect/debounce(id:for:scheduler:options:)-8x633`` +- ``Effect/debounce(id:for:scheduler:options:)-76yye`` +- ``Effect/deferred(for:scheduler:options:)`` +- ``Effect/fireAndForget(_:)`` +- ``Effect/future(_:)`` +- ``Effect/receive(subscriber:)`` +- ``Effect/result(_:)`` +- ``Effect/run(_:)`` +- ``Effect/throttle(id:for:scheduler:latest:)-9kwd5`` +- ``Effect/throttle(id:for:scheduler:latest:)-5jfpx`` +- ``Effect/timer(id:every:tolerance:on:options:)-4exe6`` +- ``Effect/timer(id:every:tolerance:on:options:)-7po0d`` +- ``Effect/Subscriber`` + diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectRun.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectRun.md new file mode 100644 index 000000000..3f1dcc997 --- /dev/null +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/EffectRun.md @@ -0,0 +1,7 @@ +# ``ComposableArchitecture/Effect/run(priority:operation:catch:file:fileID:line:)`` + +## Topics + +### Sending actions + +- ``Send`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Reducer.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Reducer.md index 8695701a1..587867b6c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Reducer.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Reducer.md @@ -18,10 +18,10 @@ - ``combine(_:)-1ern2`` - ``combined(with:)`` - ``pullback(state:action:environment:)`` -- ``pullback(state:action:environment:file:line:)`` -- ``optional(file:line:)`` -- ``forEach(state:action:environment:file:line:)-gvte`` -- ``forEach(state:action:environment:file:line:)-21wow`` +- ``pullback(state:action:environment:file:fileID:line:)`` +- ``optional(file:fileID:line:)`` +- ``forEach(state:action:environment:file:fileID:line:)-n7qj`` +- ``forEach(state:action:environment:file:fileID:line:)-3m8jy`` - ``Identified`` ### SwiftUI Integration diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/ReducerDeprecations.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/ReducerDeprecations.md index 681a713cb..3e1218c5b 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/ReducerDeprecations.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/ReducerDeprecations.md @@ -14,7 +14,7 @@ Avoid using deprecated APIs in your app. Select a method to see the replacement - ``Reducer/optional(breakpointOnNil:file:line:)`` - ``Reducer/forEach(state:action:environment:breakpointOnNil:file:line:)-7h573`` - ``Reducer/forEach(state:action:environment:breakpointOnNil:file:line:)-1h7qx`` -- ``Reducer/forEach(state:action:environment:breakpointOnNil:file:line:)-8iy04`` +- ``Reducer/forEach(state:action:environment:breakpointOnNil:file:fileID:line:)`` ### SwiftUI Integration diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md index 141a5ddab..34df5c32d 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Store.md @@ -5,7 +5,6 @@ ### Creating a Store - ``init(initialState:reducer:environment:)`` -- ``unchecked(initialState:reducer:environment:)`` ### Scoping Stores diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreDeprecations.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreDeprecations.md index 4bf129fd1..757139451 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreDeprecations.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/StoreDeprecations.md @@ -12,3 +12,4 @@ Avoid using deprecated APIs in your app. Select a method to see the replacement - ``Store/publisherScope(state:action:)`` - ``Store/publisherScope(state:)`` +- ``Store/unchecked(initialState:reducer:environment:)`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md index 61fbe5977..a4fef1754 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md @@ -6,20 +6,21 @@ - ``init(initialState:reducer:environment:file:line:)`` -### Testing a Reducer - -- ``send(_:_:file:line:)`` -- ``receive(_:_:file:line:)`` +### Configuring a Test Store -### Controlling Dependencies +- ``environment`` +- ``timeout`` -Controlling a reducer's dependencies are a crucial part of building a reliable test suite. Mutating the environment provides a means of influencing a reducer's dependencies over the course of a test. +### Testing a Reducer -- ``environment`` +- ``send(_:_:file:line:)-7vwv9`` +- ``receive(_:timeout:_:file:line:)-88eyr`` +- ``finish(timeout:file:line:)-53gi5`` +- ``TestStoreTask`` ### Accessing State -While the most common way of interacting with a test store's state is via its ``send(_:_:file:line:)`` and ``receive(_:_:file:line:)`` methods, you may also access it directly throughout a test. +While the most common way of interacting with a test store's state is via its ``send(_:_:file:line:)-7vwv9`` and ``receive(_:timeout:_:file:line:)-88eyr`` methods, you may also access it directly throughout a test. - ``state`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStoreDeprecations.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStoreDeprecations.md index 85a691a1b..f4c12dcac 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStoreDeprecations.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStoreDeprecations.md @@ -10,6 +10,10 @@ Avoid using deprecated APIs in your app. Select a method to see the replacement ### Testing a Reducer +- ``TestStore/send(_:_:file:line:)-6532q`` +- ``TestStore/receive(_:timeout:_:file:line:)-romu`` +- ``TestStore/receive(_:_:file:line:)`` +- ``TestStore/finish(timeout:file:line:)-43l4y`` - ``TestStore/assert(_:file:line:)-707lb`` - ``TestStore/assert(_:file:line:)-4gff7`` - ``TestStore/Step`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/ViewStore.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/ViewStore.md index fc015e6f5..9eb90939c 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/ViewStore.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/ViewStore.md @@ -4,9 +4,10 @@ ### Creating a View Store - - - ``init(_:removeDuplicates:)`` +- ``init(_:)-4il0f`` + + ### Accessing State @@ -16,11 +17,9 @@ ### Sending Actions - ``send(_:)`` - -### Interacting with Concurrency - - ``send(_:while:)`` - ``yield(while:)`` +- ``ViewStoreTask`` ### SwiftUI Integration @@ -30,8 +29,7 @@ - ``binding(get:send:)-l66r`` - ``binding(send:)-7nwak`` - ``binding(send:)-705m7`` - - +- ``objectWillChange-5oies`` ### Deprecations diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index c14a988ac..9b8f0cc12 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -1,240 +1,475 @@ import Foundation import ReactiveSwift +import XCTestDynamicOverlay -/// The ``Effect`` type encapsulates a unit of work that can be run in the outside world, and can feed -/// data back to the ``Store``. It is the perfect place to do side effects, such as network requests, -/// saving/loading from disk, creating timers, interacting with dependencies, and more. +#if canImport(SwiftUI) + import SwiftUI +#endif + +/// The ``Effect`` type encapsulates a unit of work that can be run in the outside world, and can +/// feed data back to the ``Store``. It is the perfect place to do side effects, such as network +/// requests, saving/loading from disk, creating timers, interacting with dependencies, and more. +/// +/// Effects are returned from reducers so that the ``Store`` can perform the effects after the +/// reducer is done running. +/// +/// There are 2 distinct ways to create an `Effect`: one using Swift's native concurrency tools, and +/// the other using ReactiveSwift framework: /// -/// Effects are returned from reducers so that the ``Store`` can perform the effects after the reducer -/// is done running. It is important to note that ``Store`` is not thread safe, and so all effects -/// must receive values on the same thread, **and** if the store is being used to drive UI then it -/// must receive values on the main thread. +/// * If using Swift's native structured concurrency tools then there are 3 main ways to create an +/// effect, depending on if you want to emit one single action back into the system, or any number +/// of actions, or just execute some work without emitting any actions: +/// * ``Effect/task(priority:operation:catch:file:fileID:line:)`` +/// * ``Effect/run(priority:operation:catch:file:fileID:line:)`` +/// * ``Effect/fireAndForget(priority:_:)`` +/// * If using ReactiveSwift in your application, in particular the dependencies of your feature's +/// environment, then you can create effects by making use of ReactiveSwift's `SignalProducer`s. +/// Note that the ReactiveSwift interface to ``Effect`` is considered soft deprecated, and you +/// should eventually port to Swift's native concurrency tools. /// -/// An effect is simply a typealias for a ReactiveSwift `SignalProducer` -public typealias Effect = SignalProducer +/// > Important: ``Store`` is not thread safe, and so all effects must receive values on the same +/// thread. This is typically the main thread, **and** if the store is being used to drive UI +/// then it must receive values on the main thread. +/// > +/// > This is only an issue if using the ReactiveSwift interface of ``Effect`` as mentioned above. +/// If you you are using Swift's concurrency tools and the `.task`, `.run` and `.fireAndForget` +/// functions on ``Effect``, then threading is automatically handled for you. +public struct Effect { + let producer: SignalProducer +} + +// MARK: - Creating Effects extension Effect { /// An effect that does nothing and completes immediately. Useful for situations where you must /// return an effect, but you don't need to do anything. public static var none: Self { - .empty + Effect(producer: .empty) } +} - /// Creates an effect that executes some work in the real world that doesn't need to feed data - /// back into the store. If an error is thrown, the effect will complete and the error will be ignored. +extension Effect where Failure == Never { + /// Wraps an asynchronous unit of work in an effect. /// - /// - Parameter work: A closure encapsulating some work to execute in the real world. - /// - Returns: An effect. + /// This function is useful for executing work in an asynchronous context and capturing the result + /// in an ``Effect`` so that the reducer, a non-asynchronous context, can process it. /// - /// - Parameter work: A closure encapsulating some work to execute in the real world. - /// - Returns: An effect. - public static func fireAndForget(_ work: @escaping () throws -> Void) -> Self { - .deferred { () -> SignalProducer in - try? work() - return .empty - } - } - + /// For example, if your environment contains a dependency that exposes an `async` function, you + /// can use ``task(priority:operation:catch:file:fileID:line:)`` to provide an asynchronous + /// context for invoking that endpoint: /// - /// - Parameter effects: A variadic list of effects. - /// - Returns: A new effect - public static func concatenate(_ effects: Effect...) -> Self { - .concatenate(effects) - } - - /// Concatenates a collection of effects together into a single effect, which runs the effects one - /// after the other. + /// ```swift + /// struct FeatureEnvironment { + /// var numberFact: (Int) async throws -> String + /// } /// - /// - Parameter effects: A collection of effects. - /// - Returns: A new effect - public static func concatenate( - _ effects: C - ) -> Effect where C.Element == Effect { - guard let first = effects.first else { return .none } - - return - effects - .dropFirst() - .reduce(into: first) { effects, effect in - effects = effects.concat(effect) - } - } + /// enum FeatureAction { + /// case factButtonTapped + /// case faceResponse(TaskResult) + /// } + /// + /// let featureReducer = Reducer { state, action, environment in + /// switch action { + /// case .factButtonTapped: + /// return .task { [number = state.number] in + /// await .factResponse(TaskResult { try await environment.numberFact(number) }) + /// } + /// + /// case .factResponse(.success(fact)): + /// // do something with fact + /// + /// case .factResponse(.failure): + /// // handle error + /// + /// ... + /// } + /// } + /// ``` + /// + /// The above code sample makes use of ``TaskResult`` in order to automatically bundle the success + /// or failure of the `numberFact` endpoint into a single type that can be sent in an action. + /// + /// The closure provided to ``task(priority:operation:catch:file:fileID:line:)`` is allowed to + /// throw, but any non-cancellation errors thrown will cause a runtime warning when run in the + /// simulator or on a device, and will cause a test failure in tests. To catch non-cancellation + /// errors use the `catch` trailing closure. + /// + /// - Parameters: + /// - priority: Priority of the underlying task. If `nil`, the priority will come from + /// `Task.currentPriority`. + /// - operation: The operation to execute. + /// - catch: An error handler, invoked if the operation throws an error other than + /// `CancellationError`. + /// - Returns: An effect wrapping the given asynchronous work. + public static func task( + priority: TaskPriority? = nil, + operation: @escaping @Sendable () async throws -> Output, + catch handler: (@Sendable (Error) async -> Output)? = nil, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> Self { + + SignalProducer { observer, lifetime in + let task = Task(priority: priority) { @MainActor in + defer { observer.sendCompleted() } + do { + try Task.checkCancellation() + let output = try await operation() + try Task.checkCancellation() + observer.send(value: output) + } catch is CancellationError { + return + } catch { + guard let handler = handler else { + #if DEBUG + var errorDump = "" + customDump(error, to: &errorDump, indent: 4) + runtimeWarning( + """ + An 'Effect.task' returned from "%@:%d" threw an unhandled error. … + + %@ - /// An ``Effect`` that waits until it is started before running - /// the supplied closure to create a new ``Effect``, whose values - /// are then sent to the subscriber of this effect. - public static func deferred(_ createProducer: @escaping () -> SignalProducer) - -> Self - { - Effect(value: ()) - .flatMap(.merge, createProducer) + All non-cancellation errors must be explicitly handled via the 'catch' parameter \ + on 'Effect.task', or via a 'do' block. + """, + [ + "\(fileID)", + line, + errorDump, + ], + file: file, + line: line + ) + #endif + return + } + await observer.send(value: handler(error)) + } + } + lifetime += AnyDisposable(task.cancel) + } + .eraseToEffect() } - /// Creates an effect that can supply a single value asynchronously in the future. + /// Wraps an asynchronous unit of work that can emit any number of times in an effect. /// - /// This can be helpful for converting APIs that are callback-based into ones that deal with - /// ``Effect``s. + /// This effect is similar to ``task(priority:operation:catch:file:fileID:line:)`` except it is + /// capable of emitting 0 or more times, not just once. /// - /// For example, to create an effect that delivers an integer after waiting a second: + /// For example, if you had an async stream in your environment: /// /// ```swift - /// Effect.future { callback in - /// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - /// callback(.success(42)) - /// } + /// struct FeatureEnvironment { + /// var events: () -> AsyncStream /// } /// ``` /// - /// Note that you can only deliver a single value to the `callback`. If you send more they will be - /// discarded: + /// Then you could attach to it in a `run` effect by using `for await` and sending each output of + /// the stream back into the system: /// /// ```swift - /// Effect.future { callback in - /// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - /// callback(.success(42)) - /// callback(.success(1729)) // Will not be emitted by the effect + /// case .startButtonTapped: + /// return .run { send in + /// for await event in environment.events() { + /// send(.event(event)) + /// } /// } - /// } /// ``` /// - /// - Parameter attemptToFulfill: A closure that takes a `callback` as an argument which can be - /// used to feed it `Result` values. - public static func future( - _ attemptToFulfill: @escaping (@escaping (Result) -> Void) -> Void + /// See ``Send`` for more information on how to use the `send` argument passed to `run`'s closure. + /// + /// The closure provided to ``run(priority:operation:catch:file:fileID:line:)`` is allowed to + /// throw, but any non-cancellation errors thrown will cause a runtime warning when run in the + /// simulator or on a device, and will cause a test failure in tests. To catch non-cancellation + /// errors use the `catch` trailing closure. + /// + /// - Parameters: + /// - priority: Priority of the underlying task. If `nil`, the priority will come from + /// `Task.currentPriority`. + /// - operation: The operation to execute. + /// - catch: An error handler, invoked if the operation throws an error other than + /// `CancellationError`. + /// - Returns: An effect wrapping the given asynchronous work. + public static func run( + priority: TaskPriority? = nil, + operation: @escaping @Sendable (Send) async throws -> Void, + catch handler: (@Sendable (Error, Send) async -> Void)? = nil, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line ) -> Self { - SignalProducer { observer, _ in - attemptToFulfill { result in - switch result { - case let .success(value): - observer.send(value: value) - observer.sendCompleted() - case let .failure(error): - observer.send(error: error) + + .run { observer in + let task = Task(priority: priority) { @MainActor in + defer { observer.sendCompleted() } + let send = Send(send: { observer.send(value: $0) }) + do { + try await operation(send) + } catch is CancellationError { + return + } catch { + guard let handler = handler else { + #if DEBUG + var errorDump = "" + customDump(error, to: &errorDump, indent: 4) + runtimeWarning( + """ + An 'Effect.run' returned from "%@:%d" threw an unhandled error. … + + %@ + + All non-cancellation errors must be explicitly handled via the 'catch' parameter \ + on 'Effect.run', or via a 'do' block. + """, + [ + "\(fileID)", + line, + errorDump, + ], + file: file, + line: line + ) + #endif + return + } + await handler(error, send) } } + return AnyDisposable { + task.cancel() + } } } - /// Initializes an effect that lazily executes some work in the real world and synchronously sends - /// that data back into the store. + /// Creates an effect that executes some work in the real world that doesn't need to feed data + /// back into the store. If an error is thrown, the effect will complete and the error will be + /// ignored. /// - /// For example, to load a user from some JSON on the disk, one can wrap that work in an effect: + /// This effect is handy for executing some asynchronous work that your feature doesn't need to + /// react to. One such example is analytics: /// /// ```swift - /// Effect.result { - /// let fileUrl = URL( - /// fileURLWithPath: NSSearchPathForDirectoriesInDomains( - /// .documentDirectory, .userDomainMask, true - /// )[0] - /// ) - /// .appendingPathComponent("user.json") - /// - /// let result = Result { - /// let data = try Data(contentsOf: fileUrl) - /// return try JSONDecoder().decode(User.self, from: $0) + /// case .buttonTapped: + /// return .fireAndForget { + /// try await environment.analytics.track("Button Tapped") /// } - /// - /// return result - /// } /// ``` /// - /// - Parameter attemptToFulfill: A closure encapsulating some work to execute in the real world. + /// The closure provided to ``fireAndForget(priority:_:)`` is allowed to throw, and any error + /// thrown will be ignored. + /// + /// - Parameters: + /// - priority: Priority of the underlying task. If `nil`, the priority will come from + /// `Task.currentPriority`. + /// - work: A closure encapsulating some work to execute in the real world. /// - Returns: An effect. - public static func result(_ attemptToFulfill: @escaping () -> Result) -> Self { - Effect { () -> Result in - attemptToFulfill() + public static func fireAndForget( + priority: TaskPriority? = nil, + _ work: @escaping @Sendable () async throws -> Void + ) -> Self { + Effect.task(priority: priority) { try? await work() } + .producer + .fireAndForget() + } +} + +/// A type that can send actions back into the system when used from +/// ``Effect/run(priority:operation:catch:file:fileID:line:)``. +/// +/// This type implements [`callAsFunction`][callAsFunction] so that you invoke it as a function +/// rather than calling methods on it: +/// +/// ```swift +/// return .run { send in +/// send(.started) +/// defer { send(.finished) } +/// for await event in environment.events { +/// send(.event(event)) +/// } +/// } +/// ``` +/// +/// You can also send actions with animation: +/// +/// ```swift +/// send(.started, animation: .spring()) +/// defer { send(.finished, animation: .default) } +/// ``` +/// +/// See ``Effect/run(priority:operation:catch:file:fileID:line:)`` for more information on how to +/// use this value to construct effects that can emit any number of times in an asynchronous +/// context. +/// +/// [callAsFunction]: https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#ID622 +@MainActor +public struct Send { + public let send: (Action) -> Void + + public init(send: @escaping (Action) -> Void) { + self.send = send + } + + /// Sends an action back into the system from an effect. + /// + /// - Parameter action: An action. + public func callAsFunction(_ action: Action) { + guard !Task.isCancelled else { return } + self.send(action) + } + + #if canImport(SwiftUI) + /// Sends an action back into the system from an effect with animation. + /// + /// - Parameters: + /// - action: An action. + /// - animation: An animation. + public func callAsFunction(_ action: Action, animation: Animation?) { + guard !Task.isCancelled else { return } + withAnimation(animation) { + self(action) + } } + #endif +} + +// MARK: - Composing Effects + +extension Effect { + /// Merges a variadic list of effects together into a single effect, which runs the effects at the + /// same time. + /// + /// - Parameter effects: A list of effects. + /// - Returns: A new effect + public static func merge(_ effects: Self...) -> Self { + .merge(effects) } - /// Turns any `SignalProducer` into an ``Effect`` that cannot fail by wrapping its output and failure in - /// a result. + /// Merges a sequence of effects together into a single effect, which runs the effects at the same + /// time. /// - /// This can be useful when you are working with a failing API but want to deliver its data to an - /// action that handles both success and failure. + /// - Parameter effects: A sequence of effects. + /// - Returns: A new effect + public static func merge(_ effects: S) -> Self where S.Element == Effect { + SignalProducer.merge(effects.map(\.producer)).eraseToEffect() + } + + /// Concatenates a variadic list of effects together into a single effect, which runs the effects + /// one after the other. /// - /// ```swift - /// case .buttonTapped: - /// return fetchUser(id: 1) - /// .catchToEffect() - /// .map(ProfileAction.userResponse) - /// ``` + /// - Parameter effects: A variadic list of effects. + /// - Returns: A new effect + public static func concatenate(_ effects: Self...) -> Self { + .concatenate(effects) + } + + /// Concatenates a collection of effects together into a single effect, which runs the effects one + /// after the other. /// - /// - Returns: An effect that wraps `self`. - public func catchToEffect() -> Effect, Never> { - self.map(Result.success) - .flatMapError { Effect, Never>(value: Result.failure($0)) } + /// - Parameter effects: A collection of effects. + /// - Returns: A new effect + public static func concatenate(_ effects: C) -> Self where C.Element == Effect { + SignalProducer.concatenate(effects.map(\.producer)).eraseToEffect() + } + + /// Transforms all elements from the upstream effect with a provided closure. + /// + /// - Parameter transform: A closure that transforms the upstream effect's output to a new output. + /// - Returns: An effect that uses the provided closure to map elements from the upstream effect + /// to new elements that it then publishes. + public func map(_ transform: @escaping (Output) -> T) -> Effect { + self.producer.map(transform).eraseToEffect() } +} - /// Turns any `SignalProducer` into an ``Effect`` that cannot fail by wrapping its output and failure into - /// result and then applying passed in function to it. +// MARK: - Testing Effects + +extension Effect { + /// An effect that causes a test to fail if it runs. /// - /// This is a convenience operator for writing ``Effect/catchToEffect()`` followed by `map`. + /// This effect can provide an additional layer of certainty that a tested code path does not + /// execute a particular effect. + /// + /// For example, let's say we have a very simple counter application, where a user can increment + /// and decrement a number. The state and actions are simple enough: /// /// ```swift - /// case .buttonTapped: - /// return fetchUser(id: 1) - /// .catchToEffect { - /// switch $0 { - /// case let .success(response): - /// return ProfileAction.updatedUser(response) - /// case let .failure(error): - /// return ProfileAction.failedUserUpdate(error) - /// } - /// } + /// struct CounterState: Equatable { + /// var count = 0 + /// } + /// + /// enum CounterAction: Equatable { + /// case decrementButtonTapped + /// case incrementButtonTapped + /// } /// ``` /// - /// - Parameters: - /// - transform: A mapping function that converts `Result` to another type. - /// - Returns: An effect that wraps `self`. - public func catchToEffect( - _ transform: @escaping (Result) -> T - ) -> Effect { - self - .map { transform(.success($0)) } - .flatMapError { Effect(value: transform(.failure($0))) } - } - - /// Turns any publisher into an ``Effect`` for any output and failure type by ignoring all output - /// and any failure. + /// Let's throw in a side effect. If the user attempts to decrement the counter below zero, the + /// application should refuse and play an alert sound instead. /// - /// This is useful for times you want to fire off an effect but don't want to feed any data back - /// into the system. It can automatically promote an effect to your reducer's domain. + /// We can model playing a sound in the environment with an effect: /// /// ```swift - /// case .buttonTapped: - /// return analyticsClient.track("Button Tapped") - /// .fireAndForget() + /// struct CounterEnvironment { + /// let playAlertSound: () -> Effect + /// } /// ``` /// - /// - Parameters: - /// - outputType: An output type. - /// - failureType: A failure type. - /// - Returns: An effect that never produces output or errors. - public func fireAndForget( - outputType: NewValue.Type = NewValue.self, - failureType: NewError.Type = NewError.self - ) -> Effect { - self.flatMapError { _ in .empty } - .flatMap(.latest) { _ in - .empty - } - } -} - -extension Effect where Self.Error == Never { - - /// Assigns each element from an ``Effect`` to a property on an object. + /// Now that we've defined the domain, we can describe the logic in a reducer: /// - /// - Parameters: - /// - keyPath: The key path of the property to assign. - /// - object: The object on which to assign the value. - /// - Returns: Disposable instance - @discardableResult - public func assign(to keyPath: ReferenceWritableKeyPath, on object: Root) - -> Disposable - { - self.startWithValues { value in - object[keyPath: keyPath] = value + /// ```swift + /// let counterReducer = Reducer< + /// CounterState, CounterAction, CounterEnvironment + /// > { state, action, environment in + /// switch action { + /// case .decrementButtonTapped: + /// if state > 0 { + /// state.count -= 0 + /// return .none + /// } else { + /// return environment.playAlertSound() + /// .fireAndForget() + /// } + /// + /// case .incrementButtonTapped: + /// state.count += 1 + /// return .none + /// } + /// } + /// ``` + /// + /// Let's say we want to write a test for the increment path. We can see in the reducer that it + /// should never play an alert, so we can configure the environment with an effect that will + /// fail if it ever executes: + /// + /// ```swift + /// @MainActor + /// func testIncrement() async { + /// let store = TestStore( + /// initialState: CounterState(count: 0) + /// reducer: counterReducer, + /// environment: CounterEnvironment( + /// playSound: .unimplemented("playSound") + /// ) + /// ) + /// + /// await store.send(.increment) { + /// $0.count = 1 + /// } + /// } + /// ``` + /// + /// By using an `.unimplemented` effect in our environment we have strengthened the assertion and + /// made the test easier to understand at the same time. We can see, without consulting the + /// reducer itself, that this particular action should not access this effect. + /// + /// - Parameter prefix: A string that identifies this scheduler and will prefix all failure + /// messages. + /// - Returns: An effect that causes a test to fail if it runs. + public static func unimplemented(_ prefix: String) -> Self { + .fireAndForget { + XCTFail("\(prefix.isEmpty ? "" : "\(prefix) - ")An unimplemented effect ran.") } } } diff --git a/Sources/ComposableArchitecture/Effects/Animation.swift b/Sources/ComposableArchitecture/Effects/Animation.swift index 8c662cfea..79fdd9a2c 100644 --- a/Sources/ComposableArchitecture/Effects/Animation.swift +++ b/Sources/ComposableArchitecture/Effects/Animation.swift @@ -5,8 +5,6 @@ extension Effect { /// Wraps the emission of each element with SwiftUI's `withAnimation`. /// - /// This publisher is most useful when using with ``Effect/task(priority:operation:)-2czg0`` - /// /// ```swift /// case .buttonTapped: /// return .task { @@ -18,8 +16,8 @@ /// - Parameter animation: An animation. /// - Returns: A publisher. public func animation(_ animation: Animation? = .default) -> Self { - SignalProducer { observer, _ in - self.start { action in + SignalProducer { observer, _ in + self.producer.start { action in switch action { case let .value(value): withAnimation(animation) { @@ -34,6 +32,7 @@ } } } + .eraseToEffect() } } #endif diff --git a/Sources/ComposableArchitecture/Effects/Cancellation.swift b/Sources/ComposableArchitecture/Effects/Cancellation.swift index 8db555c2d..e1980fc5e 100644 --- a/Sources/ComposableArchitecture/Effects/Cancellation.swift +++ b/Sources/ComposableArchitecture/Effects/Cancellation.swift @@ -17,21 +17,20 @@ extension Effect { /// To turn an effect into a cancellable one you must provide an identifier, which is used in /// ``Effect/cancel(id:)-iun1`` to identify which in-flight effect should be canceled. Any /// hashable value can be used for the identifier, such as a string, but you can add a bit of - /// protection against typos by defining a new type for the identifier, or by defining a custom - /// hashable type: + /// protection against typos by defining a new type for the identifier: /// /// ```swift - /// struct LoadUserId: Hashable {} + /// struct LoadUserID {} /// /// case .reloadButtonTapped: /// // Start a new effect to load the user /// return environment.loadUser /// .map(Action.userResponse) - /// .cancellable(id: LoadUserId(), cancelInFlight: true) + /// .cancellable(id: LoadUserID.self, cancelInFlight: true) /// /// case .cancelButtonTapped: /// // Cancel any in-flight requests to load the user - /// return .cancel(id: LoadUserId()) + /// return .cancel(id: LoadUserID.self) /// ``` /// /// - Parameters: @@ -40,7 +39,7 @@ extension Effect { /// canceled before starting this new one. /// - Returns: A new effect that is capable of being canceled by an identifier. public func cancellable(id: AnyHashable, cancelInFlight: Bool = false) -> Self { - Effect.deferred { () -> SignalProducer in + SignalProducer.deferred { () -> SignalProducer in cancellablesLock.lock() defer { cancellablesLock.unlock() } @@ -49,13 +48,14 @@ extension Effect { cancellationCancellables[id]?.forEach { $0.dispose() } } - let subject = Signal.pipe() + let subject = Signal.pipe() - var values: [Value] = [] + var values: [Output] = [] var isCaching = true let disposable = self + .producer .on(value: { guard isCaching else { return } values.append($0) @@ -88,6 +88,7 @@ extension Effect { disposed: cancellationDisposable.dispose ) } + .eraseToEffect() } /// Turns an effect into one that is capable of being canceled. @@ -151,6 +152,96 @@ extension Effect { } } +/// Execute an operation with a cancellation identifier. +/// +/// If the operation is in-flight when `Task.cancel(id:)` is called with the same identifier, the +/// operation will be cancelled. +/// +/// - Parameters: +/// - id: A unique identifier for the operation. +/// - cancelInFlight: Determines if any in-flight operation with the same identifier should be +/// canceled before starting this new one. +/// - operation: An async operation. +/// - Throws: An error thrown by the operation. +/// - Returns: A value produced by operation. +public func withTaskCancellation( + id: AnyHashable, + cancelInFlight: Bool = false, + operation: @Sendable @escaping () async throws -> T +) async rethrows -> T { + let task = { () -> Task in + cancellablesLock.lock() + let id = CancelToken(id: id) + if cancelInFlight { + cancellationCancellables[id]?.forEach { $0.dispose() } + } + let task = Task { try await operation() } + var cancellable: AnyDisposable! + cancellable = AnyDisposable { + task.cancel() + cancellablesLock.sync { + cancellationCancellables[id]?.remove(cancellable) + if cancellationCancellables[id]?.isEmpty == .some(true) { + cancellationCancellables[id] = nil + } + } + } + cancellationCancellables[id, default: []].insert(cancellable) + cancellablesLock.unlock() + return task + }() + do { + return try await task.cancellableValue + } catch { + return try Result.failure(error)._rethrowGet() + } +} + +/// Execute an operation with a cancellation identifier. +/// +/// A convenience for calling ``withTaskCancellation(id:cancelInFlight:operation:)-4dtr6`` with a +/// static type as the operation's unique identifier. +/// +/// - Parameters: +/// - id: A unique type identifying the operation. +/// - cancelInFlight: Determines if any in-flight operation with the same identifier should be +/// canceled before starting this new one. +/// - operation: An async operation. +/// - Throws: An error thrown by the operation. +/// - Returns: A value produced by operation. +public func withTaskCancellation( + id: Any.Type, + cancelInFlight: Bool = false, + operation: @Sendable @escaping () async throws -> T +) async rethrows -> T { + try await withTaskCancellation( + id: ObjectIdentifier(id), + cancelInFlight: cancelInFlight, + operation: operation + ) +} + +extension Task where Success == Never, Failure == Never { + /// Cancel any currently in-flight operation with the given identifier. + /// + /// - Parameter id: An identifier. + public static func cancel(id: AnyHashable) async { + await MainActor.run { + cancellablesLock.sync { cancellationCancellables[.init(id: id)]?.forEach { $0.dispose() } } + } + } + + /// Cancel any currently in-flight operation with the given identifier. + /// + /// A convenience for calling `Task.cancel(id:)` with a static type as the operation's unique + /// identifier. + /// + /// - Parameter id: A unique type identifying the operation. + public static func cancel(id: Any.Type) async { + await self.cancel(id: ObjectIdentifier(id)) + } +} + struct CancelToken: Hashable { let id: AnyHashable let discriminator: ObjectIdentifier @@ -163,3 +254,22 @@ struct CancelToken: Hashable { var cancellationCancellables: [CancelToken: Set] = [:] let cancellablesLock = NSRecursiveLock() + +@rethrows +private protocol _ErrorMechanism { + associatedtype Output + func get() throws -> Output +} + +extension _ErrorMechanism { + internal func _rethrowError() rethrows -> Never { + _ = try _rethrowGet() + fatalError() + } + + internal func _rethrowGet() rethrows -> Output { + return try get() + } +} + +extension Result: _ErrorMechanism {} diff --git a/Sources/ComposableArchitecture/Effects/Concurrency.swift b/Sources/ComposableArchitecture/Effects/Concurrency.swift deleted file mode 100644 index 25362f729..000000000 --- a/Sources/ComposableArchitecture/Effects/Concurrency.swift +++ /dev/null @@ -1,122 +0,0 @@ -import ReactiveSwift - -#if canImport(_Concurrency) && compiler(>=5.5.2) - extension Effect { - /// Wraps an asynchronous unit of work in an effect. - /// - /// This function is useful for executing work in an asynchronous context and capture the - /// result in an ``Effect`` so that the reducer, a non-asynchronous context, can process it. - /// - /// ```swift - /// Effect.task { - /// guard case let .some((data, _)) = try? await URLSession.shared - /// .data(from: .init(string: "http://numbersapi.com/42")!) - /// else { - /// return "Could not load" - /// } - /// - /// return String(decoding: data, as: UTF8.self) - /// } - /// ``` - /// - /// Note that due to the lack of tools to control the execution of asynchronous work in Swift, - /// it is not recommended to use this function in reducers directly. Doing so will introduce - /// thread hops into your effects that will make testing difficult. You will be responsible - /// for adding explicit expectations to wait for small amounts of time so that effects can - /// deliver their output. - /// - /// Instead, this function is most helpful for calling `async`/`await` functions from the live - /// implementation of dependencies, such as `URLSession.data`, `MKLocalSearch.start` and more. - /// - /// - Parameters: - /// - priority: Priority of the underlying task. If `nil`, the priority will come from - /// `Task.currentPriority`. - /// - operation: The operation to execute. - /// - Returns: An effect wrapping the given asynchronous work. - public static func task( - priority: TaskPriority? = nil, - operation: @escaping @Sendable () async -> Value - ) -> Self where Error == Never { - var task: Task? - return .future { callback in - task = Task(priority: priority) { @MainActor in - guard !Task.isCancelled else { return } - let output = await operation() - guard !Task.isCancelled else { return } - callback(.success(output)) - } - } - .on(disposed: { task?.cancel() }) - } - - /// Wraps an asynchronous unit of work in an effect. - /// - /// This function is useful for executing work in an asynchronous context and capture the - /// result in an ``Effect`` so that the reducer, a non-asynchronous context, can process it. - /// - /// ```swift - /// Effect.task { - /// let (data, _) = try await URLSession.shared - /// .data(from: .init(string: "http://numbersapi.com/42")!) - /// - /// return String(decoding: data, as: UTF8.self) - /// } - /// ``` - /// - /// Note that due to the lack of tools to control the execution of asynchronous work in Swift, - /// it is not recommended to use this function in reducers directly. Doing so will introduce - /// thread hops into your effects that will make testing difficult. You will be responsible - /// for adding explicit expectations to wait for small amounts of time so that effects can - /// deliver their output. - /// - /// Instead, this function is most helpful for calling `async`/`await` functions from the live - /// implementation of dependencies, such as `URLSession.data`, `MKLocalSearch.start` and more. - /// - /// - Parameters: - /// - priority: Priority of the underlying task. If `nil`, the priority will come from - /// `Task.currentPriority`. - /// - operation: The operation to execute. - /// - Returns: An effect wrapping the given asynchronous work. - public static func task( - priority: TaskPriority? = nil, - operation: @escaping @Sendable () async throws -> Value - ) -> Self where Error == Swift.Error { - deferred { - var task: Task<(), Never>? - let producer = SignalProducer { observer, lifetime in - task = Task(priority: priority) { @MainActor in - do { - try Task.checkCancellation() - let output = try await operation() - try Task.checkCancellation() - observer.send(value: output) - observer.sendCompleted() - } catch is CancellationError { - observer.sendCompleted() - } catch { - observer.send(error: error) - } - } - } - - return producer.on(disposed: task?.cancel) - } - } - - /// Creates an effect that executes some work in the real world that doesn't need to feed data - /// back into the store. If an error is thrown, the effect will complete and the error will be ignored. - /// - /// - Parameters: - /// - priority: Priority of the underlying task. If `nil`, the priority will come from - /// `Task.currentPriority`. - /// - work: A closure encapsulating some work to execute in the real world. - /// - Returns: An effect. - public static func fireAndForget( - priority: TaskPriority? = nil, - _ work: @escaping @Sendable () async throws -> Void - ) -> Self { - Effect.task(priority: priority) { try? await work() } - .fireAndForget() - } - } -#endif diff --git a/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift b/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift new file mode 100644 index 000000000..5e82ddf0a --- /dev/null +++ b/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift @@ -0,0 +1,373 @@ +#if canImport(_Concurrency) && compiler(>=5.5.2) + extension AsyncStream { + /// Initializes an `AsyncStream` from any `AsyncSequence`. + /// + /// Useful as a type eraser for live `AsyncSequence`-based dependencies. + /// + /// For example, your feature may want to subscribe to screenshot notifications. You can model + /// this in your environment as a dependency returning an `AsyncStream`: + /// + /// ```swift + /// struct ScreenshotsEnvironment { + /// var screenshots: () -> AsyncStream + /// } + /// ``` + /// + /// Your "live" environment can supply a stream by erasing the appropriate + /// `NotificationCenter.Notifications` async sequence: + /// + /// ```swift + /// ScreenshotsEnvironment( + /// screenshots: { + /// AsyncStream( + /// NotificationCenter.default + /// .notifications(named: UIApplication.userDidTakeScreenshotNotification) + /// .map { _ in } + /// ) + /// } + /// ) + /// ``` + /// + /// While your tests can use `AsyncStream.streamWithContinuation` to spin up a controllable stream + /// for tests: + /// + /// ```swift + /// let (stream, continuation) = AsyncStream.streamWithContinuation() + /// + /// let store = TestStore( + /// initialState: ScreenshotsState(), + /// reducer: screenshotsReducer, + /// environment: ScreenshotsEnvironment( + /// screenshots: { stream } + /// ) + /// ) + /// + /// continuation.yield() // Simulate a screenshot being taken. + /// + /// await store.receive(.screenshotTaken) { ... } + /// ``` + /// + /// - Parameters: + /// - sequence: An `AsyncSequence`. + /// - limit: The maximum number of elements to hold in the buffer. By default, this value is + /// unlimited. Use a `Continuation.BufferingPolicy` to buffer a specified number of oldest or + /// newest elements. + public init( + _ sequence: S, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) where S.Element == Element { + self.init(bufferingPolicy: limit) { (continuation: Continuation) in + let task = Task { + do { + for try await element in sequence { + continuation.yield(element) + } + } catch {} + continuation.finish() + } + continuation.onTermination = + { _ in + task.cancel() + } + // NB: This explicit cast is needed to work around a compiler bug in Swift 5.5.2 + as @Sendable (Continuation.Termination) -> Void + } + } + + /// Constructs and returns a stream along with its backing continuation. + /// + /// This is handy for immediately escaping the continuation from an async stream, which typically + /// requires multiple steps: + /// + /// ```swift + /// var _continuation: AsyncStream.Continuation! + /// let stream = AsyncStream { continuation = $0 } + /// let continuation = _continuation! + /// + /// // vs. + /// + /// let (stream, continuation) = AsyncStream.streamWithContinuation() + /// ``` + /// + /// This tool is usually used for tests where we need to supply an async sequence to a dependency + /// endpoint and get access to its continuation so that we can emulate the dependency + /// emitting data. For example, suppose you have a dependency exposing an async sequence for + /// listening to notifications. To test this you can use `streamWithContinuation`: + /// + /// ```swift + /// let notifications = AsyncStream.streamWithContinuation() + /// + /// let store = TestStore( + /// initialState: LongLivingEffectsState(), + /// reducer: longLivingEffectsReducer, + /// environment: LongLivingEffectsEnvironment( + /// notifications: { notifications.stream } + /// ) + /// ) + /// + /// await store.send(.task) + /// notifications.continuation.yield("Hello") + /// await store.receive(.notification("Hello")) { + /// $0.message = "Hello" + /// } + /// ``` + /// + /// > Warning: ⚠️ `AsyncStream` does not support multiple subscribers, therefore you can only use + /// > this helper to test features that do not subscribe multiple times to the dependency + /// > endpoint. + /// + /// - Parameters: + /// - elementType: The type of element the `AsyncStream` produces. + /// - limit: A Continuation.BufferingPolicy value to set the stream’s buffering behavior. By + /// default, the stream buffers an unlimited number of elements. You can also set the policy to + /// buffer a specified number of oldest or newest elements. + /// - Returns: An `AsyncStream`. + public static func streamWithContinuation( + _ elementType: Element.Type = Element.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) -> (stream: Self, continuation: Continuation) { + var continuation: Continuation! + return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) + } + + /// An `AsyncStream` that never emits and never completes unless cancelled. + public static var never: Self { + Self { _ in } + } + + public static var finished: Self { + Self { $0.finish() } + } + } + + extension AsyncThrowingStream where Failure == Error { + /// Initializes an `AsyncStream` from any `AsyncSequence`. + /// + /// - Parameters: + /// - sequence: An `AsyncSequence`. + /// - limit: The maximum number of elements to hold in the buffer. By default, this value is + /// unlimited. Use a `Continuation.BufferingPolicy` to buffer a specified number of oldest or + /// newest elements. + public init( + _ sequence: S, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) where S.Element == Element { + self.init(bufferingPolicy: limit) { (continuation: Continuation) in + let task = Task { + do { + for try await element in sequence { + continuation.yield(element) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = + { _ in + task.cancel() + } + // NB: This explicit cast is needed to work around a compiler bug in Swift 5.5.2 + as @Sendable (Continuation.Termination) -> Void + } + } + + /// Constructs and returns a stream along with its backing continuation. + /// + /// This is handy for immediately escaping the continuation from an async stream, which typically + /// requires multiple steps: + /// + /// ```swift + /// var _continuation: AsyncThrowingStream.Continuation! + /// let stream = AsyncThrowingStream { continuation = $0 } + /// let continuation = _continuation! + /// + /// // vs. + /// + /// let (stream, continuation) = AsyncThrowingStream.streamWithContinuation() + /// ``` + /// + /// This tool is usually used for tests where we need to supply an async sequence to a dependency + /// endpoint and get access to its continuation so that we can emulate the dependency + /// emitting data. For example, suppose you have a dependency exposing an async sequence for + /// listening to notifications. To test this you can use `streamWithContinuation`: + /// + /// ```swift + /// let notifications = AsyncThrowingStream.streamWithContinuation() + /// + /// let store = TestStore( + /// initialState: LongLivingEffectsState(), + /// reducer: longLivingEffectsReducer, + /// environment: LongLivingEffectsEnvironment( + /// notifications: { notifications.stream } + /// ) + /// ) + /// + /// await store.send(.task) + /// notifications.continuation.yield("Hello") + /// await store.receive(.notification("Hello")) { + /// $0.message = "Hello" + /// } + /// ``` + /// + /// > Warning: ⚠️ `AsyncStream` does not support multiple subscribers, therefore you can only use + /// > this helper to test features that do not subscribe multiple times to the dependency + /// > endpoint. + /// + /// - Parameters: + /// - elementType: The type of element the `AsyncThrowingStream` produces. + /// - limit: A Continuation.BufferingPolicy value to set the stream’s buffering behavior. By + /// default, the stream buffers an unlimited number of elements. You can also set the policy to + /// buffer a specified number of oldest or newest elements. + /// - Returns: An `AsyncThrowingStream`. + public static func streamWithContinuation( + _ elementType: Element.Type = Element.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) -> (stream: Self, continuation: Continuation) { + var continuation: Continuation! + return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) + } + + /// An `AsyncThrowingStream` that never emits and never completes unless cancelled. + public static var never: Self { + Self { _ in } + } + + public static var finished: Self { + Self { $0.finish() } + } + } + + extension Task where Failure == Never { + /// An async function that never returns. + public static func never() async throws -> Success { + for await element in AsyncStream.never { + return element + } + throw _Concurrency.CancellationError() + } + } + + extension Task where Success == Never, Failure == Never { + /// An async function that never returns. + public static func never() async throws { + for await _ in AsyncStream.never {} + throw _Concurrency.CancellationError() + } + } + + /// A generic wrapper for isolating a mutable value to an actor. + /// + /// This type is most useful when writing tests for when you want to inspect what happens inside + /// an effect. For example, suppose you have a feature such that when a button is tapped you + /// track some analytics: + /// + /// ```swift + /// let reducer = Reducer { state, action, environment in + /// switch action { + /// case .buttonTapped: + /// return .fireAndForget { try await environment.analytics.track("Button Tapped") } + /// } + /// } + /// ``` + /// + /// Then, in tests we can construct an analytics client that appends events to a mutable array + /// rather than actually sending events to an analytics server. However, in order to do this in + /// a safe way we should use an actor, and ``ActorIsolated`` makes this easy: + /// + /// ```swift + /// func testAnalytics() async { + /// let events = ActorIsolated<[String]>([]) + /// let analytics = AnalyticsClient( + /// track: { event in + /// await events.withValue { $0.append(event) } + /// } + /// ) + /// + /// let store = TestStore( + /// initialState: State(), + /// reducer: reducer, + /// environment: Environment(analytics: analytics) + /// ) + /// + /// await store.send(.buttonTapped) + /// + /// await events.withValue { XCTAssertEqual($0, ["Button Tapped"]) } + /// } + /// ``` + @dynamicMemberLookup + public final actor ActorIsolated { + public var value: Value + + public init(_ value: Value) { + self.value = value + } + + public subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.value[keyPath: keyPath] + } + + /// Perform an operation with isolated access to the underlying value. + /// + /// - Parameters: operation: An operation to be performed on the actor with the underlying value. + /// - Returns: The result of the operation. + public func withValue( + _ operation: @Sendable (inout Value) async throws -> T + ) async rethrows -> T { + var value = self.value + defer { self.value = value } + return try await operation(&value) + } + + /// Overwrite the isolated value with a new value. + /// + /// - Parameter newValue: The value to replace the current isolated value with. + public func setValue(_ newValue: Value) { + self.value = newValue + } + } + + /// A generic wrapper for turning any non-`Sendable` type into a `Sendable` one, in an unchecked + /// manner. + /// + /// Sometimes we need to use types that should be sendable but have not yet been audited for + /// sendability. If we feel confident that the type is truly sendable, and we don't want to blanket + /// disable concurrency warnings for a module via `@precondition import`, then we can selectively + /// make that single type sendable by wrapping it in ``UncheckedSendable``. + /// + /// > Note: By wrapping something in ``UncheckedSendable`` you are asking the compiler to trust + /// you that the type is safe to use from multiple threads, and the compiler cannot help you find + /// potential race conditions in your code. + @dynamicMemberLookup + @propertyWrapper + public struct UncheckedSendable: @unchecked Sendable { + public var value: Value + + public init(_ value: Value) { + self.value = value + } + + public init(wrappedValue: Value) { + self.value = wrappedValue + } + + public var wrappedValue: Value { + _read { yield self.value } + _modify { yield &self.value } + } + + public var projectedValue: Self { + get { self } + set { self = newValue } + } + + public subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.value[keyPath: keyPath] + } + + public subscript(dynamicMember keyPath: WritableKeyPath) -> Subject { + _read { yield self.value[keyPath: keyPath] } + _modify { yield &self.value[keyPath: keyPath] } + } + } +#endif diff --git a/Sources/ComposableArchitecture/Effects/Debouncing.swift b/Sources/ComposableArchitecture/Effects/Debouncing.swift index 2f1d56a09..9df6e18ca 100644 --- a/Sources/ComposableArchitecture/Effects/Debouncing.swift +++ b/Sources/ComposableArchitecture/Effects/Debouncing.swift @@ -12,10 +12,10 @@ extension Effect { /// /// ```swift /// case let .textChanged(text): - /// struct SearchId: Hashable {} + /// enum SearchID {} /// /// return environment.search(text) - /// .debounce(id: SearchId(), for: 0.5, scheduler: environment.mainQueue) + /// .debounce(id: SearchID.self, for: 0.5, scheduler: environment.mainQueue) /// .map(Action.searchResponse) /// ``` /// @@ -29,10 +29,11 @@ extension Effect { for dueTime: TimeInterval, scheduler: DateScheduler ) -> Self { - Effect.init(value: ()) - .promoteError(Error.self) + SignalProducer.init(value: ()) + .promoteError(Failure.self) .delay(dueTime, on: scheduler) - .flatMap(.latest) { self.observe(on: scheduler) } + .flatMap(.latest) { self.producer.observe(on: scheduler) } + .eraseToEffect() .cancellable(id: id, cancelInFlight: true) } diff --git a/Sources/ComposableArchitecture/Effects/Deferring.swift b/Sources/ComposableArchitecture/Effects/Deferring.swift index b04a4223f..7a865a68f 100644 --- a/Sources/ComposableArchitecture/Effects/Deferring.swift +++ b/Sources/ComposableArchitecture/Effects/Deferring.swift @@ -12,7 +12,6 @@ extension Effect { /// ``` /// /// - Parameters: - /// - upstream: the effect you want to defer. /// - dueTime: The duration you want to defer for. /// - scheduler: The scheduler you want to deliver the defer output to. /// - options: Scheduler options that customize the effect's delivery of elements. @@ -21,8 +20,10 @@ extension Effect { for dueTime: TimeInterval, scheduler: DateScheduler ) -> Self { - SignalProducer(value: ()) - .delay(dueTime, on: scheduler) - .flatMap(.latest) { self.observe(on: scheduler) } + .init( + producer: SignalProducer(value: ()) + .delay(dueTime, on: scheduler) + .flatMap(.latest) { self.producer.observe(on: scheduler) } + ) } } diff --git a/Sources/ComposableArchitecture/Effects/SignalProducer.swift b/Sources/ComposableArchitecture/Effects/SignalProducer.swift new file mode 100644 index 000000000..fe18d1412 --- /dev/null +++ b/Sources/ComposableArchitecture/Effects/SignalProducer.swift @@ -0,0 +1,640 @@ +import ReactiveSwift + +extension Effect { + /// Initializes an effect that wraps a producer. + /// + /// > Important: This ReactiveSwift interface has been soft-deprecated in favor of Swift concurrency. + /// > Prefer performing asynchronous work directly in + /// > ``Effect/run(priority:operation:catch:file:fileID:line:)`` by adopting a non-ReactiveSwift + /// > interface, or by iterating over the producer's asynchronous sequence of `values`: + /// > + /// > ```swift + /// > return .run { send in + /// > for await value in producer.values { + /// > send(.response(value)) + /// > } + /// > } + /// > ``` + /// + /// - Parameter producer: A `SignalProducer`. + @available( + iOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + macOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + tvOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + watchOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + public init(_ producer: P) + where P.Value == Output, P.Error == Failure { + self.producer = producer.producer + } + + /// Initializes an effect that immediately emits the value passed in. + /// + /// - Parameter value: The value that is immediately emitted by the effect. + @available(iOS, deprecated: 9999.0, message: "Wrap the value in 'Effect.task', instead.") + @available(macOS, deprecated: 9999.0, message: "Wrap the value in 'Effect.task', instead.") + @available(tvOS, deprecated: 9999.0, message: "Wrap the value in 'Effect.task', instead.") + @available(watchOS, deprecated: 9999.0, message: "Wrap the value in 'Effect.task', instead.") + public init(value: Output) { + self.init(SignalProducer(value: value)) + } + + /// Initializes an effect that immediately fails with the error passed in. + /// + /// - Parameter error: The error that is immediately emitted by the effect. + @available( + iOS, deprecated: 9999.0, + message: "Throw and catch errors directly in 'Effect.task' and 'Effect.run', instead." + ) + @available( + macOS, deprecated: 9999.0, + message: "Throw and catch errors directly in 'Effect.task' and 'Effect.run', instead." + ) + @available( + tvOS, deprecated: 9999.0, + message: "Throw and catch errors directly in 'Effect.task' and 'Effect.run', instead." + ) + @available( + watchOS, deprecated: 9999.0, + message: "Throw and catch errors directly in 'Effect.task' and 'Effect.run', instead." + ) + public init(error: Failure) { + // NB: Ideally we'd return a `Fail` producer here, but due to a bug in iOS 13 that producer + // can crash when used with certain combinations of operators such as `.retry.catch`. The + // bug was fixed in iOS 14, but to remain compatible with iOS 13 and higher we need to do + // a little trickery to fail in a slightly different way. + self.init(SignalProducer(error: error)) + } + + /// Creates an effect that can supply a single value asynchronously in the future. + /// + /// This can be helpful for converting APIs that are callback-based into ones that deal with + /// ``Effect``s. + /// + /// For example, to create an effect that delivers an integer after waiting a second: + /// + /// ```swift + /// Effect.future { callback in + /// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + /// callback(.success(42)) + /// } + /// } + /// ``` + /// + /// Note that you can only deliver a single value to the `callback`. If you send more they will be + /// discarded: + /// + /// ```swift + /// Effect.future { callback in + /// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + /// callback(.success(42)) + /// callback(.success(1729)) // Will not be emitted by the effect + /// } + /// } + /// ``` + /// + /// - Parameter attemptToFulfill: A closure that takes a `callback` as an argument which can be + /// used to feed it `Result` values. + @available(iOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.") + @available(macOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.") + @available(tvOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.") + @available(watchOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.") + public static func future( + _ attemptToFulfill: @escaping (@escaping (Result) -> Void) -> Void + ) -> Self { + self.init( + producer: SignalProducer { observer, _ in + attemptToFulfill { result in + switch result { + case let .success(value): + observer.send(value: value) + observer.sendCompleted() + case let .failure(error): + observer.send(error: error) + } + } + } + ) + } + + /// Initializes an effect that lazily executes some work in the real world and synchronously sends + /// that data back into the store. + /// + /// For example, to load a user from some JSON on the disk, one can wrap that work in an effect: + /// + /// ```swift + /// Effect.result { + /// let fileUrl = URL( + /// fileURLWithPath: NSSearchPathForDirectoriesInDomains( + /// .documentDirectory, .userDomainMask, true + /// )[0] + /// ) + /// .appendingPathComponent("user.json") + /// + /// let result = Result { + /// let data = try Data(contentsOf: fileUrl) + /// return try JSONDecoder().decode(User.self, from: $0) + /// } + /// + /// return result + /// } + /// ``` + /// + /// - Parameter attemptToFulfill: A closure encapsulating some work to execute in the real world. + /// - Returns: An effect. + @available(iOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.") + @available(macOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.") + @available(tvOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.") + @available(watchOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.") + public static func result(_ attemptToFulfill: @escaping () -> Result) -> Self { + Effect( + producer: SignalProducer { () -> Result in + attemptToFulfill() + } + ) + } + + /// Initializes an effect from a callback that can send as many values as it wants, and can send + /// a completion. + /// + /// This initializer is useful for bridging callback APIs, delegate APIs, and manager APIs to the + /// ``Effect`` type. One can wrap those APIs in an Effect so that its events are sent through the + /// effect, which allows the reducer to handle them. + /// + /// For example, one can create an effect to ask for access to `MPMediaLibrary`. It can start by + /// sending the current status immediately, and then if the current status is `notDetermined` it + /// can request authorization, and once a status is received it can send that back to the effect: + /// + /// ```swift + /// Effect.run { observer in + /// observer.send(value: MPMediaLibrary.authorizationStatus()) + /// + /// guard MPMediaLibrary.authorizationStatus() == .notDetermined else { + /// observer.sendCompleted() + /// return AnyDisposable {} + /// } + /// + /// MPMediaLibrary.requestAuthorization { status in + /// observer.send(value: status) + /// observer.sendCompleted() + /// } + /// return AnyDisposable { + /// // Typically clean up resources that were created here, but this effect doesn't + /// // have any. + /// } + /// } + /// ``` + /// + /// - Parameter work: A closure that accepts a ``Signal.Observer`` value and returns a disposable. + /// When the ``Effect`` is completed, the disposable will be used to clean up any resources + /// created when the effect was started. + @available(iOS, deprecated: 9999.0, message: "Use the async version of 'Effect.run', instead.") + @available(macOS, deprecated: 9999.0, message: "Use the async version of 'Effect.run', instead.") + @available(tvOS, deprecated: 9999.0, message: "Use the async version of 'Effect.run', instead.") + @available( + watchOS, deprecated: 9999.0, message: "Use the async version of 'Effect.run', instead." + ) + public static func run( + _ work: @escaping (Signal.Observer) -> Disposable + ) -> Self { + SignalProducer { observer, lifetime in + lifetime += work(observer) + } + .eraseToEffect() + } + + /// Creates an effect that executes some work in the real world that doesn't need to feed data + /// back into the store. If an error is thrown, the effect will complete and the error will be + /// ignored. + /// + /// - Parameter work: A closure encapsulating some work to execute in the real world. + /// - Returns: An effect. + @available(iOS, deprecated: 9999.0, message: "Use the async version, instead.") + @available(macOS, deprecated: 9999.0, message: "Use the async version, instead.") + @available(tvOS, deprecated: 9999.0, message: "Use the async version, instead.") + @available(watchOS, deprecated: 9999.0, message: "Use the async version, instead.") + public static func fireAndForget(_ work: @escaping () throws -> Void) -> Self { + + SignalProducer { observer, lifetime in + try? work() + observer.sendCompleted() + } + .eraseToEffect() + } +} + +extension Effect where Failure == Error { + /// Initializes an effect that lazily executes some work in the real world and synchronously sends + /// that data back into the store. + /// + /// For example, to load a user from some JSON on the disk, one can wrap that work in an effect: + /// + /// ```swift + /// Effect.catching { + /// let fileUrl = URL( + /// fileURLWithPath: NSSearchPathForDirectoriesInDomains( + /// .documentDirectory, .userDomainMask, true + /// )[0] + /// ) + /// .appendingPathComponent("user.json") + /// + /// let data = try Data(contentsOf: fileUrl) + /// return try JSONDecoder().decode(User.self, from: $0) + /// } + /// ``` + /// + /// - Parameter work: A closure encapsulating some work to execute in the real world. + /// - Returns: An effect. + @available( + iOS, deprecated: 9999.0, + message: "Throw and catch errors directly in 'Effect.task' and 'Effect.run', instead." + ) + @available( + macOS, deprecated: 9999.0, + message: "Throw and catch errors directly in 'Effect.task' and 'Effect.run', instead." + ) + @available( + tvOS, deprecated: 9999.0, + message: "Throw and catch errors directly in 'Effect.task' and 'Effect.run', instead." + ) + @available( + watchOS, deprecated: 9999.0, + message: "Throw and catch errors directly in 'Effect.task' and 'Effect.run', instead." + ) + public static func catching(_ work: @escaping () throws -> Output) -> Self { + .future { $0(Result { try work() }) } + } +} + +extension Effect { + + /// Turns any effect into an ``Effect`` for any output and failure type by ignoring all output + /// and any failure. + /// + /// This is useful for times you want to fire off an effect but don't want to feed any data back + /// into the system. It can automatically promote an effect to your reducer's domain. + /// + /// ```swift + /// case .buttonTapped: + /// return analyticsClient.track("Button Tapped") + /// .fireAndForget() + /// ``` + /// + /// - Parameters: + /// - outputType: An output type. + /// - failureType: A failure type. + /// - Returns: An effect that never produces output or errors. + @available(iOS, deprecated: 9999.0, message: "Use the static async version, instead.") + @available(macOS, deprecated: 9999.0, message: "Use the static async version, instead.") + @available(tvOS, deprecated: 9999.0, message: "Use the static async version, instead.") + @available(watchOS, deprecated: 9999.0, message: "Use the static async version, instead.") + public func fireAndForget( + outputType: NewValue.Type = NewValue.self, + failureType: NewError.Type = NewError.self + ) -> Effect { + self + .producer + .flatMapError { _ in .empty } + .flatMap(.latest) { _ in .empty } + .eraseToEffect() + } +} + +extension SignalProducer { + /// Turns any producer into an ``Effect``. + /// + /// This can be useful for when you perform a chain of producer transformations in a reducer, and + /// you need to convert that producer to an effect so that you can return it from the reducer: + /// + /// ```swift + /// case .buttonTapped: + /// return fetchUser(id: 1) + /// .filter(\.isAdmin) + /// .eraseToEffect() + /// ``` + /// + /// - Returns: An effect that wraps `self`. + @available( + iOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + macOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + tvOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + watchOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + public func eraseToEffect() -> Effect { + Effect(self) + } + + /// Turns any producer into an ``Effect``. + /// + /// This is a convenience operator for writing ``Effect/eraseToEffect()`` followed by + /// ``Effect/map(_:)-28ghh`. + /// + /// ```swift + /// case .buttonTapped: + /// return fetchUser(id: 1) + /// .filter(\.isAdmin) + /// .eraseToEffect(ProfileAction.adminUserFetched) + /// ``` + /// + /// - Parameters: + /// - transform: A mapping function that converts `Value` to another type. + /// - Returns: An effect that wraps `self` after mapping `Value` values. + @available( + iOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + macOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + tvOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + watchOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + public func eraseToEffect( + _ transform: @escaping (Value) -> T + ) -> Effect { + self.map(transform) + .eraseToEffect() + } + + /// Turns any producer into an ``Effect`` that cannot fail by wrapping its output and failure in + /// a result. + /// + /// This can be useful when you are working with a failing API but want to deliver its data to an + /// action that handles both success and failure. + /// + /// ```swift + /// case .buttonTapped: + /// return environment.fetchUser(id: 1) + /// .catchToEffect() + /// .map(ProfileAction.userResponse) + /// ``` + /// + /// - Returns: An effect that wraps `self`. + @available( + iOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + macOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + tvOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + watchOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + public func catchToEffect() -> Effect, Never> { + self + .map(Result.success) + .flatMapError { SignalProducer, Never>(value: .failure($0)) } + .eraseToEffect() + } + + /// Turns any producer into an ``Effect`` that cannot fail by wrapping its output and failure + /// into a result and then applying passed in function to it. + /// + /// This is a convenience operator for writing ``Effect/eraseToEffect()`` followed by + /// ``Effect/map(_:)-28ghh`. + /// + /// ```swift + /// case .buttonTapped: + /// return environment.fetchUser(id: 1) + /// .catchToEffect(ProfileAction.userResponse) + /// ``` + /// + /// - Parameters: + /// - transform: A mapping function that converts `Result` to another type. + /// - Returns: An effect that wraps `self`. + @available( + iOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + macOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + tvOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + @available( + watchOS, deprecated: 9999.0, + message: "Iterate over 'SignalProducer.values' in an 'Effect.run', instead." + ) + public func catchToEffect( + _ transform: @escaping (Result) -> T + ) -> Effect { + self + .map { transform(.success($0)) } + .flatMapError { SignalProducer(value: transform(.failure($0))) } + .eraseToEffect() + } + + /// Turns any producer into an ``Effect`` for any output and failure type by ignoring all output + /// and any failure. + /// + /// This is useful for times you want to fire off an effect but don't want to feed any data back + /// into the system. It can automatically promote an effect to your reducer's domain. + /// + /// ```swift + /// case .buttonTapped: + /// return analyticsClient.track("Button Tapped") + /// .fireAndForget() + /// ``` + /// + /// - Parameters: + /// - outputType: An output type. + /// - failureType: A failure type. + /// - Returns: An effect that never produces output or errors. + @available( + iOS, deprecated: 9999.0, + message: + "Iterate over 'SignalProducer.values' in the static version of 'Effect.fireAndForget', instead." + ) + @available( + macOS, deprecated: 9999.0, + message: + "Iterate over 'SignalProducer.values' in the static version of 'Effect.fireAndForget', instead." + ) + @available( + tvOS, deprecated: 9999.0, + message: + "Iterate over 'SignalProducer.values' in the static version of 'Effect.fireAndForget', instead." + ) + @available( + watchOS, deprecated: 9999.0, + message: + "Iterate over 'SignalProducer.values' in the static version of 'Effect.fireAndForget', instead." + ) + public func fireAndForget( + outputType: NewValue.Type = NewValue.self, + failureType: NewError.Type = NewError.self + ) -> Effect { + self + .flatMapError { _ in .empty } + .flatMap(.latest) { _ in .empty } + .eraseToEffect() + } +} + +extension SignalProducer where Self.Error == Never { + + /// Assigns each element from a ``SignalProducer`` to a property on an object. + /// + /// - Parameters: + /// - keyPath: The key path of the property to assign. + /// - object: The object on which to assign the value. + /// - Returns: Disposable instance + @discardableResult + public func assign(to keyPath: ReferenceWritableKeyPath, on object: Root) + -> Disposable + { + self.startWithValues { value in + object[keyPath: keyPath] = value + } + } +} + +extension SignalProducer { + + /// A ``SignalProducer`` that waits until it is started before running + /// the supplied closure to create a new ``SignalProducer``, whose values + /// are then sent to the subscriber of this effect. + public static func deferred(_ createProducer: @escaping () -> Self) -> Self { + SignalProducer(value: ()) + .flatMap(.merge, createProducer) + } + + /// Concatenates a variadic list of producers together into a single producer, which runs the producers + /// one after the other. + /// + /// - Parameter effects: A variadic list of producers. + /// - Returns: A new producer + public static func concatenate(_ producers: Self...) -> Self { + .concatenate(producers) + } + + /// Concatenates a collection of producers together into a single effect, which runs the producers one + /// after the other. + /// + /// - Parameter effects: A collection of producers. + /// - Returns: A new producer + public static func concatenate(_ producers: C) -> Self where C.Element == Self { + guard let first = producers.first else { return .empty } + + return + producers + .dropFirst() + .reduce(into: first) { producers, producer in + producers = producers.concat(producer) + } + } + + /// Creates a producer that executes some work in the real world that doesn't need to feed data + /// back into the store. If an error is thrown, the producer will complete and the error will be + /// ignored. + /// + /// - Parameter work: A closure encapsulating some work to execute in the real world. + /// - Returns: An effect. + @available( + iOS, deprecated: 9999.0, + message: "Use the static async version of 'Effect.fireAndForget', instead." + ) + @available( + macOS, deprecated: 9999.0, + message: "Use the static async version of 'Effect.fireAndForget', instead." + ) + @available( + tvOS, deprecated: 9999.0, + message: "Use the static async version of 'Effect.fireAndForget', instead." + ) + @available( + watchOS, deprecated: 9999.0, + message: "Use the static async version of 'Effect.fireAndForget', instead." + ) + public static func fireAndForget(_ work: @escaping () throws -> Void) -> Self { + + SignalProducer { observer, lifetime in + try? work() + observer.sendCompleted() + } + } +} + +// Credits to @Marcocanc, heavily inspired by: +// https://github.com/ReactiveCocoa/ReactiveSwift/tree/swift-concurrency +// https://github.com/ReactiveCocoa/ReactiveSwift/pull/847 +#if canImport(_Concurrency) && compiler(>=5.5.2) + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, macCatalyst 13, *) + extension SignalProducerConvertible { + public var values: AsyncThrowingStream { + AsyncThrowingStream { continuation in + let disposable = producer.start { event in + switch event { + case .value(let value): + continuation.yield(value) + case .completed, + .interrupted: + continuation.finish() + case .failed(let error): + continuation.finish(throwing: error) + } + } + continuation.onTermination = { @Sendable _ in + disposable.dispose() + } + } + } + } + + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, macCatalyst 13, *) + extension SignalProducerConvertible where Error == Never { + public var values: AsyncStream { + AsyncStream { continuation in + let disposable = producer.start { event in + switch event { + case .value(let value): + continuation.yield(value) + case .completed, + .interrupted: + continuation.finish() + case .failed: + fatalError("Never is impossible to construct") + } + } + continuation.onTermination = { @Sendable _ in + disposable.dispose() + } + } + } + } +#endif diff --git a/Sources/ComposableArchitecture/Effects/TaskResult.swift b/Sources/ComposableArchitecture/Effects/TaskResult.swift new file mode 100644 index 000000000..fa81f96d6 --- /dev/null +++ b/Sources/ComposableArchitecture/Effects/TaskResult.swift @@ -0,0 +1,307 @@ +#if canImport(_Concurrency) && compiler(>=5.5.2) + import XCTestDynamicOverlay + + /// A value that represents either a success or a failure. This type differs from Swift's `Result` + /// type in that it uses only one generic for the success case, leaving the failure case as an + /// untyped `Error`. + /// + /// This type is needed because Swift's concurrency tools can only express untyped errors, such as + /// `async` functions and `AsyncSequence`, and so their output can realistically only be bridged to + /// `Result<_, Error>`. However, `Result<_, Error>` is never `Equatable` since `Error` is not + /// `Equatable`, and equatability is very important for testing in the Composable Architecture. By + /// defining our own type we get the ability to recover equatability in most situations. + /// + /// If someday Swift gets typed `throws`, then we can eliminate this type and rely solely on + /// `Result`. + /// + /// You typically use this type as the payload of an action which receives a response from an + /// effect: + /// + /// ```swift + /// enum FeatureAction: Equatable { + /// case factButtonTapped + /// case factResponse(TaskResult) + /// } + /// ``` + /// + /// Then you can model your dependency as using simple `async` and `throws` functionality: + /// + /// ```swift + /// struct FeatureEnvironment { + /// var numberFact: (Int) async throws -> String + /// } + /// ``` + /// + /// And finally you can use ``Effect/task(priority:operation:catch:file:fileID:line:)`` to construct + /// an effect in the reducer that invokes the `numberFact` endpoint and wraps its response in a + /// ``TaskResult`` by using its catching initializer, ``TaskResult/init(catching:)``: + /// + /// ```swift + /// case .factButtonTapped: + /// return .task { + /// await .factResponse( + /// TaskResult { try await environment.numberFact(state.number) } + /// ) + /// } + /// + /// case .factResponse(.success(fact)): + /// // do something with fact + /// + /// case .factResponse(.failure): + /// // handle error + /// + /// ... + /// } + /// ``` + /// + /// ## Equality + /// + /// The biggest downside to using an untyped `Error` in a result type is that the result will not + /// be equatable even if the success type is. This negatively affects your ability to test features + /// that use ``TaskResult`` in their actions with the ``TestStore``. + /// + /// ``TaskResult`` does extra work to try to maintain equatability when possible. If the underlying + /// type masked by the `Error` is `Equatable`, then it will use that `Equatable` conformance + /// on two failures. Luckily, most errors thrown by Apple's frameworks are already equatable, and + /// because errors are typically simple value types, it is usually possible to have the compiler + /// synthesize a conformance for you. + /// + /// If you are testing the unhappy path of a feature that feeds a ``TaskResult`` back into the + /// system, be sure to conform the error to equatable, or the test will fail: + /// + /// ```swift + /// // Set up a failing dependency + /// struct RefreshFailure: Error {} + /// store.environment.apiClient.fetchFeed = { throw RefreshFailure() } + /// + /// // Simulate pull-to-refresh + /// store.send(.refresh) { $0.isLoading = true } + /// + /// // Assert against failure + /// await store.receive(.refreshResponse(.failure(RefreshFailure())) { // ❌ + /// $0.errorLabelText = "An error occurred." + /// $0.isLoading = false + /// } + /// // ❌ 'RefreshFailure' is not equatable + /// ``` + /// + /// To get a passing test, explicitly conform your custom error to the `Equatable` protocol: + /// + /// ```swift + /// // Set up a failing dependency + /// struct RefreshFailure: Error, Equatable {} // 👈 + /// store.environment.apiClient.fetchFeed = { throw RefreshFailure() } + /// + /// // Simulate pull-to-refresh + /// store.send(.refresh) { $0.isLoading = true } + /// + /// // Assert against failure + /// await store.receive(.refreshResponse(.failure(RefreshFailure())) { // ✅ + /// $0.errorLabelText = "An error occurred." + /// $0.isLoading = false + /// } + /// ``` + public enum TaskResult: Sendable { + /// A success, storing a `Success` value. + case success(Success) + + /// A failure, storing an error. + case failure(Error) + + /// Creates a new task result by evaluating an async throwing closure, capturing the returned + /// value as a success, or any thrown error as a failure. + /// + /// This initializer is most often used in an async effect being returned from a reducer. See the + /// documentation for ``TaskResult`` for a concrete example. + /// + /// - Parameter body: An async, throwing closure. + @_transparent + public init(catching body: @Sendable () async throws -> Success) async { + do { + self = .success(try await body()) + } catch { + self = .failure(error) + } + } + + /// Transforms a `Result` into a `TaskResult`, erasing its `Failure` to `Error`. + /// + /// - Parameter result: A result. + @inlinable + public init(_ result: Result) { + switch result { + case let .success(value): + self = .success(value) + case let .failure(error): + self = .failure(error) + } + } + + /// Returns the success value as a throwing property. + @inlinable + public var value: Success { + get throws { + switch self { + case let .success(value): + return value + case let .failure(error): + throw error + } + } + } + + /// Returns a new task result, mapping any success value using the given transformation. + /// + /// Like `map` on `Result`, `Optional`, and many other types. + /// + /// - Parameter transform: A closure that takes the success value of this instance. + /// - Returns: A `TaskResult` instance with the result of evaluating `transform` as the new + /// success value if this instance represents a success. + @inlinable + public func map(_ transform: (Success) -> NewSuccess) -> TaskResult { + switch self { + case let .success(value): + return .success(transform(value)) + case let .failure(error): + return .failure(error) + } + } + + /// Returns a new task result, mapping any success value using the given transformation and + /// unwrapping the produced result. + /// + /// Like `flatMap` on `Result`, `Optional`, and many other types. + /// + /// - Parameter transform: A closure that takes the success value of the instance. + /// - Returns: A `TaskResult` instance, either from the closure or the previous `.failure`. + @inlinable + public func flatMap( + _ transform: (Success) -> TaskResult + ) -> TaskResult { + switch self { + case let .success(value): + return transform(value) + case let .failure(error): + return .failure(error) + } + } + } + + extension Result where Success: Sendable, Failure == Swift.Error { + /// Transforms a `TaskResult` into a `Result`. + /// + /// - Parameter result: A task result. + @inlinable + public init(_ result: TaskResult) { + switch result { + case let .success(value): + self = .success(value) + case let .failure(error): + self = .failure(error) + } + } + } + + enum TaskResultDebugging { + @TaskLocal static var emitRuntimeWarnings = true + } + + extension TaskResult: Equatable where Success: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.success(lhs), .success(rhs)): + return lhs == rhs + case let (.failure(lhs), .failure(rhs)): + return _isEqual(lhs, rhs) ?? { + #if DEBUG + if TaskResultDebugging.emitRuntimeWarnings, type(of: lhs) == type(of: rhs) { + runtimeWarning( + """ + '%1$@' is not equatable. … + + To test two values of this type, it must conform to the 'Equatable' protocol. For \ + example: + + extension %1$@: Equatable {} + + See the documentation of 'TaskResult' for more information. + """, + [ + "\(type(of: lhs))", + ] + ) + } + #endif + return false + }() + default: + return false + } + } + } + + extension TaskResult: Hashable where Success: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case let .success(value): + hasher.combine(value) + hasher.combine(0) + case let .failure(error): + if let error = (error as Any) as? AnyHashable { + hasher.combine(error) + hasher.combine(1) + } else { + #if DEBUG + if TaskResultDebugging.emitRuntimeWarnings { + runtimeWarning( + """ + '%1$@' is not hashable. … + + To hash a value of this type, it must conform to the 'Hashable' protocol. For example: + + extension %1$@: Hashable {} + + See the documentation of 'TaskResult' for more information. + """, + [ + "\(type(of: error))", + ] + ) + } + #endif + } + } + } + } + + extension TaskResult { + // NB: For those that try to interface with `TaskResult` using `Result`'s old API. + @available(*, unavailable, renamed: "value") + public func get() throws -> Success { + try self.value + } + } + + private enum _Witness {} + + private protocol _AnyEquatable { + static func _isEqual(_ lhs: Any, _ rhs: Any) -> Bool + } + + extension _Witness: _AnyEquatable where A: Equatable { + static func _isEqual(_ lhs: Any, _ rhs: Any) -> Bool { + guard + let lhs = lhs as? A, + let rhs = rhs as? A + else { return false } + return lhs == rhs + } + } + + private func _isEqual(_ a: Any, _ b: Any) -> Bool? { + func `do`(_: A.Type) -> Bool? { + (_Witness.self as? _AnyEquatable.Type)?._isEqual(a, b) + } + return _openExistential(type(of: a), do: `do`) + } +#endif diff --git a/Sources/ComposableArchitecture/Effects/Throttling.swift b/Sources/ComposableArchitecture/Effects/Throttling.swift index 3ce41b998..321d88e51 100644 --- a/Sources/ComposableArchitecture/Effects/Throttling.swift +++ b/Sources/ComposableArchitecture/Effects/Throttling.swift @@ -20,18 +20,19 @@ extension Effect { scheduler: DateScheduler, latest: Bool ) -> Self { - self.observe(on: scheduler) - .flatMap(.latest) { value -> Effect in + self.producer + .observe(on: scheduler) + .flatMap(.latest) { value -> SignalProducer in throttleLock.lock() defer { throttleLock.unlock() } guard let throttleTime = throttleTimes[id] as! Date? else { throttleTimes[id] = scheduler.currentDate throttleValues[id] = nil - return Effect(value: value) + return SignalProducer(value: value) } - let value = latest ? value : (throttleValues[id] as! Value? ?? value) + let value = latest ? value : (throttleValues[id] as! Output? ?? value) throttleValues[id] = value guard @@ -40,10 +41,10 @@ extension Effect { else { throttleTimes[id] = scheduler.currentDate throttleValues[id] = nil - return Effect(value: value) + return SignalProducer(value: value) } - return Effect(value: value) + return SignalProducer(value: value) .delay( throttleTime.addingTimeInterval(interval).timeIntervalSince1970 - scheduler.currentDate.timeIntervalSince1970, @@ -57,6 +58,7 @@ extension Effect { } ) } + .eraseToEffect() .cancellable(id: id, cancelInFlight: true) } diff --git a/Sources/ComposableArchitecture/Effects/Timer.swift b/Sources/ComposableArchitecture/Effects/Timer.swift index 7b8f8f9ab..ae05947d6 100644 --- a/Sources/ComposableArchitecture/Effects/Timer.swift +++ b/Sources/ComposableArchitecture/Effects/Timer.swift @@ -1,7 +1,7 @@ import Foundation import ReactiveSwift -extension Effect where Value == Date, Error == Never { +extension Effect where Output == Date, Failure == Never { /// Returns an effect that repeatedly emits the current time of the given scheduler on the given /// interval. /// @@ -15,65 +15,66 @@ extension Effect where Value == Date, Error == Never { /// To start and stop a timer in your feature you can create the timer effect from an action /// and then use the ``Effect/cancel(id:)-iun1`` effect to stop the timer: /// - /// ```swift - /// struct AppState { - /// var count = 0 - /// } + /// ```swift + /// struct AppState { + /// var count = 0 + /// } /// - /// enum AppAction { - /// case startButtonTapped, stopButtonTapped, timerTicked - /// } + /// enum AppAction { + /// case startButtonTapped, stopButtonTapped, timerTicked + /// } /// - /// struct AppEnvironment { - /// var mainQueue: AnySchedulerOf - /// } + /// struct AppEnvironment { + /// var mainQueue: AnySchedulerOf + /// } /// - /// let appReducer = Reducer { state, action, env in - /// struct TimerId: Hashable {} + /// let appReducer = Reducer { state, action, env in + /// struct TimerID: Hashable {} /// - /// switch action { - /// case .startButtonTapped: - /// return Effect.timer(id: TimerId(), every: 1, on: env.mainQueue) - /// .map { _ in .timerTicked } + /// switch action { + /// case .startButtonTapped: + /// return Effect.timer(id: TimerID(), every: 1, on: env.mainQueue) + /// .map { _ in .timerTicked } /// - /// case .stopButtonTapped: - /// return .cancel(id: TimerId()) + /// case .stopButtonTapped: + /// return .cancel(id: TimerID()) /// - /// case let .timerTicked: - /// state.count += 1 - /// return .none - /// } - /// ``` + /// case let .timerTicked: + /// state.count += 1 + /// return .none + /// } + /// ``` /// /// Then to test the timer in this feature you can use a test scheduler to advance time: /// - /// ```swift - /// func testTimer() { - /// let mainQueue = TestScheduler() + /// ```swift + /// @MainActor + /// func testTimer() async { + /// let mainQueue = TestScheduler() /// - /// let store = TestStore( - /// initialState: .init(), - /// reducer: appReducer, + /// let store = TestStore( + /// initialState: .init(), + /// reducer: appReducer, /// environment: .init( - /// mainQueue: mainQueue - /// ) - /// ) + /// mainQueue: mainQueue + /// ) + /// ) /// - /// store.send(.startButtonTapped) + /// await store.send(.startButtonTapped) /// - /// mainQueue.advance(by: 1) - /// store.receive(.timerTicked) { $0.count = 1 } + /// await mainQueue.advance(by: .seconds(1)) + /// await store.receive(.timerTicked) { $0.count = 1 } /// - /// mainQueue.advance(by: 5) - /// store.receive(.timerTicked) { $0.count = 2 } - /// store.receive(.timerTicked) { $0.count = 3 } - /// store.receive(.timerTicked) { $0.count = 4 } - /// store.receive(.timerTicked) { $0.count = 5 } - /// store.receive(.timerTicked) { $0.count = 6 } + /// await mainQueue.advance(by: .seconds(5)) + /// await store.receive(.timerTicked) { $0.count = 2 } + /// await store.receive(.timerTicked) { $0.count = 3 } + /// await store.receive(.timerTicked) { $0.count = 4 } + /// await store.receive(.timerTicked) { $0.count = 5 } + /// await store.receive(.timerTicked) { $0.count = 6 } /// - /// store.send(.stopButtonTapped) - /// } - /// ``` + /// await store.send(.stopButtonTapped) + /// } + /// ``` /// /// - Parameters: /// - id: The effect's identifier. @@ -89,10 +90,11 @@ extension Effect where Value == Date, Error == Never { tolerance: DispatchTimeInterval? = nil, on scheduler: DateScheduler ) -> Self { - return SignalProducer.timer( - interval: interval, on: scheduler, leeway: tolerance ?? .seconds(.max) - ) - .cancellable(id: id, cancelInFlight: true) + return + SignalProducer + .timer(interval: interval, on: scheduler, leeway: tolerance ?? .seconds(.max)) + .eraseToEffect() + .cancellable(id: id, cancelInFlight: true) } /// Returns an effect that repeatedly emits the current time of the given scheduler on the given diff --git a/Sources/ComposableArchitecture/Internal/Box.swift b/Sources/ComposableArchitecture/Internal/Box.swift new file mode 100644 index 000000000..e3fec0ac4 --- /dev/null +++ b/Sources/ComposableArchitecture/Internal/Box.swift @@ -0,0 +1,12 @@ +final class Box { + var wrappedValue: Wrapped + + init(wrappedValue: Wrapped) { + self.wrappedValue = wrappedValue + } + + var boxedValue: Wrapped { + _read { yield self.wrappedValue } + _modify { yield &self.wrappedValue } + } +} diff --git a/Sources/ComposableArchitecture/Internal/Debug.swift b/Sources/ComposableArchitecture/Internal/Debug.swift index 82a4a3a7c..93caf38b7 100644 --- a/Sources/ComposableArchitecture/Internal/Debug.swift +++ b/Sources/ComposableArchitecture/Internal/Debug.swift @@ -1,11 +1,6 @@ import CustomDump import Foundation -#if os(Linux) || os(Android) - import let CDispatch.NSEC_PER_USEC - import let CDispatch.NSEC_PER_SEC -#endif - extension String { func indent(by indent: Int) -> String { let indentation = String(repeating: " ", count: indent) diff --git a/Sources/ComposableArchitecture/Internal/Deprecations.swift b/Sources/ComposableArchitecture/Internal/Deprecations.swift index d914c7e0b..8df059cec 100644 --- a/Sources/ComposableArchitecture/Internal/Deprecations.swift +++ b/Sources/ComposableArchitecture/Internal/Deprecations.swift @@ -1,4 +1,5 @@ import CasePaths +import ReactiveSwift import XCTestDynamicOverlay #if canImport(SwiftUI) @@ -11,6 +12,41 @@ import XCTestDynamicOverlay // NB: Deprecated after 0.38.2: +extension Effect where Failure == Error { + @_disfavoredOverload + @available( + *, + deprecated, + message: "Use the non-failing version of 'Effect.task'" + ) + public static func task( + priority: TaskPriority? = nil, + operation: @escaping @Sendable () async throws -> Output + ) -> Self where Error == Swift.Error { + SignalProducer.deferred { + var task: Task<(), Never>? + let producer = SignalProducer { observer, lifetime in + task = Task(priority: priority) { @MainActor in + do { + try Task.checkCancellation() + let output = try await operation() + try Task.checkCancellation() + observer.send(value: output) + observer.sendCompleted() + } catch is CancellationError { + observer.sendCompleted() + } catch { + observer.send(error: error) + } + } + } + + return producer.on(disposed: task?.cancel) + } + .eraseToEffect() + } +} + /// Initializes a store from an initial state, a reducer, and an environment, and the main thread /// check is disabled for all interactions with this store. /// @@ -19,7 +55,8 @@ import XCTestDynamicOverlay /// - reducer: The reducer that powers the business logic of the application. /// - environment: The environment of dependencies for the application. @available( - *, deprecated, + *, + deprecated, message: """ If you use this initializer, please open a discussion on GitHub and let us know how: \ @@ -57,6 +94,7 @@ extension Effect { extension ViewStore { @available(*, deprecated, renamed: "yield(while:)") + @MainActor public func suspend(while predicate: @escaping (State) -> Bool) async { await self.yield(while: predicate) } @@ -169,7 +207,7 @@ extension Reducer { #if DEBUG extension TestStore where LocalState: Equatable, Action: Equatable { @available( - *, deprecated, message: "Use 'TestStore.send' and 'TestStore.receive' directly, instead" + *, deprecated, message: "Use 'TestStore.send' and 'TestStore.receive' directly, instead." ) public func assert( _ steps: Step..., @@ -180,7 +218,7 @@ extension Reducer { } @available( - *, deprecated, message: "Use 'TestStore.send' and 'TestStore.receive' directly, instead" + *, deprecated, message: "Use 'TestStore.send' and 'TestStore.receive' directly, instead." ) public func assert( _ steps: [Step], @@ -261,7 +299,7 @@ extension Reducer { self.line = line } - @available(*, deprecated, message: "Call 'TestStore.send' directly, instead") + @available(*, deprecated, message: "Call 'TestStore.send' directly, instead.") public static func send( _ action: LocalAction, file: StaticString = #file, @@ -271,7 +309,7 @@ extension Reducer { Step(.send(action, update), file: file, line: line) } - @available(*, deprecated, message: "Call 'TestStore.receive' directly, instead") + @available(*, deprecated, message: "Call 'TestStore.receive' directly, instead.") public static func receive( _ action: Action, file: StaticString = #file, @@ -281,7 +319,7 @@ extension Reducer { Step(.receive(action, update), file: file, line: line) } - @available(*, deprecated, message: "Mutate 'TestStore.environment' directly, instead") + @available(*, deprecated, message: "Mutate 'TestStore.environment' directly, instead.") public static func environment( file: StaticString = #file, line: UInt = #line, @@ -290,7 +328,7 @@ extension Reducer { Step(.environment(update), file: file, line: line) } - @available(*, deprecated, message: "Perform this work directly in your test, instead") + @available(*, deprecated, message: "Perform this work directly in your test, instead.") public static func `do`( file: StaticString = #file, line: UInt = #line, @@ -299,7 +337,7 @@ extension Reducer { Step(.do(work), file: file, line: line) } - @available(*, deprecated, message: "Perform this work directly in your test, instead") + @available(*, deprecated, message: "Perform this work directly in your test, instead.") public static func sequence( _ steps: [Step], file: StaticString = #file, @@ -308,7 +346,7 @@ extension Reducer { Step(.sequence(steps), file: file, line: line) } - @available(*, deprecated, message: "Perform this work directly in your test, instead") + @available(*, deprecated, message: "Perform this work directly in your test, instead.") public static func sequence( _ steps: Step..., file: StaticString = #file, @@ -373,18 +411,20 @@ extension Reducer { *, deprecated, message: """ - If you use this method, please open a discussion on GitHub and let us know how: \ - https://github.com/pointfreeco/swift-composable-architecture/discussions/new - """ + If you use this method, please open a discussion on GitHub and let us know how: \ + https://github.com/pointfreeco/swift-composable-architecture/discussions/new + """ ) public func producerScope( - state toLocalState: @escaping (Effect) -> Effect, + state toLocalState: @escaping (SignalProducer) -> SignalProducer< + LocalState, Never + >, action fromLocalAction: @escaping (LocalAction) -> Action - ) -> Effect, Never> { + ) -> SignalProducer, Never> { func extractLocalState(_ state: State) -> LocalState? { var localState: LocalState? - _ = toLocalState(Effect(value: state)) + _ = toLocalState(SignalProducer(value: state)) .startWithValues { localState = $0 } return localState } @@ -394,9 +434,13 @@ extension Reducer { let localStore = Store( initialState: localState, reducer: .init { localState, localAction, _ in - self.send(fromLocalAction(localAction)) + let task = self.send(fromLocalAction(localAction)) localState = extractLocalState(self.state) ?? localState - return .none + if let task = task { + return .fireAndForget { await task.cancellableValue } + } else { + return .none + } }, environment: () ) @@ -419,13 +463,15 @@ extension Reducer { *, deprecated, message: """ - If you use this method, please open a discussion on GitHub and let us know how: \ - https://github.com/pointfreeco/swift-composable-architecture/discussions/new - """ + If you use this method, please open a discussion on GitHub and let us know how: \ + https://github.com/pointfreeco/swift-composable-architecture/discussions/new + """ ) public func producerScope( - state toLocalState: @escaping (Effect) -> Effect - ) -> Effect, Never> { + state toLocalState: @escaping (SignalProducer) -> SignalProducer< + LocalState, Never + > + ) -> SignalProducer, Never> { self.producerScope(state: toLocalState, action: { $0 }) } @@ -436,11 +482,11 @@ extension Reducer { *, deprecated, message: """ - Dynamic member lookup is no longer supported for bindable state. Instead of dot-chaining on \ - the view store, e.g. 'viewStore.$value', invoke the 'binding' method on view store with a \ - key path to the value, e.g. 'viewStore.binding(\\.$value)'. For more on this change, see: \ - https://github.com/pointfreeco/swift-composable-architecture/pull/810 - """ + Dynamic member lookup is no longer supported for bindable state. Instead of dot-chaining on \ + the view store, e.g. 'viewStore.$value', invoke the 'binding' method on view store with a \ + key path to the value, e.g. 'viewStore.binding(\\.$value)'. For more on this change, see: \ + https://github.com/pointfreeco/swift-composable-architecture/pull/810 + """ ) public subscript( dynamicMember keyPath: WritableKeyPath> @@ -459,9 +505,9 @@ extension Reducer { *, deprecated, message: """ - For improved safety, bindable properties must now be wrapped explicitly in 'BindableState', \ - and accessed via key paths to that 'BindableState', like '\\.$value' - """ + For improved safety, bindable properties must now be wrapped explicitly in 'BindableState', \ + and accessed via key paths to that 'BindableState', like '\\.$value' + """ ) public static func set( _ keyPath: WritableKeyPath, @@ -479,9 +525,9 @@ extension Reducer { *, deprecated, message: """ - For improved safety, bindable properties must now be wrapped explicitly in 'BindableState', \ - and accessed via key paths to that 'BindableState', like '\\.$value' - """ + For improved safety, bindable properties must now be wrapped explicitly in 'BindableState', \ + and accessed via key paths to that 'BindableState', like '\\.$value' + """ ) public static func ~= ( keyPath: WritableKeyPath, @@ -496,9 +542,9 @@ extension Reducer { *, deprecated, message: """ - 'Reducer.binding()' no longer takes an explicit extract function and instead the reducer's \ - 'Action' type must conform to 'BindableAction' - """ + 'Reducer.binding()' no longer takes an explicit extract function and instead the reducer's \ + 'Action' type must conform to 'BindableAction' + """ ) public func binding(action toBindingAction: @escaping (Action) -> BindingAction?) -> Self @@ -516,11 +562,11 @@ extension Reducer { *, deprecated, message: """ - For improved safety, bindable properties must now be wrapped explicitly in 'BindableState'. \ - Bindings are now derived via 'ViewStore.binding' with a key path to that 'BindableState' \ - (for example, 'viewStore.binding(\\.$value)'). For dynamic member lookup to be available, \ - the view store's 'Action' type must also conform to 'BindableAction'. - """ + For improved safety, bindable properties must now be wrapped explicitly in 'BindableState'. \ + Bindings are now derived via 'ViewStore.binding' with a key path to that 'BindableState' \ + (for example, 'viewStore.binding(\\.$value)'). For dynamic member lookup to be available, \ + the view store's 'Action' type must also conform to 'BindableAction'. + """ ) public func binding( keyPath: WritableKeyPath, @@ -572,13 +618,14 @@ extension Reducer { // NB: Deprecated after 0.20.0: extension Reducer { - @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead") + @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") public func forEach( state toLocalState: WritableKeyPath, action toLocalAction: CasePath, environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, breakpointOnNil: Bool = true, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line ) -> Reducer { .init { globalState, globalAction, globalEnvironment in @@ -617,11 +664,13 @@ extension Reducer { "ForEachStore". """, [ - "\(file)", + "\(fileID)", line, debugCaseOutput(localAction), index, - ] + ], + file: file, + line: line ) return .none } @@ -636,7 +685,7 @@ extension Reducer { } extension ForEachStore { - @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead") + @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") public init( _ store: Store, id: KeyPath, @@ -664,7 +713,7 @@ extension Reducer { } } - @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead") + @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") public init( _ store: Store, @ViewBuilder content: @escaping (Store) -> EachContent diff --git a/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift b/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift index d74390dde..a237e80fd 100644 --- a/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift +++ b/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift @@ -31,22 +31,24 @@ @inline(__always) func runtimeWarning( _ message: @autoclosure () -> StaticString, - _ args: @autoclosure () -> [CVarArg] = [] + _ args: @autoclosure () -> [CVarArg] = [], + file: StaticString? = nil, + line: UInt? = nil ) { #if DEBUG + let message = message() if _XCTIsTesting { - XCTFail(String(format: "\(message())", arguments: args())) + if let file = file, let line = line { + XCTFail(String(format: "\(message)", arguments: args()), file: file, line: line) + } else { + XCTFail(String(format: "\(message)", arguments: args())) + } } else { #if canImport(os) unsafeBitCast( os_log as (OSLogType, UnsafeRawPointer, OSLog, StaticString, CVarArg...) -> Void, to: ((OSLogType, UnsafeRawPointer, OSLog, StaticString, [CVarArg]) -> Void).self - )(.fault, rw.dso, rw.log, message(), args()) - #else - let strMessage = message().withUTF8Buffer { - String(decoding: $0, as: UTF8.self) - } - print(String(format: strMessage, arguments: args())) + )(.fault, rw.dso, rw.log, message, args()) #endif } #endif diff --git a/Sources/ComposableArchitecture/Internal/TaskCancellableValue.swift b/Sources/ComposableArchitecture/Internal/TaskCancellableValue.swift new file mode 100644 index 000000000..2cd3636f1 --- /dev/null +++ b/Sources/ComposableArchitecture/Internal/TaskCancellableValue.swift @@ -0,0 +1,25 @@ +#if canImport(_Concurrency) && compiler(>=5.5.2) + extension Task where Failure == Error { + var cancellableValue: Success { + get async throws { + try await withTaskCancellationHandler { + self.cancel() + } operation: { + try await self.value + } + } + } + } + + extension Task where Failure == Never { + var cancellableValue: Success { + get async { + await withTaskCancellationHandler { + self.cancel() + } operation: { + await self.value + } + } + } + } +#endif diff --git a/Sources/ComposableArchitecture/Reducer.swift b/Sources/ComposableArchitecture/Reducer.swift index b12854cfc..d60b38268 100644 --- a/Sources/ComposableArchitecture/Reducer.swift +++ b/Sources/ComposableArchitecture/Reducer.swift @@ -1,5 +1,4 @@ import CasePaths -import Foundation /// A reducer describes how to evolve the current state of an application to the next state, given /// an action, and describes what ``Effect``s should be executed later by the store, if any. @@ -12,11 +11,15 @@ import Foundation /// * `Environment`: A type that holds all dependencies needed in order to produce ``Effect``s, /// such as API clients, analytics clients, random number generators, etc. /// -/// - Note: The thread on which effects output is important. An effect's output is immediately sent -/// back into the store, and ``Store`` is not thread safe. This means all effects must receive -/// values on the same thread, **and** if the ``Store`` is being used to drive UI then all output -/// must be on the main thread. You can use the `SignalProducer` method `observe(on:)` for make the -/// effect output its values on the thread of your choice. +/// > Important: The thread on which effects output is important. An effect's output is immediately +/// sent back into the store, and ``Store`` is not thread safe. This means all effects must +/// receive values on the same thread, **and** if the ``Store`` is being used to drive UI then all +/// output must be on the main thread. You can use the `Publisher` method `receive(on:)` for make +/// the effect output its values on the thread of your choice. +/// > +/// > This is only an issue if using the Combine interface of ``Effect`` as mentioned above. If you +/// you are only using Swift's concurrency tools and the `.task`, `.run` and `.fireAndForget` +/// functions on ``Effect``, then the threading is automatically handled for you. public struct Reducer { private let reducer: (inout State, Action, Environment) -> Effect @@ -69,23 +72,23 @@ public struct Reducer { /// its state, it can make a difference if `reducerA` chooses to modify `reducerB`'s state /// _before_ or _after_ `reducerB` runs. /// - /// This is perhaps most easily seen when working with ``optional(file:line:)`` reducers, where - /// the parent domain may listen to the child domain and `nil` out its state. If the parent + /// This is perhaps most easily seen when working with ``optional(file:fileID:line:)`` reducers, + /// where the parent domain may listen to the child domain and `nil` out its state. If the parent /// reducer runs before the child reducer, then the child reducer will not be able to react to its /// own action. /// - /// Similar can be said for a ``forEach(state:action:environment:file:line:)-gvte`` reducer. If - /// the parent domain modifies the child collection by moving, removing, or modifying an element - /// before the ``forEach(state:action:environment:file:line:)-gvte`` reducer runs, the - /// ``forEach(state:action:environment:file:line:)-gvte`` reducer may perform its action against - /// the wrong element, an element that no longer exists, or an element in an unexpected state. + /// Similar can be said for a ``forEach(state:action:environment:file:fileID:line:)-n7qj`` + /// reducer. If the parent domain modifies the child collection by moving, removing, or modifying + /// an element before the `forEach` reducer runs, the `forEach` reducer may perform its action + /// against the wrong element, an element that no longer exists, or an element in an unexpected + /// state. /// /// Running a parent reducer before a child reducer can be considered an application logic /// error, and can produce assertion failures. So you should almost always combine reducers in /// order from child to parent domain. /// - /// Here is an example of how you should combine an ``optional(file:line:)`` reducer with a parent - /// domain: + /// Here is an example of how you should combine an ``optional(file:fileID:line:)`` reducer with a + /// parent domain: /// /// ```swift /// let parentReducer = Reducer.combine( @@ -116,51 +119,9 @@ public struct Reducer { /// Combines many reducers into a single one by running each one on state in order, and merging /// all of the effects. /// - /// It is important to note that the order of combining reducers matter. Combining `reducerA` with - /// `reducerB` is not necessarily the same as combining `reducerB` with `reducerA`. - /// - /// This can become an issue when working with reducers that have overlapping domains. For - /// example, if `reducerA` embeds the domain of `reducerB` and reacts to its actions or modifies - /// its state, it can make a difference if `reducerA` chooses to modify `reducerB`'s state - /// _before_ or _after_ `reducerB` runs. - /// - /// This is perhaps most easily seen when working with ``optional(file:line:)`` reducers, where - /// the parent domain may listen to the child domain and `nil` out its state. If the parent - /// reducer runs before the child reducer, then the child reducer will not be able to react to its - /// own action. - /// - /// Similar can be said for a ``forEach(state:action:environment:file:line:)-gvte`` reducer. If - /// the parent domain modifies the child collection by moving, removing, or modifying an element - /// before the ``forEach(state:action:environment:file:line:)-gvte`` reducer runs, the - /// ``forEach(state:action:environment:file:line:)-gvte`` reducer may perform its action against - /// the wrong element, an element that no longer exists, or an element in an unexpected state. - /// - /// Running a parent reducer before a child reducer can be considered an application logic error, - /// and can produce assertion failures. So you should almost always combine reducers in order from - /// child to parent domain. - /// - /// Here is an example of how you should combine an ``optional(file:line:)`` reducer with a parent - /// domain: - /// - /// ```swift - /// let parentReducer = Reducer.combine( - /// // Combined before parent so that it can react to `.dismiss` while state is non-`nil`. - /// childReducer.optional().pullback( - /// state: \.child, - /// action: /ParentAction.child, - /// environment: { $0.child } - /// ), - /// // Combined after child so that it can `nil` out child state upon `.child(.dismiss)`. - /// Reducer { state, action, environment in - /// switch action - /// case .child(.dismiss): - /// state.child = nil - /// return .none - /// ... - /// } - /// }, - /// ) - /// ``` + /// This method is identical to ``Reducer/combine(_:)-994ak`` except that it takes an array + /// of reducers instead of a variadic list. See the documentation on + /// ``Reducer/combine(_:)-994ak`` for more information about what this method does. /// /// - Parameter reducers: An array of reducers. /// - Returns: A single reducer. @@ -170,57 +131,13 @@ public struct Reducer { } } - /// Combines many reducers into a single one by running each one on state in order, and merging - /// all of the effects. + /// Combines the receiving reducer with one other reducer, running the second after the first and + /// merging all of the effects. /// - /// It is important to note that the order of combining reducers matter. Combining `reducerA` with - /// `reducerB` is not necessarily the same as combining `reducerB` with `reducerA`. - /// - /// This can become an issue when working with reducers that have overlapping domains. For - /// example, if `reducerA` embeds the domain of `reducerB` and reacts to its actions or modifies - /// its state, it can make a difference if `reducerA` chooses to modify `reducerB`'s state - /// _before_ or _after_ `reducerB` runs. - /// - /// This is perhaps most easily seen when working with ``optional(file:line:)`` reducers, where - /// the parent domain may listen to the child domain and `nil` out its state. If the parent - /// reducer runs before the child reducer, then the child reducer will not be able to react to its - /// own action. - /// - /// Similar can be said for a ``forEach(state:action:environment:file:line:)-gvte`` reducer. If - /// the parent domain modifies the child collection by moving, removing, or modifying an element - /// before the ``forEach(state:action:environment:file:line:)-gvte`` reducer runs, the - /// ``forEach(state:action:environment:file:line:)-gvte`` reducer may perform its action against - /// the wrong element, an element that no longer exists, or an element in an unexpected state. - /// - /// Running a parent reducer before a child reducer can be considered an application logic error, - /// and can produce assertion failures. So you should almost always combine reducers in order from - /// child to parent domain. - /// - /// Here is an example of how you should combine an ``optional(file:line:)`` reducer with a parent - /// domain: - /// - /// ```swift - /// let parentReducer: Reducer = - /// // Run before parent so that it can react to `.dismiss` while state is non-`nil`. - /// childReducer - /// .optional() - /// .pullback( - /// state: \.child, - /// action: /ParentAction.child, - /// environment: { $0.child } - /// ) - /// // Combined after child so that it can `nil` out child state upon `.child(.dismiss)`. - /// .combined( - /// with: Reducer { state, action, environment in - /// switch action - /// case .child(.dismiss): - /// state.child = nil - /// return .none - /// ... - /// } - /// } - /// ) - /// ``` + /// This method is identical to ``Reducer/combine(_:)-994ak`` except that it combines the + /// receiver with a single other reducer rather than combining a whole list of reducers. See the + /// documentation on ``Reducer/combine(_:)-994ak`` for more information about what this method + /// does. /// /// - Parameter other: Another reducer. /// - Returns: A single reducer. @@ -400,7 +317,7 @@ public struct Reducer { /// let childReducer = Reducer< /// ChildState, ChildAction, ChildEnvironment /// > { state, action environment in - /// enum MotionId {} + /// enum MotionID {} /// /// switch action { /// case .onAppear: @@ -408,11 +325,11 @@ public struct Reducer { /// return environment.motionClient /// .start() /// .map(ChildAction.motion) - /// .cancellable(id: MotionId.self) + /// .cancellable(id: MotionID.self) /// /// case .onDisappear: /// // And explicitly cancel them when the domain is torn down - /// return .cancel(id: MotionId.self) + /// return .cancel(id: MotionID.self) /// ... /// } /// } @@ -456,7 +373,8 @@ public struct Reducer { state toLocalState: CasePath, action toLocalAction: CasePath, environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line ) -> Reducer { .init { globalState, globalAction, globalEnvironment in @@ -489,11 +407,13 @@ public struct Reducer { In SwiftUI applications, use "SwitchStore". """, [ - "\(file)", + "\(fileID)", line, debugCaseOutput(localAction), "\(State.self)", - ] + ], + file: file, + line: line ) return .none } @@ -521,7 +441,7 @@ public struct Reducer { /// // Global domain that holds an optional local domain: /// struct AppState { var modal: ModalState? } /// enum AppAction { case modal(ModalAction) } - /// struct AppEnvironment { var mainQueue: DateScheduler } + /// struct AppEnvironment { var mainQueue: AnySchedulerOf } /// /// // A reducer that works on the non-optional local domain: /// let modalReducer = Reducer { /// let childReducer = Reducer< /// ChildState, ChildAction, ChildEnvironment /// > { state, action environment in - /// enum MotionId {} + /// enum MotionID {} /// /// switch action { /// case .onAppear: @@ -622,11 +542,11 @@ public struct Reducer { /// return environment.motionClient /// .start() /// .map(ChildAction.motion) - /// .cancellable(id: MotionId.self) + /// .cancellable(id: MotionID.self) /// /// case .onDisappear: /// // And explicitly cancel them when the domain is torn down - /// return .cancel(id: MotionId.self) + /// return .cancel(id: MotionID.self) /// ... /// } /// } @@ -665,7 +585,8 @@ public struct Reducer { /// /// - Returns: A reducer that works on optional state. public func optional( - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line ) -> Reducer< State?, Action, Environment @@ -696,11 +617,13 @@ public struct Reducer { applications, use "IfLetStore". """, [ - "\(file)", + "\(fileID)", line, debugCaseOutput(action), "\(State.self)", - ] + ], + file: file, + line: line ) return .none } @@ -715,7 +638,7 @@ public struct Reducer { /// // Global domain that holds a collection of local domains: /// struct AppState { var todos: IdentifiedArrayOf } /// enum AppAction { case todo(id: Todo.ID, action: TodoAction) } - /// struct AppEnvironment { var mainQueue: DateScheduler } + /// struct AppEnvironment { var mainQueue: AnySchedulerOf } /// /// // A reducer that works on a local domain: /// let todoReducer = Reducer { ... } @@ -733,10 +656,8 @@ public struct Reducer { /// ) /// ``` /// - /// Take care when combining ``forEach(state:action:environment:file:line:)-gvte`` reducers into - /// parent domains, as order matters. Always combine - /// ``forEach(state:action:environment:file:line:)-gvte`` reducers _before_ parent reducers that - /// can modify the collection. + /// Take care when combining `forEach` reducers into parent domains, as order matters. Always + /// combine `forEach` reducers _before_ parent reducers that can modify the collection. /// /// - Parameters: /// - toLocalState: A key path that can get/set a collection of `State` elements inside @@ -749,7 +670,8 @@ public struct Reducer { state toLocalState: WritableKeyPath>, action toLocalAction: CasePath, environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line ) -> Reducer { .init { globalState, globalAction, globalEnvironment in @@ -785,11 +707,13 @@ public struct Reducer { "ForEachStore". """, [ - "\(file)", + "\(fileID)", line, debugCaseOutput(localAction), "\(id)", - ] + ], + file: file, + line: line ) return .none } @@ -807,10 +731,8 @@ public struct Reducer { /// A version of ``pullback(state:action:environment:)`` that transforms a reducer that works on /// an element into one that works on a dictionary of element values. /// - /// Take care when combining ``forEach(state:action:environment:file:line:)-21wow`` reducers into - /// parent domains, as order matters. Always combine - /// ``forEach(state:action:environment:file:line:)-21wow`` reducers _before_ parent reducers that - /// can modify the dictionary. + /// Take care when combining `forEach` reducers into parent domains, as order matters. Always + /// combine `forEach`` reducers _before_ parent reducers that can modify the dictionary. /// /// - Parameters: /// - toLocalState: A key path that can get/set a dictionary of `State` values inside @@ -822,7 +744,8 @@ public struct Reducer { state toLocalState: WritableKeyPath, action toLocalAction: CasePath, environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line ) -> Reducer { .init { globalState, globalAction, globalEnvironment in @@ -858,11 +781,13 @@ public struct Reducer { store when its state contains an element at this key. """, [ - "\(file)", + "\(fileID)", line, debugCaseOutput(localAction), "\(key)", - ] + ], + file: file, + line: line ) return .none } diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 342a6f4fc..7dd563179 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -118,20 +118,10 @@ import ReactiveSwift /// #### Thread safety checks /// /// The store performs some basic thread safety checks in order to help catch mistakes. Stores -/// constructed via the initializer ``Store/init(initialState:reducer:environment:)`` are assumed -/// to run only on the main thread, and so a check is executed immediately to make sure that is the -/// case. Further, all actions sent to the store and all scopes (see ``Store/scope(state:action:)``) -/// of the store are also checked to make sure that work is performed on the main thread. -/// -/// If you need a store that runs on a non-main thread, which should be very rare and you should -/// have a very good reason to do so, then you can construct a store via the -/// ``Store/unchecked(initialState:reducer:environment:)`` static method to opt out of all main -/// thread checks. -/// -/// --- -/// -/// See also: ``ViewStore`` to understand how one observes changes to the state in a ``Store`` and -/// sends user actions. +/// constructed via the initializer ``init(initialState:reducer:environment:)`` are assumed to run +/// only on the main thread, and so a check is executed immediately to make sure that is the case. +/// Further, all actions sent to the store and all scopes (see ``scope(state:action:)``) of the +/// store are also checked to make sure that work is performed on the main thread. public final class Store { var state: State { didSet { @@ -139,7 +129,7 @@ public final class Store { } } private let statePipe = Signal.reentrantUnserializedPipe() - var producer: Effect { + var producer: SignalProducer { Property(initial: state, then: self.statePipe.output).producer } var effectDisposables: [UUID: Disposable] = [:] @@ -323,9 +313,13 @@ public final class Store { reducer: .init { localState, localAction, _ in isSending = true defer { isSending = false } - self.send(fromLocalAction(localAction)) + let task = self.send(fromLocalAction(localAction)) localState = toLocalState(self.state) - return .none + if let task = task { + return .fireAndForget { await task.cancellableValue } + } else { + return .none + } }, environment: () ) @@ -352,11 +346,14 @@ public final class Store { self.scope(state: toLocalState, action: { $0 }) } - func send(_ action: Action, originatingFrom originatingAction: Action? = nil) { + func send( + _ action: Action, + originatingFrom originatingAction: Action? = nil + ) -> Task? { self.threadCheck(status: .send(action, originatingAction: originatingAction)) self.bufferedActions.append(action) - guard !self.isSending else { return } + guard !self.isSending else { return nil } self.isSending = true var currentState = self.state @@ -365,32 +362,68 @@ public final class Store { self.state = currentState } + let tasks = Box<[Task]>(wrappedValue: []) + while !self.bufferedActions.isEmpty { let action = self.bufferedActions.removeFirst() let effect = self.reducer(¤tState, action) var didComplete = false + let boxedTask = Box?>(wrappedValue: nil) let uuid = UUID() let observer = Signal.Observer( value: { [weak self] effectAction in - self?.send(effectAction, originatingFrom: action) + guard let self = self else { return } + if let task = self.send(effectAction, originatingFrom: action) { + tasks.wrappedValue.append(task) + } }, completed: { [weak self] in self?.threadCheck(status: .effectCompletion(action)) + boxedTask.wrappedValue?.cancel() didComplete = true self?.effectDisposables.removeValue(forKey: uuid)?.dispose() }, interrupted: { [weak self] in + boxedTask.wrappedValue?.cancel() didComplete = true self?.effectDisposables.removeValue(forKey: uuid)?.dispose() } ) - let effectDisposable = effect.start(observer) + + let effectDisposable = CompositeDisposable() + effectDisposable += effect.producer.start(observer) + effectDisposable += AnyDisposable { [weak self] in + self?.threadCheck(status: .effectCompletion(action)) + self?.effectDisposables.removeValue(forKey: uuid)?.dispose() + } if !didComplete { + let task = Task { @MainActor in + for await _ in AsyncStream.never {} + effectDisposable.dispose() + } + boxedTask.wrappedValue = task + tasks.wrappedValue.append(task) self.effectDisposables[uuid] = effectDisposable } } + + return Task { + await withTaskCancellationHandler { + var index = tasks.wrappedValue.startIndex + while index < tasks.wrappedValue.endIndex { + defer { index += 1 } + tasks.wrappedValue[index].cancel() + } + } operation: { + var index = tasks.wrappedValue.startIndex + while index < tasks.wrappedValue.endIndex { + defer { index += 1 } + await tasks.wrappedValue[index].value + } + } + } } /// Returns a "stateless" store by erasing state to `Void`. diff --git a/Sources/ComposableArchitecture/SwiftUI/Alert.swift b/Sources/ComposableArchitecture/SwiftUI/Alert.swift index 079899799..de5355a28 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Alert.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Alert.swift @@ -240,7 +240,7 @@ (viewStore.state?.title).map { Text($0) } ?? Text(""), isPresented: viewStore.binding(send: dismiss).isPresent(), presenting: viewStore.state, - actions: { $0.toSwiftUIActions(send: viewStore.send) }, + actions: { $0.toSwiftUIActions(send: { viewStore.send($0) }) }, message: { $0.message.map { Text($0) } } ) } @@ -252,7 +252,7 @@ func body(content: Content) -> some View { content.alert(item: viewStore.binding(send: dismiss)) { state in - state.toSwiftUIAlert(send: viewStore.send) + state.toSwiftUIAlert(send: { viewStore.send($0) }) } } } diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index c248642af..4ddc197bf 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -123,7 +123,7 @@ import CustomDump /// ``` /// /// Binding actions are constructed and sent to the store by calling -/// ``ViewStore/binding(_:file:line:)`` with a key path to the bindable state: +/// ``ViewStore/binding(_:file:fileID:line:)`` with a key path to the bindable state: /// /// ```swift /// TextField("Display name", text: viewStore.binding(\.$displayName)) @@ -280,7 +280,8 @@ extension BindableAction { /// - Returns: A new binding. public func binding( _ keyPath: WritableKeyPath>, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line ) -> Binding { self.binding( @@ -288,7 +289,7 @@ extension BindableAction { send: { value in #if DEBUG let debugger = BindableActionViewStoreDebugger( - value: value, bindableActionType: Action.self, file: file, line: line + value: value, bindableActionType: Action.self, file: file, fileID: fileID, line: line ) let set: (inout State) -> Void = { $0[keyPath: keyPath].wrappedValue = value @@ -402,7 +403,7 @@ extension BindingAction { /// } /// /// struct AppEnvironment { - /// var numberFact: (Int) -> Effect + /// var numberFact: (Int) async throws -> String /// ... /// } /// @@ -564,13 +565,21 @@ extension Reducer where Action: BindableAction, State == Action.State { let value: Value let bindableActionType: Any.Type let file: StaticString + let fileID: StaticString let line: UInt var wasCalled = false - init(value: Value, bindableActionType: Any.Type, file: StaticString, line: UInt) { + init( + value: Value, + bindableActionType: Any.Type, + file: StaticString, + fileID: StaticString, + line: UInt + ) { self.value = value self.bindableActionType = bindableActionType self.file = file + self.fileID = fileID self.line = line } @@ -578,7 +587,7 @@ extension Reducer where Action: BindableAction, State == Action.State { guard self.wasCalled else { runtimeWarning( """ - A binding action sent from a view store at "%@:%d" was not handled: + A binding action sent from a view store at "%@:%d" was not handled. … Action: %@ @@ -586,10 +595,12 @@ extension Reducer where Action: BindableAction, State == Action.State { To fix this, invoke the "binding()" method on your feature's reducer. """, [ - "\(self.file)", + "\(self.fileID)", self.line, "\(self.bindableActionType).binding(.set(_, \(self.value)))", - ] + ], + file: self.file, + line: self.line ) return } diff --git a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift index ceabbc3fc..60b04fa79 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift @@ -265,7 +265,7 @@ isPresented: viewStore.binding(send: dismiss).isPresent(), titleVisibility: viewStore.state?.titleVisibility.toSwiftUI ?? .automatic, presenting: viewStore.state, - actions: { $0.toSwiftUIActions(send: viewStore.send) }, + actions: { $0.toSwiftUIActions(send: { viewStore.send($0) }) }, message: { $0.message.map { Text($0) } } ) } @@ -282,7 +282,7 @@ func body(content: Content) -> some View { #if !os(macOS) return content.actionSheet(item: viewStore.binding(send: dismiss)) { state in - state.toSwiftUIActionSheet(send: viewStore.send) + state.toSwiftUIActionSheet(send: { viewStore.send($0) }) } #else return EmptyView() diff --git a/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift b/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift index 7924b0c88..92a996fae 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift @@ -51,7 +51,7 @@ /// } /// ``` /// - /// Enhance its reducer using ``Reducer/forEach(state:action:environment:file:line:)-gvte``: + /// Enhance its reducer using ``Reducer/forEach(state:action:environment:file:fileID:line:)-n7qj``: /// /// ```swift /// let appReducer = todoReducer.forEach( diff --git a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift index 4e896f010..abf690873 100644 --- a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift @@ -51,9 +51,9 @@ /// } /// ``` /// - /// - See also: ``Reducer/pullback(state:action:environment:file:line:)``, a method that aids in - /// transforming reducers that operate on each case of an enum into reducers that operate on the - /// entire enum. + /// - See also: ``Reducer/pullback(state:action:environment:file:fileID:line:)``, a method that aids + /// in transforming reducers that operate on each case of an enum into reducers that operate on + /// the entire enum. public struct SwitchStore: View { public let store: Store public let content: () -> Content @@ -186,7 +186,8 @@ public init( _ store: Store, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line, @ViewBuilder content: @escaping () -> CaseLet ) @@ -202,7 +203,7 @@ { self.init(store) { content() - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { _ExhaustivityCheckView(file: file, fileID: fileID, line: line) } } } @@ -245,7 +246,8 @@ public init( _ store: Store, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line, @ViewBuilder content: @escaping () -> TupleView< ( @@ -271,7 +273,7 @@ self.init(store) { content.value.0 content.value.1 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { _ExhaustivityCheckView(file: file, fileID: fileID, line: line) } } } @@ -325,7 +327,8 @@ public init( _ store: Store, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line, @ViewBuilder content: @escaping () -> TupleView< ( @@ -356,7 +359,7 @@ content.value.0 content.value.1 content.value.2 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { _ExhaustivityCheckView(file: file, fileID: fileID, line: line) } } } @@ -422,7 +425,8 @@ State4, Action4, Content4 >( _ store: Store, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line, @ViewBuilder content: @escaping () -> TupleView< ( @@ -458,7 +462,7 @@ content.value.1 content.value.2 content.value.3 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { _ExhaustivityCheckView(file: file, fileID: fileID, line: line) } } } @@ -532,7 +536,8 @@ State5, Action5, Content5 >( _ store: Store, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line, @ViewBuilder content: @escaping () -> TupleView< ( @@ -573,7 +578,7 @@ content.value.2 content.value.3 content.value.4 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { _ExhaustivityCheckView(file: file, fileID: fileID, line: line) } } } @@ -655,7 +660,8 @@ State6, Action6, Content6 >( _ store: Store, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line, @ViewBuilder content: @escaping () -> TupleView< ( @@ -701,7 +707,7 @@ content.value.3 content.value.4 content.value.5 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { _ExhaustivityCheckView(file: file, fileID: fileID, line: line) } } } @@ -791,7 +797,8 @@ State7, Action7, Content7 >( _ store: Store, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line, @ViewBuilder content: @escaping () -> TupleView< ( @@ -842,7 +849,7 @@ content.value.4 content.value.5 content.value.6 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { _ExhaustivityCheckView(file: file, fileID: fileID, line: line) } } } @@ -940,7 +947,8 @@ State8, Action8, Content8 >( _ store: Store, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line, @ViewBuilder content: @escaping () -> TupleView< ( @@ -996,7 +1004,7 @@ content.value.5 content.value.6 content.value.7 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { _ExhaustivityCheckView(file: file, fileID: fileID, line: line) } } } @@ -1102,7 +1110,8 @@ State9, Action9, Content9 >( _ store: Store, - file: StaticString = #fileID, + file: StaticString = #file, + fileID: StaticString = #fileID, line: UInt = #line, @ViewBuilder content: @escaping () -> TupleView< ( @@ -1163,7 +1172,7 @@ content.value.6 content.value.7 content.value.8 - Default { _ExhaustivityCheckView(file: file, line: line) } + Default { _ExhaustivityCheckView(file: file, fileID: fileID, line: line) } } } } @@ -1171,6 +1180,7 @@ public struct _ExhaustivityCheckView: View { @EnvironmentObject private var store: StoreObservableObject let file: StaticString + let fileID: StaticString let line: UInt public var body: some View { @@ -1178,7 +1188,7 @@ let message = """ Warning: SwitchStore.body@\(self.file):\(self.line) - "\(debugCaseOutput(self.store.wrappedValue.state))" was encountered by a \ + "\(debugCaseOutput(self.store.wrappedValue.state))" was encountered by a \ "SwitchStore" that does not handle this case. Make sure that you exhaustively provide a "CaseLet" view for each case in "\(State.self)", \ @@ -1210,10 +1220,12 @@ or provide a "Default" view at the end of the "SwitchStore". """, [ - "\(self.file)", + "\(self.fileID)", self.line, debugCaseOutput(self.store.wrappedValue.state), - ] + ], + file: self.file, + line: self.line ) } #else diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift new file mode 100644 index 000000000..08dba9adb --- /dev/null +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -0,0 +1,1030 @@ +#if DEBUG + import ReactiveSwift + import CustomDump + import Foundation + import XCTestDynamicOverlay + + #if os(Linux) + import CDispatch + #endif + + /// A testable runtime for a reducer. + /// + /// This object aids in writing expressive and exhaustive tests for features built in the + /// Composable Architecture. It allows you to send a sequence of actions to the store, and each + /// step of the way you must assert exactly how state changed, and how effect emissions were fed + /// back into the system. + /// + /// There are multiple ways the test store forces you to exhaustively assert on how your feature + /// behaves: + /// + /// * After each action is sent you must describe precisely how the state changed from before + /// the action was sent to after it was sent. + /// + /// If even the smallest piece of data differs the test will fail. This guarantees that you + /// are proving you know precisely how the state of the system changes. + /// + /// * Sending an action can sometimes cause an effect to be executed, and if that effect emits + /// an action that is fed back into the system, you **must** explicitly assert that you expect + /// to receive that action from the effect, _and_ you must assert how state changed as a + /// result. + /// + /// If you try to send another action before you have handled all effect emissions the + /// assertion will fail. This guarantees that you do not accidentally forget about an effect + /// emission, and that the sequence of steps you are describing will mimic how the application + /// behaves in reality. + /// + /// * All effects must complete by the time the assertion has finished running the steps you + /// specify. + /// + /// If at the end of the assertion there is still an in-flight effect running, the assertion + /// will fail. This helps exhaustively prove that you know what effects are in flight and + /// forces you to prove that effects will not cause any future changes to your state. + /// + /// For example, given a simple counter reducer: + /// + /// ```swift + /// struct CounterState { + /// var count = 0 + /// } + /// enum CounterAction: Equatable { + /// case decrementButtonTapped + /// case incrementButtonTapped + /// } + /// + /// let counterReducer = Reducer { state, action, _ in + /// switch action { + /// case .decrementButtonTapped: + /// state.count -= 1 + /// return .none + /// + /// case .incrementButtonTapped: + /// state.count += 1 + /// return .none + /// } + /// } + /// ``` + /// + /// One can assert against its behavior over time: + /// + /// ```swift + /// @MainActor + /// class CounterTests: XCTestCase { + /// func testCounter() async { + /// let store = TestStore( + /// initialState: CounterState(count: 0), // Given a counter state of 0 + /// reducer: counterReducer, + /// environment: () + /// ) + /// await store.send(.incrementButtonTapped) { // When the increment button is tapped + /// $0.count = 1 // Then the count should be 1 + /// } + /// } + /// } + /// ``` + /// + /// Note that in the trailing closure of `.send(.incrementButtonTapped)` we are given a single + /// mutable value of the state before the action was sent, and it is our job to mutate the value + /// to match the state after the action was sent. In this case the `count` field changes to `1`. + /// + /// For a more complex example, consider the following bare-bones search feature that uses the + /// ``Effect/debounce(id:for:scheduler:options:)-76yye`` operator to wait for the user to stop + /// typing before making a network request: + /// + /// ```swift + /// struct SearchState: Equatable { + /// var query = "" + /// var results: [String] = [] + /// } + /// + /// enum SearchAction: Equatable { + /// case queryChanged(String) + /// case response([String]) + /// } + /// + /// struct SearchEnvironment { + /// var mainQueue: AnySchedulerOf + /// var request: (String) async throws -> [String] + /// } + /// + /// let searchReducer = Reducer { + /// state, action, environment in + /// switch action { + /// case let .queryChanged(query): + /// enum SearchID {} + /// + /// state.query = query + /// return .run { send in + /// guard let results = try? await environment.request(query) else { return } + /// send(.response(results)) + /// } + /// .debounce(id: SearchID.self, for: 0.5, scheduler: environment.mainQueue) + /// + /// case let .response(results): + /// state.results = results + /// return .none + /// } + /// } + /// ``` + /// + /// It can be fully tested by controlling the environment's scheduler and effect: + /// + /// ```swift + /// // Create a test dispatch scheduler to control the timing of effects + /// let mainQueue = TestScheduler() + /// + /// let store = TestStore( + /// initialState: SearchState(), + /// reducer: searchReducer, + /// environment: SearchEnvironment( + /// // Wrap the test scheduler in a type-erased scheduler + /// mainQueue: mainQueue.eraseToAnyScheduler(), + /// // Simulate a search response with one item + /// request: { ["Composable Architecture"] } + /// ) + /// ) + /// + /// // Change the query + /// await store.send(.searchFieldChanged("c") { + /// // Assert that state updates accordingly + /// $0.query = "c" + /// } + /// + /// // Advance the queue by a period shorter than the debounce + /// await mainQueue.advance(by: 0.25) + /// + /// // Change the query again + /// await store.send(.searchFieldChanged("co") { + /// $0.query = "co" + /// } + /// + /// // Advance the queue by a period shorter than the debounce + /// await mainQueue.advance(by: 0.25) + /// // Advance the scheduler to the debounce + /// await scheduler.advance(by: 0.25) + /// + /// // Assert that the expected response is received + /// await store.receive(.response(["Composable Architecture"])) { + /// // Assert that state updates accordingly + /// $0.results = ["Composable Architecture"] + /// } + /// ``` + /// + /// This test is proving that the debounced network requests are correctly canceled when we do not + /// wait longer than the 0.5 seconds, because if it wasn't and it delivered an action when we did + /// not expect it would cause a test failure. + /// + public final class TestStore { + /// The current environment. + /// + /// The environment can be modified throughout a test store's lifecycle in order to influence + /// how it produces effects. This can be handy for testing flows that require a dependency + /// to start in a failing state and then later change into a succeeding state: + /// + /// ```swift + /// // Start dependency endpoint in a failing state + /// store.environment.client.fetch = { _ in throw FetchError() } + /// await store.send(.buttonTapped) + /// await store.receive(.response(.failure(FetchError())) { + /// … + /// } + /// + /// // Change dependency endpoint into a succeeding state + /// await store.environment.client.fetch = { "Hello \($0)!" } + /// await store.send(.buttonTapped) + /// await store.receive(.response(.success("Hello Blob!"))) { + /// … + /// } + /// ``` + public var environment: Environment + + /// The current state. + /// + /// When read from a trailing closure assertion in ``send(_:_:file:line:)-7vwv9`` or + /// ``receive(_:timeout:_:file:line:)-88eyr``, it will equal the `inout` state passed to the + /// closure. + public private(set) var state: State + + /// The timeout to await for in-flight effects. + /// + /// This is the default timeout used in all methods that take an optional timeout, such as + /// ``send(_:_:file:line:)-7vwv9``, ``receive(_:timeout:_:file:line:)-88eyr`` and + /// ``finish(timeout:file:line:)-53gi5``. + public var timeout: UInt64 + + private let file: StaticString + private let fromLocalAction: (LocalAction) -> Action + private var line: UInt + private var inFlightEffects: Set = [] + var receivedActions: [(action: Action, state: State)] = [] + private let reducer: Reducer + private var store: Store! + private let toLocalState: (State) -> LocalState + + private init( + environment: Environment, + file: StaticString, + fromLocalAction: @escaping (LocalAction) -> Action, + initialState: State, + line: UInt, + reducer: Reducer, + toLocalState: @escaping (State) -> LocalState + ) { + self.environment = environment + self.file = file + self.fromLocalAction = fromLocalAction + self.line = line + self.reducer = reducer + self.state = initialState + self.toLocalState = toLocalState + self.timeout = 100 * NSEC_PER_MSEC + + self.store = Store( + initialState: initialState, + reducer: Reducer { [unowned self] state, action, _ in + let effects: Effect + switch action.origin { + case let .send(localAction): + effects = self.reducer.run(&state, self.fromLocalAction(localAction), self.environment) + self.state = state + + case let .receive(action): + effects = self.reducer.run(&state, action, self.environment) + self.receivedActions.append((action, state)) + } + + let effect = LongLivingEffect(file: action.file, line: action.line) + return + effects + .producer + .on( + starting: { [weak self] in self?.inFlightEffects.insert(effect) }, + completed: { [weak self] in self?.inFlightEffects.remove(effect) }, + disposed: { [weak self] in self?.inFlightEffects.remove(effect) } + ) + .eraseToEffect { .init(origin: .receive($0), file: action.file, line: action.line) } + + }, + environment: () + ) + } + + #if swift(>=5.7) + /// Suspends until all in-flight effects have finished, or until it times out. + /// + /// Can be used to assert that all effects have finished. + /// + /// - Parameter duration: The amount of time to wait before asserting. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @MainActor + public func finish( + timeout duration: Duration, + file: StaticString = #file, + line: UInt = #line + ) async { + await self.finish(timeout: duration.nanoseconds, file: file, line: line) + } + #endif + + /// Suspends until all in-flight effects have finished, or until it times out. + /// + /// Can be used to assert that all effects have finished. + /// + /// - Parameter nanoseconds: The amount of time to wait before asserting. + @MainActor + public func finish( + timeout nanoseconds: UInt64? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + let nanoseconds = nanoseconds ?? self.timeout + let start = DispatchTime.now().uptimeNanoseconds + await Task.megaYield() + while !self.inFlightEffects.isEmpty { + guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < nanoseconds + else { + let timeoutMessage = + nanoseconds != self.self.timeout + ? #"try increasing the duration of this assertion's "timeout""# + : #"configure this assertion with an explicit "timeout""# + let suggestion = """ + There are effects in-flight. If the effect that delivers this action uses a \ + scheduler (via "receive(on:)", "delay", "debounce", etc.), make sure that you wait \ + enough time for the scheduler to perform the effect. If you are using a test \ + scheduler, advance the scheduler so that the effects may complete, or consider using \ + an immediate scheduler to immediately perform the effect instead. + + If you are not yet using a scheduler, or can not use a scheduler, \(timeoutMessage). + """ + + XCTFail( + """ + Expected effects to finish, but there are still effects in-flight\ + \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). + + \(suggestion) + """, + file: file, + line: line + ) + return + } + await Task.yield() + } + } + + deinit { + self.completed() + } + + func completed() { + if !self.receivedActions.isEmpty { + var actions = "" + customDump(self.receivedActions.map(\.action), to: &actions) + XCTFail( + """ + The store received \(self.receivedActions.count) unexpected \ + action\(self.receivedActions.count == 1 ? "" : "s") after this one: … + + Unhandled actions: \(actions) + """, + file: self.file, line: self.line + ) + } + for effect in self.inFlightEffects { + XCTFail( + """ + An effect returned for this action is still running. It must complete before the end of \ + the test. … + + To fix, inspect any effects the reducer returns for this action and ensure that all of \ + them complete by the end of the test. There are a few reasons why an effect may not have \ + completed: + + • If using async/await in your effect, it may need a little bit of time to properly \ + finish. To fix you can simply perform "await store.finish()" at the end of your test. + + • If an effect uses a scheduler (via "receive(on:)", "delay", "debounce", etc.), make \ + sure that you wait enough time for the scheduler to perform the effect. If you are using \ + a test scheduler, advance the scheduler so that the effects may complete, or consider \ + using an immediate scheduler to immediately perform the effect instead. + + • If you are returning a long-living effect (timers, notifications, subjects, etc.), \ + then make sure those effects are torn down by marking the effect ".cancellable" and \ + returning a corresponding cancellation effect ("Effect.cancel") from another action, or, \ + if your effect is driven by a Combine subject, send it a completion. + """, + file: effect.file, + line: effect.line + ) + } + } + + private struct LongLivingEffect: Hashable { + let id = UUID() + let file: StaticString + let line: UInt + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + self.id.hash(into: &hasher) + } + } + + private struct TestAction: CustomDebugStringConvertible { + let origin: Origin + let file: StaticString + let line: UInt + + enum Origin { + case send(LocalAction) + case receive(Action) + } + + var debugDescription: String { + switch self.origin { + case let .send(action): + return debugCaseOutput(action) + + case let .receive(action): + return debugCaseOutput(action) + } + } + } + } + + extension TestStore where State == LocalState, Action == LocalAction { + /// Initializes a test store from an initial state, a reducer, and an initial environment. + /// + /// - Parameters: + /// - initialState: The state to start the test from. + /// - reducer: A reducer. + /// - environment: The environment to start the test from. + public convenience init( + initialState: State, + reducer: Reducer, + environment: Environment, + file: StaticString = #file, + line: UInt = #line + ) { + self.init( + environment: environment, + file: file, + fromLocalAction: { $0 }, + initialState: initialState, + line: line, + reducer: reducer, + toLocalState: { $0 } + ) + } + } + + extension TestStore where LocalState: Equatable { + /// Sends an action to the store and asserts when state changes. + /// + /// This method suspends in order to allow any effects to start. For example, if you + /// track an analytics event in a ``Effect/fireAndForget(priority:_:)`` when an action is sent, + /// you can assert on that behavior immediately after awaiting `store.send`: + /// + /// ```swift + /// @MainActor + /// func testAnalytics() async { + /// let events = ActorIsolated<[String]>([]) + /// let analytics = AnalyticsClient( + /// track: { event in + /// await events.withValue { $0.append(event) } + /// } + /// ) + /// + /// let store = TestStore( + /// initialState: State(), + /// reducer: reducer, + /// environment: Environment(analytics: analytics) + /// ) + /// + /// await store.send(.buttonTapped) + /// + /// await events.withValue { XCTAssertEqual($0, ["Button Tapped"]) } + /// } + /// ``` + /// + /// This method suspends only for the duration until the effect _starts_ from sending the + /// action. It does _not_ suspend for the duration of the effect. + /// + /// In order to suspend for the duration of the effect you can use its return value, a + /// ``TestStoreTask``, which represents the lifecycle of the effect started from sending an + /// action. You can use this value to suspend until the effect finishes, or to force the + /// cancellation of the effect, which is helpful for effects that are tied to a view's lifecycle + /// and not torn down when an action is sent, such as actions sent in SwiftUI's `task` view + /// modifier. + /// + /// For example, if your feature kicks off a long-living effect when the view appears by using + /// SwiftUI's `task` view modifier, then you can write a test for such a feature by explicitly + /// canceling the effect's task after you make all assertions: + /// + /// ```swift + /// let store = TestStore(...) + /// + /// // emulate the view appearing + /// let task = await store.send(.task) + /// + /// // assertions + /// + /// // emulate the view disappearing + /// await task.cancel() + /// ``` + /// + /// - Parameters: + /// - action: An action. + /// - updateExpectingResult: A closure that asserts state changed by sending the action to the + /// store. The mutable state sent to this closure must be modified to match the state of the + /// store after processing the given action. Do not provide a closure if no change is + /// expected. + /// - Returns: A ``TestStoreTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @MainActor + @discardableResult + public func send( + _ action: LocalAction, + _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async -> TestStoreTask { + if !self.receivedActions.isEmpty { + var actions = "" + customDump(self.receivedActions.map(\.action), to: &actions) + XCTFail( + """ + Must handle \(self.receivedActions.count) received \ + action\(self.receivedActions.count == 1 ? "" : "s") before sending an action: … + + Unhandled actions: \(actions) + """, + file: file, line: line + ) + } + var expectedState = self.toLocalState(self.state) + let previousState = self.state + let task = self.store.send(.init(origin: .send(action), file: file, line: line)) + await Task.megaYield() + do { + let currentState = self.state + self.state = previousState + defer { self.state = currentState } + + try self.expectedStateShouldMatch( + expected: &expectedState, + actual: self.toLocalState(currentState), + modify: updateExpectingResult, + file: file, + line: line + ) + } catch { + XCTFail("Threw error: \(error)", file: file, line: line) + } + if "\(self.file)" == "\(file)" { + self.line = line + } + await Task.megaYield() + return .init(rawValue: task, timeout: self.timeout) + } + + /// Sends an action to the store and asserts when state changes. + /// + /// This method returns a ``TestStoreTask``, which represents the lifecycle of the effect + /// started from sending an action. You can use this value to force the cancellation of the + /// effect, which is helpful for effects that are tied to a view's lifecycle and not torn + /// down when an action is sent, such as actions sent in SwiftUI's `task` view modifier. + /// + /// For example, if your feature kicks off a long-living effect when the view appears by using + /// SwiftUI's `task` view modifier, then you can write a test for such a feature by explicitly + /// canceling the effect's task after you make all assertions: + /// + /// ```swift + /// let store = TestStore(...) + /// + /// // emulate the view appearing + /// let task = await store.send(.task) + /// + /// // assertions + /// + /// // emulate the view disappearing + /// await task.cancel() + /// ``` + /// + /// - Parameters: + /// - action: An action. + /// - updateExpectingResult: A closure that asserts state changed by sending the action to the + /// store. The mutable state sent to this closure must be modified to match the state of the + /// store after processing the given action. Do not provide a closure if no change is + /// expected. + /// - Returns: A ``TestStoreTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @available(iOS, deprecated: 9999.0, message: "Call the async-friendly 'send' instead.") + @available(macOS, deprecated: 9999.0, message: "Call the async-friendly 'send' instead.") + @available(tvOS, deprecated: 9999.0, message: "Call the async-friendly 'send' instead.") + @available(watchOS, deprecated: 9999.0, message: "Call the async-friendly 'send' instead.") + @discardableResult + public func send( + _ action: LocalAction, + _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) -> TestStoreTask { + if !self.receivedActions.isEmpty { + var actions = "" + customDump(self.receivedActions.map(\.action), to: &actions) + XCTFail( + """ + Must handle \(self.receivedActions.count) received \ + action\(self.receivedActions.count == 1 ? "" : "s") before sending an action: … + + Unhandled actions: \(actions) + """, + file: file, line: line + ) + } + var expectedState = self.toLocalState(self.state) + let previousState = self.state + let task = self.store.send(.init(origin: .send(action), file: file, line: line)) + do { + let currentState = self.state + self.state = previousState + defer { self.state = currentState } + + try self.expectedStateShouldMatch( + expected: &expectedState, + actual: self.toLocalState(currentState), + modify: updateExpectingResult, + file: file, + line: line + ) + } catch { + XCTFail("Threw error: \(error)", file: file, line: line) + } + if "\(self.file)" == "\(file)" { + self.line = line + } + + return .init(rawValue: task, timeout: self.timeout) + } + + private func expectedStateShouldMatch( + expected: inout LocalState, + actual: LocalState, + modify: ((inout LocalState) throws -> Void)? = nil, + file: StaticString, + line: UInt + ) throws { + let current = expected + if let modify = modify { + try modify(&expected) + } + + if expected != actual { + let difference = + diff(expected, actual, format: .proportional) + .map { "\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" } + ?? """ + Expected: + \(String(describing: expected).indent(by: 2)) + + Actual: + \(String(describing: actual).indent(by: 2)) + """ + + let messageHeading = + modify != nil + ? "A state change does not match expectation" + : "State was not expected to change, but a change occurred" + XCTFail( + """ + \(messageHeading): … + + \(difference) + """, + file: file, + line: line + ) + } else if expected == current && modify != nil { + XCTFail( + """ + Expected state to change, but no change occurred. + + The trailing closure made no observable modifications to state. If no change to state is \ + expected, omit the trailing closure. + """, + file: file, line: line + ) + } + } + } + + extension TestStore where LocalState: Equatable, Action: Equatable { + /// Asserts an action was received from an effect and asserts when state changes. + /// + /// - Parameters: + /// - expectedAction: An action expected from an effect. + /// - updateExpectingResult: A closure that asserts state changed by sending the action to the + /// store. The mutable state sent to this closure must be modified to match the state of the + /// store after processing the given action. Do not provide a closure if no change is + /// expected. + @available(iOS, deprecated: 9999.0, message: "Call the async-friendly 'receive' instead.") + @available(macOS, deprecated: 9999.0, message: "Call the async-friendly 'receive' instead.") + @available(tvOS, deprecated: 9999.0, message: "Call the async-friendly 'receive' instead.") + @available(watchOS, deprecated: 9999.0, message: "Call the async-friendly 'receive' instead.") + public func receive( + _ expectedAction: Action, + _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) { + guard !self.receivedActions.isEmpty else { + XCTFail( + """ + Expected to receive an action, but received none. + """, + file: file, line: line + ) + return + } + let (receivedAction, state) = self.receivedActions.removeFirst() + if expectedAction != receivedAction { + let difference = TaskResultDebugging.$emitRuntimeWarnings.withValue(false) { + diff(expectedAction, receivedAction, format: .proportional) + .map { "\($0.indent(by: 4))\n\n(Expected: −, Received: +)" } + ?? """ + Expected: + \(String(describing: expectedAction).indent(by: 2)) + + Received: + \(String(describing: receivedAction).indent(by: 2)) + """ + } + + XCTFail( + """ + Received unexpected action: … + + \(difference) + """, + file: file, line: line + ) + } + var expectedState = self.toLocalState(self.state) + do { + try expectedStateShouldMatch( + expected: &expectedState, + actual: self.toLocalState(state), + modify: updateExpectingResult, + file: file, + line: line + ) + } catch { + XCTFail("Threw error: \(error)", file: file, line: line) + } + self.state = state + if "\(self.file)" == "\(file)" { + self.line = line + } + } + + #if swift(>=5.7) + /// Asserts an action was received from an effect and asserts how the state changes. + /// + /// - Parameters: + /// - expectedAction: An action expected from an effect. + /// - duration: The amount of time to wait for the expected action. + /// - updateExpectingResult: A closure that asserts state changed by sending the action to + /// the store. The mutable state sent to this closure must be modified to match the state + /// of the store after processing the given action. Do not provide a closure if no change + /// is expected. + @MainActor + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func receive( + _ expectedAction: Action, + timeout duration: Duration, + _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + await self.receive( + expectedAction, + timeout: duration.nanoseconds, + updateExpectingResult, + file: file, + line: line + ) + } + #endif + + /// Asserts an action was received from an effect and asserts how the state changes. + /// + /// - Parameters: + /// - expectedAction: An action expected from an effect. + /// - nanoseconds: The amount of time to wait for the expected action. + /// - updateExpectingResult: A closure that asserts state changed by sending the action to the + /// store. The mutable state sent to this closure must be modified to match the state of the + /// store after processing the given action. Do not provide a closure if no change is + /// expected. + @MainActor + public func receive( + _ expectedAction: Action, + timeout nanoseconds: UInt64? = nil, + _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + let nanoseconds = nanoseconds ?? self.timeout + + guard !self.inFlightEffects.isEmpty + else { + { self.receive(expectedAction, updateExpectingResult, file: file, line: line) }() + return + } + + await Task.megaYield() + let start = DispatchTime.now().uptimeNanoseconds + while !Task.isCancelled { + await Task.detached(priority: .low) { await Task.yield() }.value + + guard self.receivedActions.isEmpty + else { break } + + guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < nanoseconds + else { + let suggestion: String + if self.inFlightEffects.isEmpty { + suggestion = """ + There are no in-flight effects that could deliver this action. Could the effect you \ + expected to deliver this action have been cancelled? + """ + } else { + let timeoutMessage = + nanoseconds != self.timeout + ? #"try increasing the duration of this assertion's "timeout""# + : #"configure this assertion with an explicit "timeout""# + suggestion = """ + There are effects in-flight. If the effect that delivers this action uses a \ + scheduler (via "receive(on:)", "delay", "debounce", etc.), make sure that you wait \ + enough time for the scheduler to perform the effect. If you are using a test \ + scheduler, advance the scheduler so that the effects may complete, or consider using \ + an immediate scheduler to immediately perform the effect instead. + + If you are not yet using a scheduler, or can not use a scheduler, \(timeoutMessage). + """ + } + XCTFail( + """ + Expected to receive an action, but received none\ + \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). + + \(suggestion) + """, + file: file, + line: line + ) + return + } + } + + guard !Task.isCancelled + else { return } + + { self.receive(expectedAction, updateExpectingResult, file: file, line: line) }() + await Task.megaYield() + } + } + + extension TestStore { + /// Scopes a store to assert against more local state and actions. + /// + /// Useful for testing view store-specific state and actions. + /// + /// - Parameters: + /// - toLocalState: A function that transforms the reducer's state into more local state. This + /// state will be asserted against as it is mutated by the reducer. Useful for testing view + /// store state transformations. + /// - fromLocalAction: A function that wraps a more local action in the reducer's action. + /// Local actions can be "sent" to the store, while any reducer action may be received. + /// Useful for testing view store action transformations. + public func scope( + state toLocalState: @escaping (LocalState) -> S, + action fromLocalAction: @escaping (A) -> LocalAction + ) -> TestStore { + .init( + environment: self.environment, + file: self.file, + fromLocalAction: { self.fromLocalAction(fromLocalAction($0)) }, + initialState: self.store.state, + line: self.line, + reducer: self.reducer, + toLocalState: { toLocalState(self.toLocalState($0)) } + ) + } + + /// Scopes a store to assert against more local state. + /// + /// Useful for testing view store-specific state. + /// + /// - Parameter toLocalState: A function that transforms the reducer's state into more local + /// state. This state will be asserted against as it is mutated by the reducer. Useful for + /// testing view store state transformations. + public func scope( + state toLocalState: @escaping (LocalState) -> S + ) -> TestStore { + self.scope(state: toLocalState, action: { $0 }) + } + } + + /// The type returned from ``TestStore/send(_:_:file:line:)-7vwv9`` that represents the lifecycle + /// of the effect started from sending an action. + /// + /// You can use this value in tests to cancel the effect started from sending an action: + /// + /// ```swift + /// // Simulate the "task" view modifier invoking some async work + /// let task = store.send(.task) + /// + /// // Simulate the view cancelling this work on dismissal + /// await task.cancel() + /// ``` + /// + /// You can also explicitly wait for an effect to finish: + /// + /// ```swift + /// store.send(.startTimerButtonTapped) + /// + /// await mainQueue.advance(by: .seconds(1)) + /// await store.receive(.timerTick) { $0.elapsed = 1 } + /// + /// // Wait for cleanup effects to finish before completing the test + /// await store.send(.stopTimerButtonTapped).finish() + /// ``` + /// + /// See ``TestStore/finish(timeout:file:line:)-53gi5`` for the ability to await all in-flight + /// effects in the test store. + /// + /// See ``ViewStoreTask`` for the analog provided to ``ViewStore``. + public struct TestStoreTask: Hashable, Sendable { + fileprivate let rawValue: Task? + fileprivate let timeout: UInt64 + + /// Cancels the underlying task and waits for it to finish. + public func cancel() async { + self.rawValue?.cancel() + await self.rawValue?.cancellableValue + } + + #if swift(>=5.7) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + /// Asserts the underlying task finished. + /// + /// - Parameter duration: The amount of time to wait before asserting. + public func finish( + timeout duration: Duration, + file: StaticString = #file, + line: UInt = #line + ) async { + await self.finish(timeout: duration.nanoseconds, file: file, line: line) + } + #endif + + /// Asserts the underlying task finished. + /// + /// - Parameter nanoseconds: The amount of time to wait before asserting. + public func finish( + timeout nanoseconds: UInt64? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + let nanoseconds = nanoseconds ?? self.timeout + await Task.megaYield() + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { await self.rawValue?.cancellableValue } + group.addTask { + try await Task.sleep(nanoseconds: nanoseconds) + throw CancellationError() + } + try await group.next() + group.cancelAll() + } + } catch { + let timeoutMessage = + nanoseconds != self.timeout + ? #"try increasing the duration of this assertion's "timeout""# + : #"configure this assertion with an explicit "timeout""# + let suggestion = """ + If this task delivers its action with a scheduler (via "receive(on:)", "delay", \ + "debounce", etc.), make sure that you wait enough time for the scheduler to perform its \ + work. If you are using a test scheduler, advance the scheduler so that the effects may \ + complete, or consider using an immediate scheduler to immediately perform the effect \ + instead. + + If you are not yet using a scheduler, or can not use a scheduler, \(timeoutMessage). + """ + + XCTFail( + """ + Expected task to finish, but it is still in-flight\ + \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). + + \(suggestion) + """, + file: file, + line: line + ) + } + } + + /// A Boolean value that indicates whether the task should stop executing. + /// + /// After the value of this property becomes `true`, it remains `true` indefinitely. There is + /// no way to uncancel a task. + public var isCancelled: Bool { + self.rawValue?.isCancelled ?? true + } + } + + extension Task where Success == Never, Failure == Never { + static func megaYield(count: Int = 3) async { + for _ in 1...count { + await Task.detached(priority: .low) { await Task.yield() }.value + } + } + } + + #if swift(>=5.7) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension Duration { + fileprivate var nanoseconds: UInt64 { + UInt64(self.components.seconds) * NSEC_PER_SEC + + UInt64(self.components.attoseconds) / 1_000_000_000 + } + } + #endif +#endif diff --git a/Sources/ComposableArchitecture/TestSupport/TestStore.swift b/Sources/ComposableArchitecture/TestSupport/TestStore.swift deleted file mode 100644 index f17b4a6ee..000000000 --- a/Sources/ComposableArchitecture/TestSupport/TestStore.swift +++ /dev/null @@ -1,551 +0,0 @@ -#if DEBUG - import ReactiveSwift - import CustomDump - import Foundation - import XCTestDynamicOverlay - - /// A testable runtime for a reducer. - /// - /// This object aids in writing expressive and exhaustive tests for features built in the - /// Composable Architecture. It allows you to send a sequence of actions to the store, and each - /// step of the way you must assert exactly how state changed, and how effect emissions were fed - /// back into the system. - /// - /// There are multiple ways the test store forces you to exhaustively assert on how your feature - /// behaves: - /// - /// * After each action is sent you must describe precisely how the state changed from before - /// the action was sent to after it was sent. - /// - /// If even the smallest piece of data differs the test will fail. This guarantees that you - /// are proving you know precisely how the state of the system changes. - /// - /// * Sending an action can sometimes cause an effect to be executed, and if that effect emits - /// an action that is fed back into the system, you **must** explicitly assert that you expect - /// to receive that action from the effect, _and_ you must assert how state changed as a - /// result. - /// - /// If you try to send another action before you have handled all effect emissions the - /// assertion will fail. This guarantees that you do not accidentally forget about an effect - /// emission, and that the sequence of steps you are describing will mimic how the application - /// behaves in reality. - /// - /// * All effects must complete by the time the assertion has finished running the steps you - /// specify. - /// - /// If at the end of the assertion there is still an in-flight effect running, the assertion - /// will fail. This helps exhaustively prove that you know what effects are in flight and - /// forces you to prove that effects will not cause any future changes to your state. - /// - /// For example, given a simple counter reducer: - /// - /// ```swift - /// struct CounterState { - /// var count = 0 - /// } - /// enum CounterAction: Equatable { - /// case decrementButtonTapped - /// case incrementButtonTapped - /// } - /// - /// let counterReducer = Reducer { state, action, _ in - /// switch action { - /// case .decrementButtonTapped: - /// state.count -= 1 - /// return .none - /// - /// case .incrementButtonTapped: - /// state.count += 1 - /// return .none - /// } - /// } - /// ``` - /// - /// One can assert against its behavior over time: - /// - /// ```swift - /// class CounterTests: XCTestCase { - /// func testCounter() { - /// let store = TestStore( - /// initialState: CounterState(count: 0), // Given a counter state of 0 - /// reducer: counterReducer, - /// environment: () - /// ) - /// store.send(.incrementButtonTapped) { // When the increment button is tapped - /// $0.count = 1 // Then the count should be 1 - /// } - /// } - /// } - /// ``` - /// - /// Note that in the trailing closure of `.send(.incrementButtonTapped)` we are given a single - /// mutable value of the state before the action was sent, and it is our job to mutate the value - /// to match the state after the action was sent. In this case the `count` field changes to `1`. - /// - /// For a more complex example, consider the following bare-bones search feature that uses the - /// ``Effect/debounce(id:for:scheduler:)-76yye`` operator to wait for the user to stop - /// typing before making a network request: - /// - /// ```swift - /// struct SearchState: Equatable { - /// var query = "" - /// var results: [String] = [] - /// } - /// - /// enum SearchAction: Equatable { - /// case queryChanged(String) - /// case response([String]) - /// } - /// - /// struct SearchEnvironment { - /// var mainQueue: DateScheduler - /// var request: (String) -> Effect<[String], Never> - /// } - /// - /// let searchReducer = Reducer { - /// state, action, environment in - /// - /// enum SearchId {} - /// - /// switch action { - /// case let .queryChanged(query): - /// state.query = query - /// return environment.request(self.query) - /// .debounce(id: SearchId.self, for: 0.5, scheduler: environment.mainQueue) - /// - /// case let .response(results): - /// state.results = results - /// return .none - /// } - /// } - /// ``` - /// - /// It can be fully tested by controlling the environment's scheduler and effect: - /// - /// ```swift - /// // Create a test dispatch scheduler to control the timing of effects - /// let mainQueue = TestScheduler() - /// - /// let store = TestStore( - /// initialState: SearchState(), - /// reducer: searchReducer, - /// environment: SearchEnvironment( - /// mainQueue: mainQueue, - /// // Simulate a search response with one item - /// request: { _ in Effect(value: ["Composable Architecture"]) } - /// ) - /// ) - /// - /// // Change the query - /// store.send(.searchFieldChanged("c") { - /// // Assert that state updates accordingly - /// $0.query = "c" - /// } - /// - /// // Advance the scheduler by a period shorter than the debounce - /// mainQueue.advance(by: .millseconds(250)) - /// - /// // Change the query again - /// store.send(.searchFieldChanged("co") { - /// $0.query = "co" - /// } - /// - /// // Advance the scheduler by a period shorter than the debounce - /// mainQueue.advance(by: .millseconds(250)) - /// // Advance the scheduler to the debounce - /// mainQueue.advance(by: .millseconds(250)) - /// - /// // Assert that the expected response is received - /// store.receive(.response(["Composable Architecture"])) { - /// // Assert that state updates accordingly - /// $0.results = ["Composable Architecture"] - /// } - /// ``` - /// - /// This test is proving that the debounced network requests are correctly canceled when we do not - /// wait longer than the 0.5 seconds, because if it wasn't and it delivered an action when we did - /// not expect it would cause a test failure. - /// - public final class TestStore { - /// The current environment. - /// - /// The environment can be modified throughout a test store's lifecycle in order to influence - /// how it produces effects. - public var environment: Environment - - /// The current state. - /// - /// When read from a trailing closure assertion in ``send(_:_:file:line:)`` or - /// ``receive(_:_:file:line:)``, it will equal the `inout` state passed to the closure. - public private(set) var state: State - - private let file: StaticString - private let fromLocalAction: (LocalAction) -> Action - private var line: UInt - private var inFlightEffects: Set = [] - var receivedActions: [(action: Action, state: State)] = [] - private let reducer: Reducer - private var store: Store! - private let toLocalState: (State) -> LocalState - - private init( - environment: Environment, - file: StaticString, - fromLocalAction: @escaping (LocalAction) -> Action, - initialState: State, - line: UInt, - reducer: Reducer, - toLocalState: @escaping (State) -> LocalState - ) { - self.environment = environment - self.file = file - self.fromLocalAction = fromLocalAction - self.line = line - self.reducer = reducer - self.state = initialState - self.toLocalState = toLocalState - - self.store = Store( - initialState: initialState, - reducer: Reducer { [unowned self] state, action, _ in - let effects: Effect - switch action.origin { - case let .send(localAction): - effects = self.reducer.run(&state, self.fromLocalAction(localAction), self.environment) - self.state = state - - case let .receive(action): - effects = self.reducer.run(&state, action, self.environment) - self.receivedActions.append((action, state)) - } - - let effect = LongLivingEffect(file: action.file, line: action.line) - return - effects - .on( - starting: { [weak self] in self?.inFlightEffects.insert(effect) }, - completed: { [weak self] in self?.inFlightEffects.remove(effect) }, - disposed: { [weak self] in self?.inFlightEffects.remove(effect) } - ) - .map { .init(origin: .receive($0), file: action.file, line: action.line) } - - }, - environment: () - ) - } - - deinit { - self.completed() - } - - func completed() { - if !self.receivedActions.isEmpty { - var actions = "" - customDump(self.receivedActions.map(\.action), to: &actions) - XCTFail( - """ - The store received \(self.receivedActions.count) unexpected \ - action\(self.receivedActions.count == 1 ? "" : "s") after this one: … - - Unhandled actions: \(actions) - """, - file: self.file, line: self.line - ) - } - for effect in self.inFlightEffects { - XCTFail( - """ - An effect returned for this action is still running. It must complete before the end of \ - the test. … - - To fix, inspect any effects the reducer returns for this action and ensure that all of \ - them complete by the end of the test. There are a few reasons why an effect may not have \ - completed: - - • If an effect uses a scheduler (via "receive(on:)", "delay", "debounce", etc.), make \ - sure that you wait enough time for the scheduler to perform the effect. If you are using \ - a test scheduler, advance the scheduler so that the effects may complete, or consider \ - using an immediate scheduler to immediately perform the effect instead. - - • If you are returning a long-living effect (timers, notifications, subjects, etc.), \ - then make sure those effects are torn down by marking the effect ".cancellable" and \ - returning a corresponding cancellation effect ("Effect.cancel") from another action, or, \ - if your effect is driven by a Combine subject, send it a completion. - """, - file: effect.file, - line: effect.line - ) - } - } - - private struct LongLivingEffect: Hashable { - let id = UUID() - let file: StaticString - let line: UInt - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - self.id.hash(into: &hasher) - } - } - - private struct TestAction: CustomDebugStringConvertible { - let origin: Origin - let file: StaticString - let line: UInt - - enum Origin { - case send(LocalAction) - case receive(Action) - } - - var debugDescription: String { - switch self.origin { - case let .send(action): - return debugCaseOutput(action) - - case let .receive(action): - return debugCaseOutput(action) - } - } - } - } - - extension TestStore where State == LocalState, Action == LocalAction { - /// Initializes a test store from an initial state, a reducer, and an initial environment. - /// - /// - Parameters: - /// - initialState: The state to start the test from. - /// - reducer: A reducer. - /// - environment: The environment to start the test from. - public convenience init( - initialState: State, - reducer: Reducer, - environment: Environment, - file: StaticString = #file, - line: UInt = #line - ) { - self.init( - environment: environment, - file: file, - fromLocalAction: { $0 }, - initialState: initialState, - line: line, - reducer: reducer, - toLocalState: { $0 } - ) - } - } - - extension TestStore where LocalState: Equatable { - /// Sends an action to the store and asserts when state changes. - /// - /// - Parameters: - /// - action: An action. - /// - updateExpectingResult: A closure that asserts state changed by sending the action to the - /// store. The mutable state sent to this closure must be modified to match the state of the - /// store after processing the given action. Do not provide a closure if no change is - /// expected. - public func send( - _ action: LocalAction, - _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) { - if !self.receivedActions.isEmpty { - var actions = "" - customDump(self.receivedActions.map(\.action), to: &actions) - XCTFail( - """ - Must handle \(self.receivedActions.count) received \ - action\(self.receivedActions.count == 1 ? "" : "s") before sending an action: … - - Unhandled actions: \(actions) - """, - file: file, line: line - ) - } - var expectedState = self.toLocalState(self.state) - let previousState = self.state - self.store.send(.init(origin: .send(action), file: file, line: line)) - do { - let currentState = self.state - self.state = previousState - defer { self.state = currentState } - - try self.expectedStateShouldMatch( - expected: &expectedState, - actual: self.toLocalState(currentState), - modify: updateExpectingResult, - file: file, - line: line - ) - } catch { - XCTFail("Threw error: \(error)", file: file, line: line) - } - if "\(self.file)" == "\(file)" { - self.line = line - } - } - - private func expectedStateShouldMatch( - expected: inout LocalState, - actual: LocalState, - modify: ((inout LocalState) throws -> Void)? = nil, - file: StaticString, - line: UInt - ) throws { - let current = expected - if let modify = modify { - try modify(&expected) - } - - if expected != actual { - let difference = - diff(expected, actual, format: .proportional) - .map { "\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" } - ?? """ - Expected: - \(String(describing: expected).indent(by: 2)) - - Actual: - \(String(describing: actual).indent(by: 2)) - """ - - let messageHeading = - modify != nil - ? "A state change does not match expectation" - : "State was not expected to change, but a change occurred" - XCTFail( - """ - \(messageHeading): … - - \(difference) - """, - file: file, - line: line - ) - } else if expected == current && modify != nil { - XCTFail( - """ - Expected state to change, but no change occurred. - - The trailing closure made no observable modifications to state. If no change to state is \ - expected, omit the trailing closure. - """, - file: file, line: line - ) - } - } - } - - extension TestStore where LocalState: Equatable, Action: Equatable { - /// Asserts an action was received from an effect and asserts when state changes. - /// - /// - Parameters: - /// - expectedAction: An action expected from an effect. - /// - updateExpectingResult: A closure that asserts state changed by sending the action to the - /// store. The mutable state sent to this closure must be modified to match the state of the - /// store after processing the given action. Do not provide a closure if no change is - /// expected. - public func receive( - _ expectedAction: Action, - _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) { - guard !self.receivedActions.isEmpty else { - XCTFail( - """ - Expected to receive an action, but received none. - """, - file: file, line: line - ) - return - } - let (receivedAction, state) = self.receivedActions.removeFirst() - if expectedAction != receivedAction { - let difference = - diff(expectedAction, receivedAction, format: .proportional) - .map { "\($0.indent(by: 4))\n\n(Expected: −, Received: +)" } - ?? """ - Expected: - \(String(describing: expectedAction).indent(by: 2)) - - Received: - \(String(describing: receivedAction).indent(by: 2)) - """ - - XCTFail( - """ - Received unexpected action: … - - \(difference) - """, - file: file, line: line - ) - } - var expectedState = self.toLocalState(self.state) - do { - try expectedStateShouldMatch( - expected: &expectedState, - actual: self.toLocalState(state), - modify: updateExpectingResult, - file: file, - line: line - ) - } catch { - XCTFail("Threw error: \(error)", file: file, line: line) - } - self.state = state - if "\(self.file)" == "\(file)" { - self.line = line - } - } - } - - extension TestStore { - /// Scopes a store to assert against more local state and actions. - /// - /// Useful for testing view store-specific state and actions. - /// - /// - Parameters: - /// - toLocalState: A function that transforms the reducer's state into more local state. This - /// state will be asserted against as it is mutated by the reducer. Useful for testing view - /// store state transformations. - /// - fromLocalAction: A function that wraps a more local action in the reducer's action. - /// Local actions can be "sent" to the store, while any reducer action may be received. - /// Useful for testing view store action transformations. - public func scope( - state toLocalState: @escaping (LocalState) -> S, - action fromLocalAction: @escaping (A) -> LocalAction - ) -> TestStore { - .init( - environment: self.environment, - file: self.file, - fromLocalAction: { self.fromLocalAction(fromLocalAction($0)) }, - initialState: self.store.state, - line: self.line, - reducer: self.reducer, - toLocalState: { toLocalState(self.toLocalState($0)) } - ) - } - - /// Scopes a store to assert against more local state. - /// - /// Useful for testing view store-specific state. - /// - /// - Parameter toLocalState: A function that transforms the reducer's state into more local - /// state. This state will be asserted against as it is mutated by the reducer. Useful for - /// testing view store state transformations. - public func scope( - state toLocalState: @escaping (LocalState) -> S - ) -> TestStore { - self.scope(state: toLocalState, action: { $0 }) - } - } - -#endif diff --git a/Sources/ComposableArchitecture/TestSupport/UnimplementedEffect.swift b/Sources/ComposableArchitecture/TestSupport/UnimplementedEffect.swift deleted file mode 100644 index 8c0eec3fb..000000000 --- a/Sources/ComposableArchitecture/TestSupport/UnimplementedEffect.swift +++ /dev/null @@ -1,89 +0,0 @@ -import XCTestDynamicOverlay - -extension Effect { - /// An effect that causes a test to fail if it runs. - /// - /// This effect can provide an additional layer of certainty that a tested code path does not - /// execute a particular effect. - /// - /// For example, let's say we have a very simple counter application, where a user can increment - /// and decrement a number. The state and actions are simple enough: - /// - /// ```swift - /// struct CounterState: Equatable { - /// var count = 0 - /// } - /// - /// enum CounterAction: Equatable { - /// case decrementButtonTapped - /// case incrementButtonTapped - /// } - /// ``` - /// - /// Let's throw in a side effect. If the user attempts to decrement the counter below zero, the - /// application should refuse and play an alert sound instead. - /// - /// We can model playing a sound in the environment with an effect: - /// - /// ```swift - /// struct CounterEnvironment { - /// let playAlertSound: () -> Effect - /// } - /// ``` - /// - /// Now that we've defined the domain, we can describe the logic in a reducer: - /// - /// ```swift - /// let counterReducer = Reducer< - /// CounterState, CounterAction, CounterEnvironment - /// > { state, action, environment in - /// switch action { - /// case .decrementButtonTapped: - /// if state.count > 0 { - /// state.count -= 0 - /// return .none - /// } else { - /// return environment.playAlertSound() - /// .fireAndForget() - /// } - /// - /// case .incrementButtonTapped: - /// state.count += 1 - /// return .none - /// } - /// } - /// ``` - /// - /// Let's say we want to write a test for the increment path. We can see in the reducer that it - /// should never play an alert, so we can configure the environment with an effect that will - /// fail if it ever executes: - /// - /// ```swift - /// func testIncrement() { - /// let store = TestStore( - /// initialState: CounterState(count: 0), - /// reducer: counterReducer, - /// environment: CounterEnvironment( - /// playAlertSound: { .unimplemented("playAlertSound") } - /// ) - /// ) - /// - /// store.send(.incrementButtonTapped) { - /// $0.count = 1 - /// } - /// } - /// ``` - /// - /// By using an `.unimplemented` effect in our environment we have strengthened the assertion and - /// made the test easier to understand at the same time. We can see, without consulting the - /// reducer itself, that this particular action should not access this effect. - /// - /// - Parameter prefix: A string that identifies this scheduler and will prefix all failure - /// messages. - /// - Returns: An effect that causes a test to fail if it runs. - public static func unimplemented(_ prefix: String) -> Self { - .fireAndForget { - XCTFail("\(prefix.isEmpty ? "" : "\(prefix) - ")An unimplemented effect ran.") - } - } -} diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 6a0d808b6..bd2b6f023 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -50,19 +50,10 @@ import ReactiveSwift /// } /// ``` /// -/// ### Thread safety -/// -/// The ``ViewStore`` class is not thread-safe, and all interactions with it (and the store it was -/// derived from) must happen on the same thread. Further, for SwiftUI applications, all -/// interactions must happen on the _main_ thread. See the documentation of the ``Store`` class for -/// more information as to why this decision was made. -/// -/// ### ViewStore object lifetime -/// -/// You must always keep a strong reference to any ``ViewStore`` that you create to prevent it from -/// being immediately deallocated, and thereby preventing its ``produced`` ``StoreProducer`` from -/// emiting any more state updates. This is primarly an issue when using UIKit, as the SwiftUI -/// ``WithViewStore`` helper ensures that the ``ViewStore`` is retained. +/// > Important: The ``ViewStore`` class is not thread-safe, and all interactions with it (and the +/// > store it was derived from) must happen on the same thread. Further, for SwiftUI applications, +/// > all interactions must happen on the _main_ thread. See the documentation of the ``Store`` +/// > class for more information as to why this decision was made. @dynamicMemberLookup public final class ViewStore { #if !canImport(Combine) @@ -70,10 +61,12 @@ public final class ViewStore { public class ObservableObjectPublisher {} #endif + // N.B. `ViewStore` does not use a `@Published` property, so `objectWillChange` + // won't be synthesized automatically. To work around issues on iOS 13 we explicitly declare it. public private(set) lazy var objectWillChange = ObservableObjectPublisher() - private let _send: (Action) -> Void - fileprivate var _state: CurrentValueRelay + private let _send: (Action) -> Task? + fileprivate let _state: CurrentValueRelay private var viewDisposable: Disposable? /// Initializes a view store from a store. @@ -145,15 +138,26 @@ public final class ViewStore { /// Sends an action to the store. /// - /// ``ViewStore`` is not thread safe and you should only send actions to it from the main thread. - /// If you are wanting to send actions on background threads due to the fact that the reducer - /// is performing computationally expensive work, then a better way to handle this is to wrap - /// that work in an ``Effect`` that is performed on a background thread so that the result can - /// be fed back into the store. + /// This method returns a ``ViewStoreTask``, which represents the lifecycle of the effect started + /// from sending an action. You can use this value to tie the effect's lifecycle _and_ + /// cancellation to an asynchronous context, such as SwiftUI's `task` view modifier: + /// + /// ```swift + /// .task { await viewStore.send(.task).finish() } + /// ``` + /// + /// > Important: ``ViewStore`` is not thread safe and you should only send actions to it from the + /// > main thread. If you want to send actions on background threads due to the fact that the + /// > reducer is performing computationally expensive work, then a better way to handle this is to + /// > wrap that work in an ``Effect`` that is performed on a background thread so that the result + /// > can be fed back into the store. /// /// - Parameter action: An action. - public func send(_ action: Action) { - self._send(action) + /// - Returns: A ``ViewStoreTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @discardableResult + public func send(_ action: Action) -> ViewStoreTask { + .init(rawValue: self._send(action)) } #if canImport(SwiftUI) @@ -164,12 +168,161 @@ public final class ViewStore { /// - Parameters: /// - action: An action. /// - animation: An animation. - public func send(_ action: Action, animation: Animation?) { + @discardableResult + public func send(_ action: Action, animation: Animation?) -> ViewStoreTask { withAnimation(animation) { self.send(action) } } + #endif + #if canImport(_Concurrency) && compiler(>=5.5.2) + /// Sends an action into the store and then suspends while a piece of state is `true`. + /// + /// This method can be used to interact with async/await code, allowing you to suspend while work + /// is being performed in an effect. One common example of this is using SwiftUI's `.refreshable` + /// method, which shows a loading indicator on the screen while work is being performed. + /// + /// For example, suppose we wanted to load some data from the network when a pull-to-refresh + /// gesture is performed on a list. The domain and logic for this feature can be modeled like so: + /// + /// ```swift + /// struct State: Equatable { + /// var isLoading = false + /// var response: String? + /// } + /// + /// enum Action { + /// case pulledToRefresh + /// case receivedResponse(TaskResult) + /// } + /// + /// struct Environment { + /// var fetch: () async throws -> String + /// } + /// + /// let reducer = Reducer { state, action, environment in + /// switch action { + /// case .pulledToRefresh: + /// state.isLoading = true + /// return .task { + /// await .receivedResponse(TaskResult { try await environment.fetch() }) + /// } + /// + /// case let .receivedResponse(result): + /// state.isLoading = false + /// state.response = try? result.value + /// return .none + /// } + /// } + /// ``` + /// + /// Note that we keep track of an `isLoading` boolean in our state so that we know exactly when + /// the network response is being performed. + /// + /// The view can show the fact in a `List`, if it's present, and we can use the `.refreshable` + /// view modifier to enhance the list with pull-to-refresh capabilities: + /// + /// ```swift + /// struct MyView: View { + /// let store: Store + /// + /// var body: some View { + /// WithViewStore(self.store) { viewStore in + /// List { + /// if let response = viewStore.response { + /// Text(response) + /// } + /// } + /// .refreshable { + /// await viewStore.send(.pulledToRefresh, while: \.isLoading) + /// } + /// } + /// } + /// } + /// ``` + /// + /// Here we've used the ``send(_:while:)`` method to suspend while the `isLoading` state is + /// `true`. Once that piece of state flips back to `false` the method will resume, signaling to + /// `.refreshable` that the work has finished which will cause the loading indicator to disappear. + /// + /// - Parameters: + /// - action: An action. + /// - predicate: A predicate on `State` that determines for how long this method should suspend. + @MainActor + public func send(_ action: Action, while predicate: @escaping (State) -> Bool) async { + let task = self.send(action) + await withTaskCancellationHandler { + task.rawValue?.cancel() + } operation: { + await self.yield(while: predicate) + } + } + + #if canImport(SwiftUI) + /// Sends an action into the store and then suspends while a piece of state is `true`. + /// + /// See the documentation of ``send(_:while:)`` for more information. + /// + /// - Parameters: + /// - action: An action. + /// - animation: The animation to perform when the action is sent. + /// - predicate: A predicate on `State` that determines for how long this method should suspend. + @MainActor + public func send( + _ action: Action, + animation: Animation?, + while predicate: @escaping (State) -> Bool + ) async { + let task = withAnimation(animation) { self.send(action) } + await withTaskCancellationHandler { + task.rawValue?.cancel() + } operation: { + await self.yield(while: predicate) + } + } + #endif + + /// Suspends the current task while a predicate on state is `true`. + /// + /// If you want to suspend at the same time you send an action to the view store, use + /// ``send(_:while:)``. + /// + /// - Parameter predicate: A predicate on `State` that determines for how long this method should + /// suspend. + @MainActor + public func yield(while predicate: @escaping (State) -> Bool) async { + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + _ = await self.produced.producer + .values + .first(where: { !predicate($0) }) + } else { + let cancellable = Box(wrappedValue: nil) + try? await withTaskCancellationHandler( + handler: { cancellable.wrappedValue?.dispose() }, + operation: { + try Task.checkCancellation() + try await withUnsafeThrowingContinuation { + (continuation: UnsafeContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + cancellable.wrappedValue = self.produced.producer + .filter { !predicate($0) } + .take(first: 1) + .startWithValues { _ in + continuation.resume() + _ = cancellable + } + } + } + ) + } + } + #endif + + #if canImport(SwiftUI) /// Derives a binding from the store that prevents direct writes to state and instead sends /// actions to the store. /// @@ -282,7 +435,7 @@ public final class ViewStore { /// enum Action { case alertDismissed } /// /// .alert( - /// item: self.store.binding( + /// item: viewStore.binding( /// send: .alertDismissed /// ) /// ) { title in Alert(title: Text(title)) } @@ -317,17 +470,55 @@ extension ViewStore where State == Void { } } +/// The type returned from ``ViewStore/send(_:)`` that represents the lifecycle of the effect +/// started from sending an action. +/// +/// You can use this value to tie the effect's lifecycle _and_ cancellation to an asynchronous +/// context, such as the `task` view modifier. +/// +/// ```swift +/// .task { await viewStore.send(.task).finish() } +/// ``` +/// +/// > Note: Unlike `Task`, ``ViewStoreTask`` automatically sets up a cancellation handler between +/// > the current async context and the task. +/// +/// See ``TestStoreTask`` for the analog provided to ``TestStore``. +public struct ViewStoreTask: Hashable, Sendable { + fileprivate let rawValue: Task? + + /// Cancels the underlying task and waits for it to finish. + public func cancel() async { + self.rawValue?.cancel() + await self.finish() + } + + /// Waits for the task to finish. + public func finish() async { + await self.rawValue?.cancellableValue + } + + /// A Boolean value that indicates whether the task should stop executing. + /// + /// After the value of this property becomes `true`, it remains `true` indefinitely. There is no + /// way to uncancel a task. + public var isCancelled: Bool { + self.rawValue?.isCancelled ?? true + } +} + #if canImport(Combine) extension ViewStore: ObservableObject { } #endif +/// A producer of store state. @dynamicMemberLookup public struct StoreProducer: SignalProducerConvertible { - public let upstream: Effect + public let upstream: SignalProducer public let viewStore: Any - public var producer: Effect { + public var producer: SignalProducer { upstream } @@ -337,7 +528,7 @@ public struct StoreProducer: SignalProducerConvertible { } private init( - upstream: Effect, + upstream: SignalProducer, viewStore: Any ) { self.upstream = upstream @@ -354,7 +545,7 @@ public struct StoreProducer: SignalProducerConvertible { /// Returns the resulting `SignalProducer` of a given key path. public subscript( dynamicMember keyPath: KeyPath - ) -> Effect { + ) -> SignalProducer { self.upstream.map(keyPath).skipRepeats() } } @@ -364,156 +555,3 @@ private struct HashableWrapper: Hashable { static func == (lhs: Self, rhs: Self) -> Bool { false } func hash(into hasher: inout Hasher) {} } - -#if canImport(_Concurrency) && compiler(>=5.5.2) - extension ViewStore { - /// Sends an action into the store and then suspends while a piece of state is `true`. - /// - /// This method can be used to interact with async/await code, allowing you to suspend while - /// work is being performed in an effect. One common example of this is using SwiftUI's - /// `.refreshable` method, which shows a loading indicator on the screen while work is being - /// performed. - /// - /// For example, suppose we wanted to load some data from the network when a pull-to-refresh - /// gesture is performed on a list. The domain and logic for this feature can be modeled like - /// so: - /// - /// ```swift - /// struct State: Equatable { - /// var isLoading = false - /// var response: String? - /// } - /// - /// enum Action { - /// case pulledToRefresh - /// case receivedResponse(String?) - /// } - /// - /// struct Environment { - /// var fetch: () -> Effect - /// } - /// - /// let reducer = Reducer { state, action, environment in - /// switch action { - /// case .pulledToRefresh: - /// state.isLoading = true - /// return environment.fetch() - /// .map(Action.receivedResponse) - /// - /// case let .receivedResponse(response): - /// state.isLoading = false - /// state.response = response - /// return .none - /// } - /// } - /// ``` - /// - /// Note that we keep track of an `isLoading` boolean in our state so that we know exactly - /// when the network response is being performed. - /// - /// The view can show the fact in a `List`, if it's present, and we can use the `.refreshable` - /// view modifier to enhance the list with pull-to-refresh capabilities: - /// - /// ```swift - /// struct MyView: View { - /// let store: Store - /// - /// var body: some View { - /// WithViewStore(self.store) { viewStore in - /// List { - /// if let response = viewStore.response { - /// Text(response) - /// } - /// } - /// .refreshable { - /// await viewStore.send(.pulledToRefresh, while: \.isLoading) - /// } - /// } - /// } - /// } - /// ``` - /// - /// Here we've used the ``send(_:while:)`` method to suspend while the `isLoading` state is - /// `true`. Once that piece of state flips back to `false` the method will resume, signaling - /// to `.refreshable` that the work has finished which will cause the loading indicator to - /// disappear. - /// - /// **Note:** ``ViewStore`` is not thread safe and you should only send actions to it from the - /// main thread. If you are wanting to send actions on background threads due to the fact that - /// the reducer is performing computationally expensive work, then a better way to handle this - /// is to wrap that work in an ``Effect`` that is performed on a background thread so that the - /// result can be fed back into the store. - /// - /// - Parameters: - /// - action: An action. - /// - predicate: A predicate on `State` that determines for how long this method should - /// suspend. - @MainActor - public func send( - _ action: Action, - while predicate: @escaping (State) -> Bool - ) async { - self.send(action) - await self.yield(while: predicate) - } - - #if canImport(SwiftUI) - /// Sends an action into the store and then suspends while a piece of state is `true`. - /// - /// See the documentation of ``send(_:while:)`` for more information. - /// - /// - Parameters: - /// - action: An action. - /// - animation: The animation to perform when the action is sent. - /// - predicate: A predicate on `State` that determines for how long this method should - /// suspend. - public func send( - _ action: Action, - animation: Animation?, - while predicate: @escaping (State) -> Bool - ) async { - withAnimation(animation) { self.send(action) } - await self.yield(while: predicate) - } - #endif - - /// Suspends the current task while a predicate on state is `true`. - /// - /// If you want to suspend at the same time you send an action to the view store, use - /// ``send(_:while:)``. - /// - /// - Parameter predicate: A predicate on `State` that determines for how long this method - /// should suspend. - public func yield(while predicate: @escaping (State) -> Bool) async { - let cancellable = Box(wrappedValue: nil) - try? await withTaskCancellationHandler( - operation: { - try Task.checkCancellation() - try await withUnsafeThrowingContinuation { - (continuation: UnsafeContinuation) in - guard !Task.isCancelled else { - continuation.resume(throwing: CancellationError()) - return - } - cancellable.wrappedValue = self.produced.producer - .filter { !predicate($0) } - .take(first: 1) - .startWithValues { _ in - continuation.resume() - _ = cancellable - } - } - }, - onCancel: { cancellable.wrappedValue?.dispose() } - ) - } - } - - private class Box { - var wrappedValue: Value - - init(wrappedValue: Value) { - self.wrappedValue = wrappedValue - } - } -#endif diff --git a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift index 3d6b08a9c..32fdfb76c 100644 --- a/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift +++ b/Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift @@ -2,158 +2,157 @@ import ComposableArchitecture import ReactiveSwift import XCTest -final class ComposableArchitectureTests: XCTestCase { - func testScheduling() { - enum CounterAction: Equatable { - case incrAndSquareLater - case incrNow - case squareNow - } +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class ComposableArchitectureTests: XCTestCase { + func testScheduling() async { + enum CounterAction: Equatable { + case incrAndSquareLater + case incrNow + case squareNow + } - let counterReducer = Reducer { - state, action, scheduler in - switch action { - case .incrAndSquareLater: - return .merge( - Effect(value: .incrNow) - .delay(2, on: scheduler), - Effect(value: .squareNow) - .delay(1, on: scheduler), - Effect(value: .squareNow) - .delay(2, on: scheduler) - ) - case .incrNow: - state += 1 - return .none - case .squareNow: - state *= state - return .none + let counterReducer = Reducer { + state, action, scheduler in + switch action { + case .incrAndSquareLater: + return .merge( + Effect(value: .incrNow).deferred(for: 2, scheduler: scheduler), + Effect(value: .squareNow).deferred(for: 1, scheduler: scheduler), + Effect(value: .squareNow).deferred(for: 2, scheduler: scheduler) + ) + case .incrNow: + state += 1 + return .none + case .squareNow: + state *= state + return .none + } } - } - let mainQueue = TestScheduler() - - let store = TestStore( - initialState: 2, - reducer: counterReducer, - environment: mainQueue - ) - - store.send(.incrAndSquareLater) - mainQueue.advance(by: 1) - store.receive(.squareNow) { $0 = 4 } - mainQueue.advance(by: 1) - store.receive(.incrNow) { $0 = 5 } - store.receive(.squareNow) { $0 = 25 } - - store.send(.incrAndSquareLater) - mainQueue.advance(by: 2) - store.receive(.squareNow) { $0 = 625 } - store.receive(.incrNow) { $0 = 626 } - store.receive(.squareNow) { $0 = 391876 } - } + let mainQueue = TestScheduler() + + let store = TestStore( + initialState: 2, + reducer: counterReducer, + environment: mainQueue + ) - func testSimultaneousWorkOrdering() { - let testScheduler = TestScheduler() + await store.send(.incrAndSquareLater) + await mainQueue.advance(by: 1) + await store.receive(.squareNow) { $0 = 4 } + await mainQueue.advance(by: 1) + await store.receive(.incrNow) { $0 = 5 } + await store.receive(.squareNow) { $0 = 25 } + + await store.send(.incrAndSquareLater) + await mainQueue.advance(by: 2) + await store.receive(.squareNow) { $0 = 625 } + await store.receive(.incrNow) { $0 = 626 } + await store.receive(.squareNow) { $0 = 391876 } + } - var values: [Int] = [] - testScheduler.schedule(after: .seconds(0), interval: .seconds(1)) { values.append(1) } - testScheduler.schedule(after: .seconds(0), interval: .seconds(2)) { values.append(42) } + func testSimultaneousWorkOrdering() { + let testScheduler = TestScheduler() - XCTAssertNoDifference(values, []) - testScheduler.advance() - XCTAssertNoDifference(values, [1, 42]) - testScheduler.advance(by: 2) - XCTAssertNoDifference(values, [1, 42, 1, 42, 1]) - } + var values: [Int] = [] + testScheduler.schedule(after: .seconds(0), interval: .seconds(1)) { values.append(1) } + testScheduler.schedule(after: .seconds(0), interval: .seconds(2)) { values.append(42) } - func testLongLivingEffects() { - typealias Environment = ( - startEffect: Effect, - stopEffect: Effect - ) - - enum Action { case end, incr, start } - - let reducer = Reducer { state, action, environment in - switch action { - case .end: - return environment.stopEffect.fireAndForget() - case .incr: - state += 1 - return .none - case .start: - return environment.startEffect.map { Action.incr } - } + XCTAssertNoDifference(values, []) + testScheduler.advance() + XCTAssertNoDifference(values, [1, 42]) + testScheduler.advance(by: 2) + XCTAssertNoDifference(values, [1, 42, 1, 42, 1]) } - let subject = Signal.pipe() - - let store = TestStore( - initialState: 0, - reducer: reducer, - environment: ( - startEffect: subject.output.producer, - stopEffect: .fireAndForget { subject.input.sendCompleted() } + func testLongLivingEffects() async { + typealias Environment = ( + startEffect: Effect, + stopEffect: Effect ) - ) - store.send(.start) - store.send(.incr) { $0 = 1 } - subject.input.send(value: ()) - store.receive(.incr) { $0 = 2 } - store.send(.end) - } + enum Action { case end, incr, start } + + let reducer = Reducer { state, action, environment in + switch action { + case .end: + return environment.stopEffect.fireAndForget() + case .incr: + state += 1 + return .none + case .start: + return environment.startEffect.map { Action.incr } + } + } - func testCancellation() { - enum Action: Equatable { - case cancel - case incr - case response(Int) - } + let subject = Signal.pipe() - struct Environment { - let fetch: (Int) -> Effect - let mainQueue: DateScheduler - } + let store = TestStore( + initialState: 0, + reducer: reducer, + environment: ( + startEffect: subject.output.producer.eraseToEffect(), + stopEffect: .fireAndForget { subject.input.sendCompleted() } + ) + ) - let reducer = Reducer { state, action, environment in - enum CancelId {} + await store.send(.start) + await store.send(.incr) { $0 = 1 } + subject.input.send(value: ()) + await store.receive(.incr) { $0 = 2 } + await store.send(.end) + } - switch action { - case .cancel: - return .cancel(id: CancelId.self) + func testCancellation() async { + let mainQueue = TestScheduler() - case .incr: - state += 1 - return environment.fetch(state) - .observe(on: environment.mainQueue) - .map(Action.response) - .cancellable(id: CancelId.self) + enum Action: Equatable { + case cancel + case incr + case response(Int) + } - case let .response(value): - state = value - return .none + struct Environment { + let fetch: (Int) async -> Int } - } - let mainQueue = TestScheduler() + let reducer = Reducer { state, action, environment in + enum CancelID {} + + switch action { + case .cancel: + return .cancel(id: CancelID.self) + + case .incr: + state += 1 + return .task { [state] in + try await mainQueue.sleep(for: .seconds(1)) + return .response(await environment.fetch(state)) + } + .cancellable(id: CancelID.self) + + case let .response(value): + state = value + return .none + } + } - let store = TestStore( - initialState: 0, - reducer: reducer, - environment: Environment( - fetch: { value in Effect(value: value * value) }, - mainQueue: mainQueue + let store = TestStore( + initialState: 0, + reducer: reducer, + environment: Environment( + fetch: { value in value * value } + ) ) - ) - store.send(.incr) { $0 = 1 } - mainQueue.advance() - store.receive(.response(1)) + await store.send(.incr) { $0 = 1 } + await mainQueue.advance(by: .seconds(1)) + await store.receive(.response(1)) - store.send(.incr) { $0 = 2 } - store.send(.cancel) - mainQueue.run() + await store.send(.incr) { $0 = 2 } + await store.send(.cancel) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/DeprecatedTests.swift b/Tests/ComposableArchitectureTests/DeprecatedTests.swift new file mode 100644 index 000000000..1c6b5164c --- /dev/null +++ b/Tests/ComposableArchitectureTests/DeprecatedTests.swift @@ -0,0 +1,34 @@ +import ComposableArchitecture +import XCTest + +@available(*, deprecated) +final class DeprecatedTests: XCTestCase { + func testUncheckedStore() { + var expectations: [XCTestExpectation] = [] + for n in 1...100 { + let expectation = XCTestExpectation(description: "\(n)th iteration is complete") + expectations.append(expectation) + DispatchQueue.global().async { + let viewStore = ViewStore( + Store.unchecked( + initialState: 0, + reducer: Reducer { state, _, expectation in + state += 1 + if state == 2 { + return .fireAndForget { expectation.fulfill() } + } + return .none + }, + environment: expectation + ) + ) + viewStore.send(()) + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + viewStore.send(()) + } + } + } + + wait(for: expectations, timeout: 1) + } +} diff --git a/Tests/ComposableArchitectureTests/EffectCancellationTests.swift b/Tests/ComposableArchitectureTests/EffectCancellationTests.swift index 5daf66630..012d89c11 100644 --- a/Tests/ComposableArchitectureTests/EffectCancellationTests.swift +++ b/Tests/ComposableArchitectureTests/EffectCancellationTests.swift @@ -18,6 +18,7 @@ final class EffectCancellationTests: XCTestCase { .cancellable(id: CancelToken()) effect + .producer .startWithValues { values.append($0) } XCTAssertNoDifference(values, []) @@ -27,6 +28,7 @@ final class EffectCancellationTests: XCTestCase { XCTAssertNoDifference(values, [1, 2]) _ = Effect.cancel(id: CancelToken()) + .producer .start() subject.input.send(value: 3) @@ -39,6 +41,7 @@ final class EffectCancellationTests: XCTestCase { let subject = Signal.pipe() Effect(subject.output) .cancellable(id: CancelToken(), cancelInFlight: true) + .producer .startWithValues { values.append($0) } XCTAssertNoDifference(values, []) @@ -49,6 +52,7 @@ final class EffectCancellationTests: XCTestCase { Effect(subject.output) .cancellable(id: CancelToken(), cancelInFlight: true) + .producer .startWithValues { values.append($0) } subject.input.send(value: 3) @@ -61,14 +65,16 @@ final class EffectCancellationTests: XCTestCase { var value: Int? Effect(value: 1) - .delay(0.15, on: QueueScheduler.main) + .deferred(for: 0.15, scheduler: QueueScheduler.main) .cancellable(id: CancelToken()) + .producer .startWithValues { value = $0 } XCTAssertNoDifference(value, nil) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { _ = Effect.cancel(id: CancelToken()) + .producer .start() } @@ -82,14 +88,16 @@ final class EffectCancellationTests: XCTestCase { var value: Int? Effect(value: 1) - .delay(2, on: mainQueue) + .deferred(for: 2, scheduler: mainQueue) .cancellable(id: CancelToken()) + .producer .startWithValues { value = $0 } XCTAssertNoDifference(value, nil) mainQueue.advance(by: 1) Effect.cancel(id: CancelToken()) + .producer .start() mainQueue.run() @@ -100,6 +108,7 @@ final class EffectCancellationTests: XCTestCase { func testCancellablesCleanUp_OnComplete() { Effect(value: 1) .cancellable(id: 1) + .producer .startWithValues { _ in } XCTAssertNoDifference([:], cancellationCancellables) @@ -108,11 +117,13 @@ final class EffectCancellationTests: XCTestCase { func testCancellablesCleanUp_OnCancel() { let mainQueue = TestScheduler() Effect(value: 1) - .delay(1, on: mainQueue) + .deferred(for: 1, scheduler: mainQueue) .cancellable(id: 1) + .producer .startWithValues { _ in } Effect.cancel(id: 1) + .producer .startWithValues { _ in } XCTAssertNoDifference([:], cancellationCancellables) @@ -127,6 +138,7 @@ final class EffectCancellationTests: XCTestCase { .cancellable(id: CancelToken()) effect + .producer .startWithValues { values.append($0) } XCTAssertNoDifference(values, []) @@ -134,6 +146,7 @@ final class EffectCancellationTests: XCTestCase { XCTAssertNoDifference(values, [1]) _ = Effect.cancel(id: CancelToken()) + .producer .start() subject.input.send(value: 2) @@ -148,6 +161,7 @@ final class EffectCancellationTests: XCTestCase { .cancellable(id: CancelToken()) effect + .producer .startWithValues { values.append($0) } subject.input.send(value: 1) @@ -157,6 +171,7 @@ final class EffectCancellationTests: XCTestCase { XCTAssertNoDifference(values, [1]) _ = Effect.cancel(id: CancelToken()) + .producer .start() XCTAssertNoDifference(values, [1]) @@ -179,24 +194,26 @@ final class EffectCancellationTests: XCTestCase { return Effect.merge( Effect(value: idx) - .delay( - Double.random(in: 1...100) / 1000, - on: QueueScheduler(internalQueue: queues.randomElement()!) + .deferred( + for: Double.random(in: 1...100) / 1000, + scheduler: QueueScheduler(internalQueue: queues.randomElement()!) ) .cancellable(id: id), - Effect(value: ()) + SignalProducer(value: ()) .delay( Double.random(in: 1...100) / 1000, on: QueueScheduler(internalQueue: queues.randomElement()!) ) - .flatMap(.latest) { Effect.cancel(id: id) } + .flatMap(.latest) { Effect.cancel(id: id).producer } + .eraseToEffect() ) } ) let expectation = self.expectation(description: "wait") effect + .producer .on(completed: { expectation.fulfill() }, value: { _ in }) .start() self.wait(for: [expectation], timeout: 999) @@ -205,18 +222,19 @@ final class EffectCancellationTests: XCTestCase { } func testNestedCancels() { - var effect = Effect { observer, _ in + var effect = SignalProducer { observer, _ in DispatchQueue.main.asyncAfter(deadline: .distantFuture) { observer.sendCompleted() } } + .eraseToEffect() .cancellable(id: 1) for _ in 1 ... .random(in: 1...1_000) { effect = effect.cancellable(id: 1) } - let disposable = effect.start() + let disposable = effect.producer.start() disposable.dispose() XCTAssertNoDifference([:], cancellationCancellables) @@ -225,18 +243,20 @@ final class EffectCancellationTests: XCTestCase { func testSharedId() { let mainQueue = TestScheduler() - let effect1 = Effect(value: 1) - .delay(1, on: mainQueue) + let effect1 = Effect(value: 1) + .deferred(for: 1, scheduler: mainQueue) .cancellable(id: "id") - let effect2 = Effect(value: 2) - .delay(2, on: mainQueue) + let effect2 = Effect(value: 2) + .deferred(for: 2, scheduler: mainQueue) .cancellable(id: "id") var expectedOutput: [Int] = [] effect1 + .producer .startWithValues { expectedOutput.append($0) } effect2 + .producer .startWithValues { expectedOutput.append($0) } XCTAssertNoDifference(expectedOutput, []) @@ -250,9 +270,11 @@ final class EffectCancellationTests: XCTestCase { let mainQueue = TestScheduler() var expectedOutput: [Int] = [] - let disposable = Effect.deferred { Effect(value: 1) } - .delay(1, on: mainQueue) + let disposable = SignalProducer.deferred { SignalProducer(value: 1) } + .eraseToEffect() + .deferred(for: 1, scheduler: mainQueue) .cancellable(id: "id") + .producer .startWithValues { expectedOutput.append($0) } // Don't hold onto cancellable so that it is deallocated immediately. @@ -265,12 +287,13 @@ final class EffectCancellationTests: XCTestCase { func testNestedMergeCancellation() { let effect = Effect.merge( - [Effect(1...2).cancellable(id: 1)] + [SignalProducer(1...2).eraseToEffect().cancellable(id: 1)] ) .cancellable(id: 2) var output: [Int] = [] effect + .producer .startWithValues { output.append($0) } XCTAssertEqual(output, [1, 2]) @@ -286,16 +309,18 @@ final class EffectCancellationTests: XCTestCase { let ids: [AnyHashable] = [A(), B(), C()] let effects = ids.map { id in - Effect(value: id) - .delay(1, on: mainQueue) + Effect(value: id) + .deferred(for: 1, scheduler: mainQueue) .cancellable(id: id) } Effect.merge(effects) + .producer .startWithValues { output.append($0) } Effect .cancel(ids: [A(), C()]) + .producer .startWithValues { _ in } mainQueue.advance(by: 1) diff --git a/Tests/ComposableArchitectureTests/EffectDebounceTests.swift b/Tests/ComposableArchitectureTests/EffectDebounceTests.swift index 79abaef2a..2ae2c45f2 100644 --- a/Tests/ComposableArchitectureTests/EffectDebounceTests.swift +++ b/Tests/ComposableArchitectureTests/EffectDebounceTests.swift @@ -1,81 +1,91 @@ -import ComposableArchitecture import ReactiveSwift import XCTest -final class EffectDebounceTests: XCTestCase { - func testDebounce() { - let mainQueue = TestScheduler() - var values: [Int] = [] +@testable import ComposableArchitecture - func runDebouncedEffect(value: Int) { - struct CancelToken: Hashable {} +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectDebounceTests: XCTestCase { + func testDebounce() async { + let mainQueue = TestScheduler() + var values: [Int] = [] - Effect(value: value) - .debounce(id: CancelToken(), for: 1, scheduler: mainQueue) - .startWithValues { values.append($0) } - } + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runDebouncedEffect(value: Int) { + struct CancelToken: Hashable {} - runDebouncedEffect(value: 1) + Effect(value: value) + .debounce(id: CancelToken(), for: 1, scheduler: mainQueue) + .producer + .startWithValues { values.append($0) } + } - // Nothing emits right away. - XCTAssertNoDifference(values, []) + runDebouncedEffect(value: 1) - // Waiting half the time also emits nothing - mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, []) + // Nothing emits right away. + XCTAssertNoDifference(values, []) - // Run another debounced effect. - runDebouncedEffect(value: 2) + // Waiting half the time also emits nothing + await mainQueue.advance(by: 0.5) + XCTAssertNoDifference(values, []) - // Waiting half the time emits nothing because the first debounced effect has been canceled. - mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, []) + // Run another debounced effect. + runDebouncedEffect(value: 2) - // Run another debounced effect. - runDebouncedEffect(value: 3) + // Waiting half the time emits nothing because the first debounced effect has been canceled. + await mainQueue.advance(by: 0.5) + XCTAssertNoDifference(values, []) - // Waiting half the time emits nothing because the second debounced effect has been canceled. - mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, []) + // Run another debounced effect. + runDebouncedEffect(value: 3) - // Waiting the rest of the time emits the final effect value. - mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, [3]) + // Waiting half the time emits nothing because the second debounced effect has been canceled. + await mainQueue.advance(by: 0.5) + XCTAssertNoDifference(values, []) - // Running out the scheduler - mainQueue.run() - XCTAssertNoDifference(values, [3]) - } + // Waiting the rest of the time emits the final effect value. + await mainQueue.advance(by: 0.5) + XCTAssertNoDifference(values, [3]) - func testDebounceIsLazy() { - let mainQueue = TestScheduler() - var values: [Int] = [] - var effectRuns = 0 + // Running out the scheduler + await mainQueue.run() + XCTAssertNoDifference(values, [3]) + } - func runDebouncedEffect(value: Int) { - struct CancelToken: Hashable {} + func testDebounceIsLazy() async { + let mainQueue = TestScheduler() + var values: [Int] = [] + var effectRuns = 0 - Effect.deferred { () -> SignalProducer in - effectRuns += 1 - return Effect(value: value) + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runDebouncedEffect(value: Int) { + struct CancelToken: Hashable {} + + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .debounce(id: CancelToken(), for: 1, scheduler: mainQueue) + .producer + .startWithValues { values.append($0) } } - .debounce(id: CancelToken(), for: 1, scheduler: mainQueue) - .startWithValues { values.append($0) } - } - runDebouncedEffect(value: 1) + runDebouncedEffect(value: 1) - XCTAssertNoDifference(values, []) - XCTAssertNoDifference(effectRuns, 0) + XCTAssertNoDifference(values, []) + XCTAssertNoDifference(effectRuns, 0) - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, []) - XCTAssertNoDifference(effectRuns, 0) + XCTAssertNoDifference(values, []) + XCTAssertNoDifference(effectRuns, 0) - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - XCTAssertNoDifference(values, [1]) - XCTAssertNoDifference(effectRuns, 1) + XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(effectRuns, 1) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/EffectDeferredTests.swift b/Tests/ComposableArchitectureTests/EffectDeferredTests.swift index 58160953a..620800bb7 100644 --- a/Tests/ComposableArchitectureTests/EffectDeferredTests.swift +++ b/Tests/ComposableArchitectureTests/EffectDeferredTests.swift @@ -1,15 +1,18 @@ -import ComposableArchitecture import ReactiveSwift import XCTest +@testable import ComposableArchitecture + final class EffectDeferredTests: XCTestCase { - func testDeferred() { + func testDeferred() async { let mainQueue = TestScheduler() var values: [Int] = [] func runDeferredEffect(value: Int) { SignalProducer(value: value) + .eraseToEffect() .deferred(for: 1, scheduler: mainQueue) + .producer .startWithValues { values.append($0) } } @@ -19,43 +22,45 @@ final class EffectDeferredTests: XCTestCase { XCTAssertNoDifference(values, []) // Waiting half the time also emits nothing - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) XCTAssertNoDifference(values, []) // Run another deferred effect. runDeferredEffect(value: 2) // Waiting half the time emits first deferred effect received. - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) XCTAssertNoDifference(values, [1]) // Run another deferred effect. runDeferredEffect(value: 3) // Waiting half the time emits second deferred effect received. - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) XCTAssertNoDifference(values, [1, 2]) // Waiting the rest of the time emits the final effect value. - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) XCTAssertNoDifference(values, [1, 2, 3]) // Running out the scheduler - mainQueue.run() + await mainQueue.run() XCTAssertNoDifference(values, [1, 2, 3]) } - func testDeferredIsLazy() { + func testDeferredIsLazy() async { let mainQueue = TestScheduler() var values: [Int] = [] var effectRuns = 0 func runDeferredEffect(value: Int) { - Effect.deferred { () -> Effect in + SignalProducer.deferred { () -> SignalProducer in effectRuns += 1 - return Effect(value: value) + return SignalProducer(value: value) } + .eraseToEffect() .deferred(for: 1, scheduler: mainQueue) + .producer .startWithValues { values.append($0) } } @@ -64,12 +69,12 @@ final class EffectDeferredTests: XCTestCase { XCTAssertNoDifference(values, []) XCTAssertNoDifference(effectRuns, 0) - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) XCTAssertNoDifference(values, []) XCTAssertNoDifference(effectRuns, 0) - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) XCTAssertNoDifference(values, [1]) XCTAssertNoDifference(effectRuns, 1) diff --git a/Tests/ComposableArchitectureTests/EffectFailureTests.swift b/Tests/ComposableArchitectureTests/EffectFailureTests.swift new file mode 100644 index 000000000..83e90125d --- /dev/null +++ b/Tests/ComposableArchitectureTests/EffectFailureTests.swift @@ -0,0 +1,56 @@ +import ReactiveSwift +import XCTest + +@testable import ComposableArchitecture + +// `XCTExpectFailure` is not supported on Linux +#if !os(Linux) + @MainActor + final class EffectFailureTests: XCTestCase { + func testTaskUnexpectedThrows() { + XCTExpectFailure { + Effect.task { + struct Unexpected: Error {} + throw Unexpected() + } + .producer + .start() + + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + } issueMatcher: { + $0.compactDescription == """ + An 'Effect.task' returned from "ComposableArchitectureTests/EffectFailureTests.swift:12" \ + threw an unhandled error. … + + EffectFailureTests.Unexpected() + + All non-cancellation errors must be explicitly handled via the 'catch' parameter on \ + 'Effect.task', or via a 'do' block. + """ + } + } + + func testRunUnexpectedThrows() { + XCTExpectFailure { + Effect.run { _ in + struct Unexpected: Error {} + throw Unexpected() + } + .producer + .start() + + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + } issueMatcher: { + $0.compactDescription == """ + An 'Effect.run' returned from "ComposableArchitectureTests/EffectFailureTests.swift:35" \ + threw an unhandled error. … + + EffectFailureTests.Unexpected() + + All non-cancellation errors must be explicitly handled via the 'catch' parameter on \ + 'Effect.run', or via a 'do' block. + """ + } + } + } +#endif diff --git a/Tests/ComposableArchitectureTests/EffectRunTests.swift b/Tests/ComposableArchitectureTests/EffectRunTests.swift new file mode 100644 index 000000000..0024754f0 --- /dev/null +++ b/Tests/ComposableArchitectureTests/EffectRunTests.swift @@ -0,0 +1,127 @@ +import XCTest + +@testable import ComposableArchitecture + +#if os(Linux) + import CDispatch +#endif + +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectRunTests: XCTestCase { + func testRun() async { + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .run { send in await send(.response) } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped) + await store.receive(.response) + } + + func testRunCatch() async { + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .run { _ in + struct Failure: Error {} + throw Failure() + } catch: { @Sendable _, send in // NB: Explicit '@Sendable' required in 5.5.2 + await send(.response) + } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped) + await store.receive(.response) + } + + // `XCTExpectFailure` is not supported on Linux + #if !os(Linux) + func testRunUnhandledFailure() async { + XCTExpectFailure(nil, enabled: nil, strict: nil) { + $0.compactDescription == """ + An 'Effect.run' returned from "ComposableArchitectureTests/EffectRunTests.swift:69" threw \ + an unhandled error. … + + EffectRunTests.Failure() + + All non-cancellation errors must be explicitly handled via the 'catch' parameter on \ + 'Effect.run', or via a 'do' block. + """ + } + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .run { send in + struct Failure: Error {} + throw Failure() + } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + // NB: We wait a long time here because XCTest failures take a long time to generate + await store.send(.tapped).finish(timeout: 5 * NSEC_PER_SEC) + } + #endif + + func testRunCancellation() async { + enum CancelID {} + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .run { send in + await Task.cancel(id: CancelID.self) + try Task.checkCancellation() + await send(.response) + } + .cancellable(id: CancelID.self) + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped).finish() + } + + func testRunCancellationCatch() async { + enum CancelID {} + struct State: Equatable {} + enum Action: Equatable { case tapped, responseA, responseB } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .run { send in + await Task.cancel(id: CancelID.self) + try Task.checkCancellation() + await send(.responseA) + } catch: { @Sendable _, send in // NB: Explicit '@Sendable' required in 5.5.2 + await send(.responseB) + } + .cancellable(id: CancelID.self) + case .responseA, .responseB: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped).finish() + } + } +#endif diff --git a/Tests/ComposableArchitectureTests/EffectTaskTests.swift b/Tests/ComposableArchitectureTests/EffectTaskTests.swift new file mode 100644 index 000000000..a91ba9084 --- /dev/null +++ b/Tests/ComposableArchitectureTests/EffectTaskTests.swift @@ -0,0 +1,123 @@ +import XCTest + +@testable import ComposableArchitecture + +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectTaskTests: XCTestCase { + func testTask() async { + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .task { .response } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped) + await store.receive(.response) + } + + func testTaskCatch() async { + struct State: Equatable {} + enum Action: Equatable, Sendable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .task { + struct Failure: Error {} + throw Failure() + } catch: { @Sendable _ in // NB: Explicit '@Sendable' required in 5.5.2 + .response + } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped) + await store.receive(.response) + } + + // `XCTExpectFailure` is not supported on Linux + #if !os(Linux) + func testTaskUnhandledFailure() async { + XCTExpectFailure(nil, enabled: nil, strict: nil) { + $0.compactDescription == """ + An 'Effect.task' returned from "ComposableArchitectureTests/EffectTaskTests.swift:65" \ + threw an unhandled error. … + + EffectTaskTests.Failure() + + All non-cancellation errors must be explicitly handled via the 'catch' parameter on \ + 'Effect.task', or via a 'do' block. + """ + } + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .task { + struct Failure: Error {} + throw Failure() + } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + // NB: We wait a long time here because XCTest failures take a long time to generate + await store.send(.tapped).finish(timeout: 5 * NSEC_PER_SEC) + } + #endif + + func testTaskCancellation() async { + enum CancelID {} + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .task { + await Task.cancel(id: CancelID.self) + try Task.checkCancellation() + return .response + } + .cancellable(id: CancelID.self) + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped).finish() + } + + func testTaskCancellationCatch() async { + enum CancelID {} + struct State: Equatable {} + enum Action: Equatable { case tapped, responseA, responseB } + let reducer = Reducer { state, action, _ in + switch action { + case .tapped: + return .task { + await Task.cancel(id: CancelID.self) + try Task.checkCancellation() + return .responseA + } catch: { @Sendable _ in // NB: Explicit '@Sendable' required in 5.5.2 + .responseB + } + .cancellable(id: CancelID.self) + case .responseA, .responseB: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer, environment: ()) + await store.send(.tapped).finish() + } + } +#endif diff --git a/Tests/ComposableArchitectureTests/EffectTests.swift b/Tests/ComposableArchitectureTests/EffectTests.swift index 798870d5f..7ce1772b2 100644 --- a/Tests/ComposableArchitectureTests/EffectTests.swift +++ b/Tests/ComposableArchitectureTests/EffectTests.swift @@ -3,279 +3,245 @@ import XCTest @testable import ComposableArchitecture -#if os(Linux) - import let CDispatch.NSEC_PER_MSEC -#endif - -final class EffectTests: XCTestCase { - let mainQueue = TestScheduler() - - func testEraseToEffectWithError() { - struct Error: Swift.Error, Equatable {} - - SignalProducer(result: .success(42)) - .startWithResult { XCTAssertNoDifference($0, .success(42)) } - - SignalProducer(result: .failure(Error())) - .startWithResult { XCTAssertNoDifference($0, .failure(Error())) } - - SignalProducer(result: .success(42)) - .startWithResult { XCTAssertNoDifference($0, .success(42)) } - - SignalProducer(result: .success(42)) - .catchToEffect { - switch $0 { - case let .success(val): - return val - case .failure: - return -1 +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectTests: XCTestCase { + let mainQueue = TestScheduler() + + func testEraseToEffectWithError() { + struct Error: Swift.Error, Equatable {} + + SignalProducer(result: .success(42)) + .startWithResult { XCTAssertNoDifference($0, .success(42)) } + + SignalProducer(result: .failure(Error())) + .startWithResult { XCTAssertNoDifference($0, .failure(Error())) } + + SignalProducer(result: .success(42)) + .startWithResult { XCTAssertNoDifference($0, .success(42)) } + + SignalProducer(result: .success(42)) + .catchToEffect { + switch $0 { + case let .success(val): + return val + case .failure: + return -1 + } } - } - .startWithValues { XCTAssertNoDifference($0, 42) } - - SignalProducer(result: .failure(Error())) - .catchToEffect { - switch $0 { - case let .success(val): - return val - case .failure: - return -1 + .producer + .startWithValues { XCTAssertNoDifference($0, 42) } + + SignalProducer(result: .failure(Error())) + .catchToEffect { + switch $0 { + case let .success(val): + return val + case .failure: + return -1 + } } - } - .startWithValues { XCTAssertNoDifference($0, -1) } - } - - func testConcatenate() { - var values: [Int] = [] + .producer + .startWithValues { XCTAssertNoDifference($0, -1) } + } - let effect = Effect.concatenate( - Effect(value: 1).delay(1, on: mainQueue), - Effect(value: 2).delay(2, on: mainQueue), - Effect(value: 3).delay(3, on: mainQueue) - ) + func testConcatenate() { + var values: [Int] = [] - effect.startWithValues { values.append($0) } + let effect = Effect.concatenate( + Effect(value: 1).deferred(for: 1, scheduler: mainQueue), + Effect(value: 2).deferred(for: 2, scheduler: mainQueue), + Effect(value: 3).deferred(for: 3, scheduler: mainQueue) + ) - XCTAssertNoDifference(values, []) + effect.producer.startWithValues { values.append($0) } - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(values, []) - self.mainQueue.advance(by: 2) - XCTAssertNoDifference(values, [1, 2]) + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1]) - self.mainQueue.advance(by: 3) - XCTAssertNoDifference(values, [1, 2, 3]) + self.mainQueue.advance(by: 2) + XCTAssertNoDifference(values, [1, 2]) - self.mainQueue.run() - XCTAssertNoDifference(values, [1, 2, 3]) - } + self.mainQueue.advance(by: 3) + XCTAssertNoDifference(values, [1, 2, 3]) - func testConcatenateOneEffect() { - var values: [Int] = [] + self.mainQueue.run() + XCTAssertNoDifference(values, [1, 2, 3]) + } - let effect = Effect.concatenate( - Effect(value: 1).delay(1, on: mainQueue) - ) + func testConcatenateOneEffect() { + var values: [Int] = [] - effect.startWithValues { values.append($0) } + let effect = Effect.concatenate( + Effect(value: 1).deferred(for: 1, scheduler: mainQueue) + ) - XCTAssertNoDifference(values, []) + effect.producer.startWithValues { values.append($0) } - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(values, []) - self.mainQueue.run() - XCTAssertNoDifference(values, [1]) - } + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1]) - func testMerge() { - let effect = Effect.merge( - Effect(value: 1).delay(1, on: mainQueue), - Effect(value: 2).delay(2, on: mainQueue), - Effect(value: 3).delay(3, on: mainQueue) - ) + self.mainQueue.run() + XCTAssertNoDifference(values, [1]) + } - var values: [Int] = [] - effect.startWithValues { values.append($0) } + func testMerge() { + let effect = Effect.merge( + Effect(value: 1).deferred(for: 1, scheduler: mainQueue), + Effect(value: 2).deferred(for: 2, scheduler: mainQueue), + Effect(value: 3).deferred(for: 3, scheduler: mainQueue) + ) - XCTAssertNoDifference(values, []) + var values: [Int] = [] + effect.producer.startWithValues { values.append($0) } - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(values, []) - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1, 2]) + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1]) - self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1, 2, 3]) - } + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1, 2]) - func testEffectSubscriberInitializer() { - let effect = Effect { subscriber, _ in - subscriber.send(value: 1) - subscriber.send(value: 2) - self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { - subscriber.send(value: 3) - } - self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(2)) { - subscriber.send(value: 4) - subscriber.sendCompleted() - } + self.mainQueue.advance(by: 1) + XCTAssertNoDifference(values, [1, 2, 3]) } - var values: [Int] = [] - var isComplete = false - effect - .on(completed: { isComplete = true }, value: { values.append($0) }) - .start() + func testEffectRunInitializer() { + let effect = Effect.run { observer in + observer.send(value: 1) + observer.send(value: 2) + self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { + observer.send(value: 3) + } + self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(2)) { + observer.send(value: 4) + observer.sendCompleted() + } - XCTAssertNoDifference(values, [1, 2]) - XCTAssertNoDifference(isComplete, false) + return AnyDisposable() + } - self.mainQueue.advance(by: 1) + var values: [Int] = [] + var isComplete = false + effect + .producer + .on(completed: { isComplete = true }, value: { values.append($0) }) + .start() - XCTAssertNoDifference(values, [1, 2, 3]) - XCTAssertNoDifference(isComplete, false) + XCTAssertNoDifference(values, [1, 2]) + XCTAssertNoDifference(isComplete, false) - self.mainQueue.advance(by: 1) + self.mainQueue.advance(by: 1) - XCTAssertNoDifference(values, [1, 2, 3, 4]) - XCTAssertNoDifference(isComplete, true) - } + XCTAssertNoDifference(values, [1, 2, 3]) + XCTAssertNoDifference(isComplete, false) - func testEffectSubscriberInitializer_WithCancellation() { - enum CancelId {} + self.mainQueue.advance(by: 1) - let effect = Effect { subscriber, _ in - subscriber.send(value: 1) - self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { - subscriber.send(value: 2) - } + XCTAssertNoDifference(values, [1, 2, 3, 4]) + XCTAssertNoDifference(isComplete, true) } - .cancellable(id: CancelId.self) - var values: [Int] = [] - var isComplete = false - effect - .on(completed: { isComplete = true }) - .startWithValues { values.append($0) } + func testEffectRunInitializer_WithCancellation() { + enum CancelID {} - XCTAssertNoDifference(values, [1]) - XCTAssertNoDifference(isComplete, false) - - Effect.cancel(id: CancelId.self) - .startWithValues { _ in } + let effect = Effect.run { subscriber in + subscriber.send(value: 1) + self.mainQueue.schedule(after: self.mainQueue.currentDate.addingTimeInterval(1)) { + subscriber.send(value: 2) + } + return AnyDisposable() + } + .cancellable(id: CancelID.self) - self.mainQueue.advance(by: 1) + var values: [Int] = [] + var isComplete = false + effect + .producer + .on(completed: { isComplete = true }) + .startWithValues { values.append($0) } - XCTAssertNoDifference(values, [1]) - XCTAssertNoDifference(isComplete, true) - } + XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(isComplete, false) - func testDoubleCancelInFlight() { - var result: Int? + Effect.cancel(id: CancelID.self) + .producer + .startWithValues { _ in } - _ = Effect(value: 42) - .cancellable(id: "id", cancelInFlight: true) - .cancellable(id: "id", cancelInFlight: true) - .startWithValues { result = $0 } + self.mainQueue.advance(by: 1) - XCTAssertEqual(result, 42) - } - - #if !os(Linux) - func testUnimplemented() { - let effect = Effect.failing("unimplemented") - _ = XCTExpectFailure { - effect - .start() - } issueMatcher: { issue in - issue.compactDescription == "unimplemented - An unimplemented effect ran." - } + XCTAssertNoDifference(values, [1]) + XCTAssertNoDifference(isComplete, true) } - #endif - #if canImport(_Concurrency) && compiler(>=5.5.2) - func testTask() { - let expectation = self.expectation(description: "Complete") + func testDoubleCancelInFlight() { var result: Int? - Effect.task { @MainActor in - expectation.fulfill() - return 42 - } - .startWithValues { result = $0 } - self.wait(for: [expectation], timeout: 1) - XCTAssertNoDifference(result, 42) - } - func testThrowingTask() { - let expectation = self.expectation(description: "Complete") - struct MyError: Error {} - var result: Error? - let disposable = Effect.task { @MainActor in - expectation.fulfill() - throw MyError() - } - .on( - failed: { error in - result = error - }, - completed: { - XCTFail() - }, - value: { _ in - XCTFail() - } - ) - .start() + _ = Effect(value: 42) + .cancellable(id: "id", cancelInFlight: true) + .cancellable(id: "id", cancelInFlight: true) + .producer + .startWithValues { result = $0 } - self.wait(for: [expectation], timeout: 1) - XCTAssertNotNil(result) - disposable.dispose() + XCTAssertEqual(result, 42) } - func testCancellingTask_Failable() { - @Sendable func work() async throws -> Int { - try await Task.sleep(nanoseconds: NSEC_PER_MSEC) - XCTFail() - return 42 + #if !os(Linux) + func testUnimplemented() { + let effect = Effect.failing("unimplemented") + _ = XCTExpectFailure { + effect + .producer + .start() + } issueMatcher: { issue in + issue.compactDescription == "unimplemented - An unimplemented effect ran." + } } - - let disposable = Effect.task { try await work() } - .on( - completed: { XCTFail() }, - value: { _ in XCTFail() } - ) - .start(on: QueueScheduler.main) - .start() - - disposable.dispose() - - _ = XCTWaiter.wait(for: [.init()], timeout: 1.1) - } - - func testCancellingTask_Infalable() { - @Sendable func work() async -> Int { - do { - try await Task.sleep(nanoseconds: NSEC_PER_MSEC) - XCTFail() - } catch { + #endif + + #if canImport(_Concurrency) && compiler(>=5.5.2) + func testTask() { + let expectation = self.expectation(description: "Complete") + var result: Int? + Effect.task { @MainActor in + expectation.fulfill() + return 42 } - return 42 + .producer + .startWithValues { result = $0 } + self.wait(for: [expectation], timeout: 1) + XCTAssertNoDifference(result, 42) } - let disposable = Effect.task { await work() } - .on( - completed: { XCTFail() }, - value: { _ in XCTFail() } - ) - .start(on: QueueScheduler.main) - .start() + func testCancellingTask_Infallible() { + @Sendable func work() async -> Int { + do { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC) + XCTFail() + } catch { + } + return 42 + } + + let disposable = Effect.task { await work() } + .producer + .on( + completed: { XCTFail() }, + value: { _ in XCTFail() } + ) + .start(on: QueueScheduler.main) + .start() - disposable.dispose() + disposable.dispose() - _ = XCTWaiter.wait(for: [.init()], timeout: 1.1) - } - #endif -} + _ = XCTWaiter.wait(for: [.init()], timeout: 1.1) + } + #endif + } +#endif diff --git a/Tests/ComposableArchitectureTests/EffectThrottleTests.swift b/Tests/ComposableArchitectureTests/EffectThrottleTests.swift index 3057db603..61c814ece 100644 --- a/Tests/ComposableArchitectureTests/EffectThrottleTests.swift +++ b/Tests/ComposableArchitectureTests/EffectThrottleTests.swift @@ -3,196 +3,212 @@ import XCTest @testable import ComposableArchitecture -final class EffectThrottleTests: XCTestCase { - let mainQueue = TestScheduler() - - func testThrottleLatest() { - var values: [Int] = [] - var effectRuns = 0 - - func runThrottledEffect(value: Int) { - enum CancelToken {} - - Effect.deferred { () -> Effect in - effectRuns += 1 - return .init(value: value) +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class EffectThrottleTests: XCTestCase { + let mainQueue = TestScheduler() + + func testThrottleLatest() async { + var values: [Int] = [] + var effectRuns = 0 + + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runThrottledEffect(value: Int) { + enum CancelToken {} + + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: true) + .producer + .startWithValues { values.append($0) } } - .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: true) - .startWithValues { values.append($0) } - } - - runThrottledEffect(value: 1) - mainQueue.advance() + runThrottledEffect(value: 1) - // A value emits right away. - XCTAssertNoDifference(values, [1]) + await mainQueue.advance() - runThrottledEffect(value: 2) + // A value emits right away. + XCTAssertNoDifference(values, [1]) - mainQueue.advance() + runThrottledEffect(value: 2) - // A second value is throttled. - XCTAssertNoDifference(values, [1]) + await mainQueue.advance() - mainQueue.advance(by: 0.25) + // A second value is throttled. + XCTAssertNoDifference(values, [1]) - runThrottledEffect(value: 3) + await mainQueue.advance(by: 0.25) - mainQueue.advance(by: 0.25) + runThrottledEffect(value: 3) - runThrottledEffect(value: 4) + await mainQueue.advance(by: 0.25) - mainQueue.advance(by: 0.25) + runThrottledEffect(value: 4) - runThrottledEffect(value: 5) + await mainQueue.advance(by: 0.25) - // A third value is throttled. - XCTAssertNoDifference(values, [1]) + runThrottledEffect(value: 5) - mainQueue.advance(by: 0.25) + // A third value is throttled. + XCTAssertNoDifference(values, [1]) - // The latest value emits. - XCTAssertNoDifference(values, [1, 5]) - } - - func testThrottleFirst() { - var values: [Int] = [] - var effectRuns = 0 - - func runThrottledEffect(value: Int) { - enum CancelToken {} + await mainQueue.advance(by: 0.25) - Effect.deferred { () -> Effect in - effectRuns += 1 - return .init(value: value) - } - .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: false) - .startWithValues { values.append($0) } + // The latest value emits. + XCTAssertNoDifference(values, [1, 5]) } - runThrottledEffect(value: 1) - - mainQueue.advance() - - // A value emits right away. - XCTAssertNoDifference(values, [1]) + func testThrottleFirst() async { + var values: [Int] = [] + var effectRuns = 0 + + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runThrottledEffect(value: Int) { + enum CancelToken {} + + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: false) + .producer + .startWithValues { values.append($0) } + } - runThrottledEffect(value: 2) + runThrottledEffect(value: 1) - mainQueue.advance() + await mainQueue.advance() - // A second value is throttled. - XCTAssertNoDifference(values, [1]) + // A value emits right away. + XCTAssertNoDifference(values, [1]) - mainQueue.advance(by: 0.25) + runThrottledEffect(value: 2) - runThrottledEffect(value: 3) + await mainQueue.advance() - mainQueue.advance(by: 0.25) + // A second value is throttled. + XCTAssertNoDifference(values, [1]) - runThrottledEffect(value: 4) + await mainQueue.advance(by: 0.25) - mainQueue.advance(by: 0.25) + runThrottledEffect(value: 3) - runThrottledEffect(value: 5) + await mainQueue.advance(by: 0.25) - mainQueue.advance(by: 0.25) + runThrottledEffect(value: 4) - // The second (throttled) value emits. - XCTAssertNoDifference(values, [1, 2]) + await mainQueue.advance(by: 0.25) - mainQueue.advance(by: 0.25) + runThrottledEffect(value: 5) - runThrottledEffect(value: 6) + await mainQueue.advance(by: 0.25) - mainQueue.advance(by: 0.50) + // The second (throttled) value emits. + XCTAssertNoDifference(values, [1, 2]) - // A third value is throttled. - XCTAssertNoDifference(values, [1, 2]) + await mainQueue.advance(by: 0.25) - runThrottledEffect(value: 7) + runThrottledEffect(value: 6) - mainQueue.advance(by: 0.25) + await mainQueue.advance(by: 0.50) - // The third (throttled) value emits. - XCTAssertNoDifference(values, [1, 2, 6]) - } + // A third value is throttled. + XCTAssertNoDifference(values, [1, 2]) - func testThrottleAfterInterval() { - var values: [Int] = [] - var effectRuns = 0 + runThrottledEffect(value: 7) - func runThrottledEffect(value: Int) { - enum CancelToken {} + await mainQueue.advance(by: 0.25) - Effect.deferred { () -> Effect in - effectRuns += 1 - return .init(value: value) - } - .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: true) - .startWithValues { values.append($0) } + // The third (throttled) value emits. + XCTAssertNoDifference(values, [1, 2, 6]) } - runThrottledEffect(value: 1) - - mainQueue.advance() + func testThrottleAfterInterval() async { + var values: [Int] = [] + var effectRuns = 0 + + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runThrottledEffect(value: Int) { + enum CancelToken {} + + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .throttle(id: CancelToken.self, for: 1, scheduler: mainQueue, latest: true) + .producer + .startWithValues { values.append($0) } + } - // A value emits right away. - XCTAssertNoDifference(values, [1]) + runThrottledEffect(value: 1) - mainQueue.advance(by: 2) + await mainQueue.advance() - runThrottledEffect(value: 2) + // A value emits right away. + XCTAssertNoDifference(values, [1]) - mainQueue.advance() + await mainQueue.advance(by: 2) - // A second value is emitted right away. - XCTAssertNoDifference(values, [1, 2]) + runThrottledEffect(value: 2) - mainQueue.advance(by: 2) + await mainQueue.advance() - runThrottledEffect(value: 3) + // A second value is emitted right away. + XCTAssertNoDifference(values, [1, 2]) - mainQueue.advance() + await mainQueue.advance(by: 2) - // A third value is emitted right away. - XCTAssertNoDifference(values, [1, 2, 3]) - } + runThrottledEffect(value: 3) - func testThrottleEmitsFirstValueOnce() { - var values: [Int] = [] - var effectRuns = 0 + await mainQueue.advance() - func runThrottledEffect(value: Int) { - enum CancelToken {} + // A third value is emitted right away. + XCTAssertNoDifference(values, [1, 2, 3]) + } - Effect.deferred { () -> Effect in - effectRuns += 1 - return .init(value: value) + func testThrottleEmitsFirstValueOnce() async { + var values: [Int] = [] + var effectRuns = 0 + + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runThrottledEffect(value: Int) { + enum CancelToken {} + + SignalProducer.deferred { () -> SignalProducer in + effectRuns += 1 + return .init(value: value) + } + .eraseToEffect() + .throttle( + id: CancelToken.self, for: 1, scheduler: mainQueue, latest: false + ) + .producer + .startWithValues { values.append($0) } } - .throttle( - id: CancelToken.self, for: 1, scheduler: mainQueue, latest: false - ) - .startWithValues { values.append($0) } - } - runThrottledEffect(value: 1) + runThrottledEffect(value: 1) - mainQueue.advance() + await mainQueue.advance() - // A value emits right away. - XCTAssertNoDifference(values, [1]) + // A value emits right away. + XCTAssertNoDifference(values, [1]) - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - runThrottledEffect(value: 2) + runThrottledEffect(value: 2) - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - runThrottledEffect(value: 3) + runThrottledEffect(value: 3) - // A second value is emitted right away. - XCTAssertNoDifference(values, [1, 2]) + // A second value is emitted right away. + XCTAssertNoDifference(values, [1, 2]) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/ReducerTests.swift b/Tests/ComposableArchitectureTests/ReducerTests.swift index 523dc3aa5..05355c224 100644 --- a/Tests/ComposableArchitectureTests/ReducerTests.swift +++ b/Tests/ComposableArchitectureTests/ReducerTests.swift @@ -1,225 +1,228 @@ -import ComposableArchitecture import CustomDump import ReactiveSwift import XCTest -#if canImport(os) - import os.signpost -#endif - -final class ReducerTests: XCTestCase { - func testCallableAsFunction() { - let reducer = Reducer { state, _, _ in - state += 1 - return .none - } - - var state = 0 - _ = reducer.run(&state, (), ()) - XCTAssertNoDifference(state, 1) - } - - func testCombine_EffectsAreMerged() { - typealias Scheduler = DateScheduler - enum Action: Equatable { - case increment - } - - var fastValue: Int? - let fastReducer = Reducer { state, _, scheduler in - state += 1 - return Effect.fireAndForget { fastValue = 42 } - .delay(1, on: scheduler) - } - - var slowValue: Int? - let slowReducer = Reducer { state, _, scheduler in - state += 1 - return Effect.fireAndForget { slowValue = 1729 } - .delay(2, on: scheduler) - } +@testable import ComposableArchitecture - let mainQueue = TestScheduler() - let store = TestStore( - initialState: 0, - reducer: .combine(fastReducer, slowReducer), - environment: mainQueue - ) +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class ReducerTests: XCTestCase { + func testCallableAsFunction() { + let reducer = Reducer { state, _, _ in + state += 1 + return .none + } - store.send(.increment) { - $0 = 2 + var state = 0 + _ = reducer.run(&state, (), ()) + XCTAssertNoDifference(state, 1) } - // Waiting a second causes the fast effect to fire. - mainQueue.advance(by: 1) - XCTAssertNoDifference(fastValue, 42) - // Waiting one more second causes the slow effect to fire. This proves that the effects - // are merged together, as opposed to concatenated. - mainQueue.advance(by: 1) - XCTAssertNoDifference(slowValue, 1729) - } - func testCombine() { - enum Action: Equatable { - case increment - } + func testCombine_EffectsAreMerged() async { + typealias Scheduler = DateScheduler + enum Action: Equatable { + case increment + } - var childEffectExecuted = false - let childReducer = Reducer { state, _, _ in - state += 1 - return Effect.fireAndForget { childEffectExecuted = true } - } + var fastValue: Int? + let fastReducer = Reducer { state, _, scheduler in + state += 1 + return Effect.fireAndForget { fastValue = 42 } + .deferred(for: 1, scheduler: scheduler) + } - var mainEffectExecuted = false - let mainReducer = Reducer { state, _, _ in - state += 1 - return Effect.fireAndForget { mainEffectExecuted = true } - } - .combined(with: childReducer) + var slowValue: Int? + let slowReducer = Reducer { state, _, scheduler in + state += 1 + return Effect.fireAndForget { slowValue = 1729 } + .deferred(for: 2, scheduler: scheduler) + } - let store = TestStore( - initialState: 0, - reducer: mainReducer, - environment: () - ) + let mainQueue = TestScheduler() + let store = TestStore( + initialState: 0, + reducer: .combine(fastReducer, slowReducer), + environment: mainQueue + ) - store.send(.increment) { - $0 = 2 + await store.send(.increment) { + $0 = 2 + } + // Waiting a second causes the fast effect to fire. + await mainQueue.advance(by: 1) + XCTAssertNoDifference(fastValue, 42) + // Waiting one more second causes the slow effect to fire. This proves that the effects + // are merged together, as opposed to concatenated. + await mainQueue.advance(by: 1) + XCTAssertNoDifference(slowValue, 1729) } - XCTAssertTrue(childEffectExecuted) - XCTAssertTrue(mainEffectExecuted) - } - - func testDebug() { - var logs: [String] = [] - let logsExpectation = self.expectation(description: "logs") - logsExpectation.expectedFulfillmentCount = 2 + func testCombine() async { + enum Action: Equatable { + case increment + } - let reducer = Reducer { state, action, _ in - switch action { - case .incrWithBool: - return .none - case .incr: - state.count += 1 - return .none - case .noop: - return .none + var childEffectExecuted = false + let childReducer = Reducer { state, _, _ in + state += 1 + return Effect.fireAndForget { childEffectExecuted = true } } - } - .debug("[prefix]") { _ in - DebugEnvironment( - printer: { - logs.append($0) - logsExpectation.fulfill() - } - ) - } - let store = TestStore( - initialState: .init(), - reducer: reducer, - environment: () - ) - store.send(.incr) { $0.count = 1 } - store.send(.noop) - - self.wait(for: [logsExpectation], timeout: 5) - - XCTAssertNoDifference( - logs, - [ - #""" - [prefix]: received action: - DebugAction.incr - - DebugState(count: 0) - + DebugState(count: 1) - - """#, - #""" - [prefix]: received action: - DebugAction.noop - (No state changes) - - """#, - ] - ) - } + var mainEffectExecuted = false + let mainReducer = Reducer { state, _, _ in + state += 1 + return Effect.fireAndForget { mainEffectExecuted = true } + } + .combined(with: childReducer) - func testDebug_ActionFormat_OnlyLabels() { - var logs: [String] = [] - let logsExpectation = self.expectation(description: "logs") + let store = TestStore( + initialState: 0, + reducer: mainReducer, + environment: () + ) - let reducer = Reducer { state, action, _ in - switch action { - case let .incrWithBool(bool): - state.count += bool ? 1 : 0 - return .none - default: - return .none + await store.send(.increment) { + $0 = 2 } + + XCTAssertTrue(childEffectExecuted) + XCTAssertTrue(mainEffectExecuted) } - .debug("[prefix]", actionFormat: .labelsOnly) { _ in - DebugEnvironment( - printer: { - logs.append($0) - logsExpectation.fulfill() + + func testDebug() async { + var logs: [String] = [] + let logsExpectation = self.expectation(description: "logs") + logsExpectation.expectedFulfillmentCount = 2 + + let reducer = Reducer { state, action, _ in + switch action { + case .incrWithBool: + return .none + case .incr: + state.count += 1 + return .none + case .noop: + return .none } - ) - } + } + .debug("[prefix]") { _ in + DebugEnvironment( + printer: { + logs.append($0) + logsExpectation.fulfill() + } + ) + } - let viewStore = ViewStore( - Store( + let store = TestStore( initialState: .init(), reducer: reducer, environment: () ) - ) - viewStore.send(.incrWithBool(true)) - - self.wait(for: [logsExpectation], timeout: 5) - - XCTAssertNoDifference( - logs, - [ - #""" - [prefix]: received action: - DebugAction.incrWithBool - - DebugState(count: 0) - + DebugState(count: 1) - - """# - ] - ) - } - - #if canImport(os) - @available(iOS 12.0, *) - func testDefaultSignpost() { - let reducer = Reducer.empty.signpost(log: .default) - var n = 0 - let effect = reducer.run(&n, (), ()) - let expectation = self.expectation(description: "effect") - effect - .startWithCompleted { - expectation.fulfill() - } - self.wait(for: [expectation], timeout: 0.1) + await store.send(.incr) { $0.count = 1 } + await store.send(.noop) + + self.wait(for: [logsExpectation], timeout: 5) + + XCTAssertNoDifference( + logs, + [ + #""" + [prefix]: received action: + DebugAction.incr + - DebugState(count: 0) + + DebugState(count: 1) + + """#, + #""" + [prefix]: received action: + DebugAction.noop + (No state changes) + + """#, + ] + ) } - @available(iOS 12.0, *) - func testDisabledSignpost() { - let reducer = Reducer.empty.signpost(log: .disabled) - var n = 0 - let effect = reducer.run(&n, (), ()) - let expectation = self.expectation(description: "effect") - effect - .startWithCompleted { - expectation.fulfill() + func testDebug_ActionFormat_OnlyLabels() { + var logs: [String] = [] + let logsExpectation = self.expectation(description: "logs") + + let reducer = Reducer { state, action, _ in + switch action { + case let .incrWithBool(bool): + state.count += bool ? 1 : 0 + return .none + default: + return .none } - self.wait(for: [expectation], timeout: 0.1) + } + .debug("[prefix]", actionFormat: .labelsOnly) { _ in + DebugEnvironment( + printer: { + logs.append($0) + logsExpectation.fulfill() + } + ) + } + + let viewStore = ViewStore( + Store( + initialState: .init(), + reducer: reducer, + environment: () + ) + ) + viewStore.send(.incrWithBool(true)) + + self.wait(for: [logsExpectation], timeout: 5) + + XCTAssertNoDifference( + logs, + [ + #""" + [prefix]: received action: + DebugAction.incrWithBool + - DebugState(count: 0) + + DebugState(count: 1) + + """# + ] + ) } - #endif -} + + #if canImport(os) + @available(iOS 12.0, *) + func testDefaultSignpost() { + let reducer = Reducer.empty.signpost(log: .default) + var n = 0 + let effect = reducer.run(&n, (), ()) + let expectation = self.expectation(description: "effect") + effect + .producer + .startWithCompleted { + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 0.1) + } + + @available(iOS 12.0, *) + func testDisabledSignpost() { + let reducer = Reducer.empty.signpost(log: .disabled) + var n = 0 + let effect = reducer.run(&n, (), ()) + let expectation = self.expectation(description: "effect") + effect + .producer + .startWithCompleted { + expectation.fulfill() + } + self.wait(for: [expectation], timeout: 0.1) + } + #endif + } +#endif enum DebugAction: Equatable { case incrWithBool(Bool) diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index 1329b25c9..dc6bb020b 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -1,8 +1,9 @@ -import ComposableArchitecture import ReactiveSwift import XCTest -// `XCTExpectFailure` is not supported on Linux +@testable import ComposableArchitecture + +// `XCTExpectFailure` is not supported on Linux / `@MainActor` introduces issues gathering tests on Linux #if !os(Linux) final class RuntimeWarningTests: XCTestCase { func testStoreCreationMainThread() { @@ -43,10 +44,14 @@ import XCTest reducer: Reducer { state, action, _ in switch action { case .tap: - return SignalProducer.none + return Effect.none + .producer .observe( on: QueueScheduler( - qos: .userInitiated, name: "test", targeting: DispatchQueue(label: "background"))) + qos: .userInitiated, name: "test", targeting: DispatchQueue(label: "background") + ) + ) + .eraseToEffect() case .response: return .none } @@ -115,6 +120,76 @@ import XCTest _ = XCTWaiter.wait(for: [.init()], timeout: 2) } + @MainActor + func testEffectEmitMainThread() async { + XCTExpectFailure { + [ + """ + An effect completed on a non-main thread. … + + Effect returned from: + Action.response + + Make sure to use ".receive(on:)" on any effects that execute on background threads to \ + receive their output on the main thread. + + The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \ + (including all of its scopes and derived view stores) must be done on the main thread. + """, + """ + An effect completed on a non-main thread. … + + Effect returned from: + Action.tap + + Make sure to use ".receive(on:)" on any effects that execute on background threads to \ + receive their output on the main thread. + + The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \ + (including all of its scopes and derived view stores) must be done on the main thread. + """, + """ + An effect published an action on a non-main thread. … + + Effect published: + Action.response + + Effect returned from: + Action.tap + + Make sure to use ".receive(on:)" on any effects that execute on background threads to \ + receive their output on the main thread. + + The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \ + (including all of its scopes and derived view stores) must be done on the main thread. + """, + ] + .contains($0.compactDescription) + } + + enum Action { case tap, response } + let store = Store( + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case .tap: + return .run { observer in + Thread.detachNewThread { + XCTAssertFalse(Thread.isMainThread, "Effect should send on non-main thread.") + observer.send(value: .response) + observer.sendCompleted() + } + return AnyDisposable {} + } + case .response: + return .none + } + }, + environment: () + ) + await ViewStore(store).send(.tap).finish() + } + func testBindingUnhandledAction() { struct State: Equatable { @BindableState var value = 0 @@ -134,7 +209,8 @@ import XCTest ViewStore(store).binding(\.$value).wrappedValue = 42 } issueMatcher: { $0.compactDescription == """ - A binding action sent from a view store at "ComposableArchitectureTests/RuntimeWarningTests.swift:\(line+1)" was not handled: + A binding action sent from a view store at \ + "ComposableArchitectureTests/RuntimeWarningTests.swift:\(line+1)" was not handled. … Action: Action.binding(.set(_, 42)) diff --git a/Tests/ComposableArchitectureTests/SchedulerTests.swift b/Tests/ComposableArchitectureTests/SchedulerTests.swift index 47800b23a..29621b075 100644 --- a/Tests/ComposableArchitectureTests/SchedulerTests.swift +++ b/Tests/ComposableArchitectureTests/SchedulerTests.swift @@ -1,95 +1,96 @@ -import ComposableArchitecture import ReactiveSwift import XCTest +@testable import ComposableArchitecture + final class SchedulerTests: XCTestCase { - func testAdvance() { + func testAdvance() async { let mainQueue = TestScheduler() var value: Int? - Effect(value: 1) + SignalProducer(value: 1) .delay(1, on: mainQueue) .startWithValues { value = $0 } XCTAssertNoDifference(value, nil) - mainQueue.advance(by: 0.25) + await mainQueue.advance(by: 0.25) XCTAssertNoDifference(value, nil) - mainQueue.advance(by: 0.25) + await mainQueue.advance(by: 0.25) XCTAssertNoDifference(value, nil) - mainQueue.advance(by: 0.25) + await mainQueue.advance(by: 0.25) XCTAssertNoDifference(value, nil) - mainQueue.advance(by: 0.25) + await mainQueue.advance(by: 0.25) XCTAssertNoDifference(value, 1) } - func testRunScheduler() { + func testRunScheduler() async { let mainQueue = TestScheduler() var value: Int? - Effect(value: 1) + SignalProducer(value: 1) .delay(1_000_000_000, on: mainQueue) .startWithValues { value = $0 } XCTAssertNoDifference(value, nil) - mainQueue.advance(by: .seconds(1_000_000)) + await mainQueue.advance(by: .seconds(1_000_000)) XCTAssertNoDifference(value, nil) - mainQueue.run() + await mainQueue.run() XCTAssertNoDifference(value, 1) } - func testDelay0Advance() { + func testDelay0Advance() async { let mainQueue = TestScheduler() var value: Int? - Effect(value: 1) + SignalProducer(value: 1) .delay(0, on: mainQueue) .startWithValues { value = $0 } XCTAssertNoDifference(value, nil) - mainQueue.advance() + await mainQueue.advance() XCTAssertNoDifference(value, 1) } - func testSubscribeOnAdvance() { + func testSubscribeOnAdvance() async { let mainQueue = TestScheduler() var value: Int? - Effect(value: 1) + SignalProducer(value: 1) .start(on: mainQueue) .startWithValues { value = $0 } XCTAssertNoDifference(value, nil) - mainQueue.advance() + await mainQueue.advance() XCTAssertNoDifference(value, 1) } - func testReceiveOnAdvance() { + func testReceiveOnAdvance() async { let mainQueue = TestScheduler() var value: Int? - Effect(value: 1) + SignalProducer(value: 1) .observe(on: mainQueue) .startWithValues { value = $0 } XCTAssertNoDifference(value, nil) - mainQueue.advance() + await mainQueue.advance() XCTAssertNoDifference(value, 1) } @@ -110,7 +111,7 @@ final class SchedulerTests: XCTestCase { XCTAssertNoDifference(values, [1, 42, 42, 1, 42]) } - func testDebounceReceiveOn() { + func testDebounceReceiveOn() async { let mainQueue = TestScheduler() let subject = Signal.pipe() @@ -126,13 +127,13 @@ final class SchedulerTests: XCTestCase { subject.input.send(value: ()) XCTAssertNoDifference(count, 0) - mainQueue.advance(by: 1) + await mainQueue.advance(by: 1) XCTAssertNoDifference(count, 1) - mainQueue.advance(by: 1) + await mainQueue.advance(by: 1) XCTAssertNoDifference(count, 1) - mainQueue.run() + await mainQueue.run() XCTAssertNoDifference(count, 1) } } diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index a5f39fed1..bb543c386 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -3,498 +3,552 @@ import XCTest @testable import ComposableArchitecture -final class StoreTests: XCTestCase { +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class StoreTests: XCTestCase { + + func testProducedMapping() { + struct ChildState: Equatable { + var value: Int = 0 + } + struct ParentState: Equatable { + var child: ChildState = .init() + } - func testProducedMapping() { - struct ChildState: Equatable { - var value: Int = 0 - } - struct ParentState: Equatable { - var child: ChildState = .init() + let store = Store( + initialState: ParentState(), + reducer: Reducer { state, _, _ in + state.child.value += 1 + return .none + }, + environment: () + ) + + let viewStore = ViewStore(store) + var values: [Int] = [] + + viewStore.produced.child.value.startWithValues { value in + values.append(value) + } + + viewStore.send(()) + viewStore.send(()) + viewStore.send(()) + + XCTAssertNoDifference(values, [0, 1, 2, 3]) } - let store = Store( - initialState: ParentState(), - reducer: Reducer { state, _, _ in - state.child.value += 1 - return .none - }, - environment: () - ) + func testCancellableIsRemovedOnImmediatelyCompletingEffect() { + let reducer = Reducer { _, _, _ in .none } + let store = Store(initialState: (), reducer: reducer, environment: ()) + + XCTAssertNoDifference(store.effectDisposables.count, 0) - let viewStore = ViewStore(store) - var values: [Int] = [] + _ = store.send(()) - viewStore.produced.child.value.startWithValues { value in - values.append(value) + XCTAssertNoDifference(store.effectDisposables.count, 0) } - viewStore.send(()) - viewStore.send(()) - viewStore.send(()) + func testCancellableIsRemovedWhenEffectCompletes() async { + let mainQueue = TestScheduler() + let effect = SignalProducer(value: ()) + .delay(1, on: mainQueue) + .eraseToEffect() - XCTAssertNoDifference(values, [0, 1, 2, 3]) - } + enum Action { case start, end } - func testCancellableIsRemovedOnImmediatelyCompletingEffect() { - let reducer = Reducer { _, _, _ in .none } - let store = Store(initialState: (), reducer: reducer, environment: ()) + let reducer = Reducer { _, action, _ in + switch action { + case .start: + return effect.map { .end } + case .end: + return .none + } + } + let store = Store(initialState: (), reducer: reducer, environment: ()) - XCTAssertNoDifference(store.effectDisposables.count, 0) + XCTAssertNoDifference(store.effectDisposables.count, 0) - store.send(()) + _ = store.send(.start) - XCTAssertNoDifference(store.effectDisposables.count, 0) - } + XCTAssertNoDifference(store.effectDisposables.count, 1) - func testCancellableIsRemovedWhenEffectCompletes() { - let mainQueue = TestScheduler() - let effect = Effect(value: ()) - .delay(1, on: mainQueue) + await mainQueue.advance(by: 2) - enum Action { case start, end } + XCTAssertNoDifference(store.effectDisposables.count, 0) + } - let reducer = Reducer { _, action, _ in - switch action { - case .start: - return effect.map { .end } - case .end: + func testScopedStoreReceivesUpdatesFromParent() { + let counterReducer = Reducer { state, _, _ in + state += 1 return .none } - } - let store = Store(initialState: (), reducer: reducer, environment: ()) - XCTAssertNoDifference(store.effectDisposables.count, 0) + let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) + let parentViewStore = ViewStore(parentStore) + let childStore = parentStore.scope(state: String.init) - store.send(.start) + var values: [String] = [] + childStore.producer + .startWithValues { values.append($0) } - XCTAssertNoDifference(store.effectDisposables.count, 1) + XCTAssertNoDifference(values, ["0"]) - mainQueue.advance(by: 2) - - XCTAssertNoDifference(store.effectDisposables.count, 0) - } + parentViewStore.send(()) - func testScopedStoreReceivesUpdatesFromParent() { - let counterReducer = Reducer { state, _, _ in - state += 1 - return .none + XCTAssertNoDifference(values, ["0", "1"]) } - let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) - let parentViewStore = ViewStore(parentStore) - let childStore = parentStore.scope(state: String.init) + func testParentStoreReceivesUpdatesFromChild() { + let counterReducer = Reducer { state, _, _ in + state += 1 + return .none + } - var values: [String] = [] - childStore.producer - .startWithValues { values.append($0) } + let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) + let childStore = parentStore.scope(state: String.init) + let childViewStore = ViewStore(childStore) - XCTAssertNoDifference(values, ["0"]) + var values: [Int] = [] + parentStore.producer + .startWithValues { values.append($0) } - parentViewStore.send(()) + XCTAssertNoDifference(values, [0]) - XCTAssertNoDifference(values, ["0", "1"]) - } + childViewStore.send(()) - func testParentStoreReceivesUpdatesFromChild() { - let counterReducer = Reducer { state, _, _ in - state += 1 - return .none + XCTAssertNoDifference(values, [0, 1]) } - let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) - let childStore = parentStore.scope(state: String.init) - let childViewStore = ViewStore(childStore) + func testScopeCallCount() { + let counterReducer = Reducer { state, _, _ in state += 1 + return .none + } - var values: [Int] = [] - parentStore.producer - .startWithValues { values.append($0) } + var numCalls1 = 0 + _ = Store(initialState: 0, reducer: counterReducer, environment: ()) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) - XCTAssertNoDifference(values, [0]) + XCTAssertNoDifference(numCalls1, 1) + } - childViewStore.send(()) + func testScopeCallCount2() { + let counterReducer = Reducer { state, _, _ in + state += 1 + return .none + } - XCTAssertNoDifference(values, [0, 1]) - } + var numCalls1 = 0 + var numCalls2 = 0 + var numCalls3 = 0 - func testScopeCallCount() { - let counterReducer = Reducer { state, _, _ in state += 1 - return .none - } + let store1 = Store(initialState: 0, reducer: counterReducer, environment: ()) + let store2 = + store1 + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) + let store3 = + store2 + .scope(state: { (count: Int) -> Int in + numCalls2 += 1 + return count + }) + let store4 = + store3 + .scope(state: { (count: Int) -> Int in + numCalls3 += 1 + return count + }) - var numCalls1 = 0 - _ = Store(initialState: 0, reducer: counterReducer, environment: ()) - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) + _ = ViewStore(store1) + _ = ViewStore(store2) + _ = ViewStore(store3) + let viewStore4 = ViewStore(store4) - XCTAssertNoDifference(numCalls1, 1) - } + XCTAssertNoDifference(numCalls1, 1) + XCTAssertNoDifference(numCalls2, 1) + XCTAssertNoDifference(numCalls3, 1) - func testScopeCallCount2() { - let counterReducer = Reducer { state, _, _ in - state += 1 - return .none - } + viewStore4.send(()) - var numCalls1 = 0 - var numCalls2 = 0 - var numCalls3 = 0 - - let store1 = Store(initialState: 0, reducer: counterReducer, environment: ()) - let store2 = - store1 - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - let store3 = - store2 - .scope(state: { (count: Int) -> Int in - numCalls2 += 1 - return count - }) - let store4 = - store3 - .scope(state: { (count: Int) -> Int in - numCalls3 += 1 - return count - }) - - _ = ViewStore(store1) - _ = ViewStore(store2) - _ = ViewStore(store3) - let viewStore4 = ViewStore(store4) - - XCTAssertNoDifference(numCalls1, 1) - XCTAssertNoDifference(numCalls2, 1) - XCTAssertNoDifference(numCalls3, 1) - - viewStore4.send(()) - - XCTAssertNoDifference(numCalls1, 2) - XCTAssertNoDifference(numCalls2, 2) - XCTAssertNoDifference(numCalls3, 2) - - viewStore4.send(()) - - XCTAssertNoDifference(numCalls1, 3) - XCTAssertNoDifference(numCalls2, 3) - XCTAssertNoDifference(numCalls3, 3) - - viewStore4.send(()) - - XCTAssertNoDifference(numCalls1, 4) - XCTAssertNoDifference(numCalls2, 4) - XCTAssertNoDifference(numCalls3, 4) - - viewStore4.send(()) - - XCTAssertNoDifference(numCalls1, 5) - XCTAssertNoDifference(numCalls2, 5) - XCTAssertNoDifference(numCalls3, 5) - } + XCTAssertNoDifference(numCalls1, 2) + XCTAssertNoDifference(numCalls2, 2) + XCTAssertNoDifference(numCalls3, 2) + + viewStore4.send(()) + + XCTAssertNoDifference(numCalls1, 3) + XCTAssertNoDifference(numCalls2, 3) + XCTAssertNoDifference(numCalls3, 3) + + viewStore4.send(()) - func testSynchronousEffectsSentAfterSinking() { - enum Action { - case tap - case next1 - case next2 - case end + XCTAssertNoDifference(numCalls1, 4) + XCTAssertNoDifference(numCalls2, 4) + XCTAssertNoDifference(numCalls3, 4) + + viewStore4.send(()) + + XCTAssertNoDifference(numCalls1, 5) + XCTAssertNoDifference(numCalls2, 5) + XCTAssertNoDifference(numCalls3, 5) } - var values: [Int] = [] - let counterReducer = Reducer { state, action, _ in - switch action { - case .tap: - return .merge( - Effect(value: .next1), - Effect(value: .next2), - Effect.fireAndForget { values.append(1) } - ) - case .next1: - return .merge( - Effect(value: .end), - Effect.fireAndForget { values.append(2) } - ) - case .next2: - return .fireAndForget { values.append(3) } - case .end: - return .fireAndForget { values.append(4) } + + func testSynchronousEffectsSentAfterSinking() { + enum Action { + case tap + case next1 + case next2 + case end + } + var values: [Int] = [] + let counterReducer = Reducer { state, action, _ in + switch action { + case .tap: + return .merge( + Effect(value: .next1), + Effect(value: .next2), + Effect.fireAndForget { values.append(1) } + ) + case .next1: + return .merge( + Effect(value: .end), + Effect.fireAndForget { values.append(2) } + ) + case .next2: + return .fireAndForget { values.append(3) } + case .end: + return .fireAndForget { values.append(4) } + } } + + let store = Store(initialState: (), reducer: counterReducer, environment: ()) + + _ = store.send(.tap) + + XCTAssertNoDifference(values, [1, 2, 3, 4]) } - let store = Store(initialState: (), reducer: counterReducer, environment: ()) + func testLotsOfSynchronousActions() { + enum Action { case incr, noop } + let reducer = Reducer { state, action, _ in + switch action { + case .incr: + state += 1 + return state >= 100_000 ? Effect(value: .noop) : Effect(value: .incr) + case .noop: + return .none + } + } - store.send(.tap) + let store = Store(initialState: 0, reducer: reducer, environment: ()) + _ = store.send(.incr) + XCTAssertNoDifference(ViewStore(store).state, 100_000) + } - XCTAssertNoDifference(values, [1, 2, 3, 4]) - } + func testIfLetAfterScope() { + struct AppState { + var count: Int? + } - func testLotsOfSynchronousActions() { - enum Action { case incr, noop } - let reducer = Reducer { state, action, _ in - switch action { - case .incr: - state += 1 - return state >= 100_000 ? Effect(value: .noop) : Effect(value: .incr) - case .noop: + let appReducer = Reducer { state, action, _ in + state.count = action return .none } - } - let store = Store(initialState: 0, reducer: reducer, environment: ()) - store.send(.incr) - XCTAssertNoDifference(ViewStore(store).state, 100_000) - } + let parentStore = Store(initialState: AppState(), reducer: appReducer, environment: ()) - func testIfLetAfterScope() { - struct AppState { - var count: Int? - } + // NB: This test needs to hold a strong reference to the emitted stores + var outputs: [Int?] = [] + var stores: [Any] = [] - let appReducer = Reducer { state, action, _ in - state.count = action - return .none - } + parentStore + .scope(state: \.count) + .ifLet( + then: { store in + stores.append(store) + outputs.append(store.state) + }, + else: { + outputs.append(nil) + }) - let parentStore = Store(initialState: AppState(), reducer: appReducer, environment: ()) + XCTAssertNoDifference(outputs, [nil]) - // NB: This test needs to hold a strong reference to the emitted stores - var outputs: [Int?] = [] - var stores: [Any] = [] + _ = parentStore.send(1) + XCTAssertNoDifference(outputs, [nil, 1]) - parentStore - .scope(state: \.count) - .ifLet( - then: { store in - stores.append(store) - outputs.append(store.state) - }, - else: { - outputs.append(nil) - }) + _ = parentStore.send(nil) + XCTAssertNoDifference(outputs, [nil, 1, nil]) - XCTAssertNoDifference(outputs, [nil]) + _ = parentStore.send(1) + XCTAssertNoDifference(outputs, [nil, 1, nil, 1]) - parentStore.send(1) - XCTAssertNoDifference(outputs, [nil, 1]) + _ = parentStore.send(nil) + XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil]) - parentStore.send(nil) - XCTAssertNoDifference(outputs, [nil, 1, nil]) + _ = parentStore.send(1) + XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil, 1]) - parentStore.send(1) - XCTAssertNoDifference(outputs, [nil, 1, nil, 1]) + _ = parentStore.send(nil) + XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil, 1, nil]) + } - parentStore.send(nil) - XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil]) + func testIfLetTwo() { + let parentStore = Store( + initialState: 0, + reducer: Reducer { state, action, _ in + if action { + state? += 1 + return .none + } else { + return SignalProducer(value: true) + .observe(on: QueueScheduler.main) + .eraseToEffect() + } + }, + environment: () + ) - parentStore.send(1) - XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil, 1]) + parentStore + .ifLet(then: { childStore in + let vs = ViewStore(childStore) + + vs + .produced.producer + .startWithValues { _ in } + + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + vs.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + XCTAssertNoDifference(vs.state, 3) + }) + } - parentStore.send(nil) - XCTAssertNoDifference(outputs, [nil, 1, nil, 1, nil, 1, nil]) - } + func testActionQueuing() async { + let subject = Signal.pipe() - func testIfLetTwo() { - let parentStore = Store( - initialState: 0, - reducer: Reducer { state, action, _ in - if action { - state? += 1 - return .none - } else { - return Effect(value: true).observe(on: QueueScheduler.main) - } - }, - environment: () - ) - - parentStore - .ifLet(then: { childStore in - let vs = ViewStore(childStore) - - vs - .produced.producer - .startWithValues { _ in } - - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - vs.send(false) - _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - XCTAssertNoDifference(vs.state, 3) - }) - } + enum Action: Equatable { + case incrementTapped + case `init` + case doIncrement + } - func testActionQueuing() { - let subject = Signal.pipe() + let store = TestStore( + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case .incrementTapped: + subject.input.send(value: ()) + return .none + + case .`init`: + return subject.output.producer + .map { .doIncrement } + .eraseToEffect() + + case .doIncrement: + state += 1 + return .none + } + }, + environment: () + ) - enum Action: Equatable { - case incrementTapped - case `init` - case doIncrement + await store.send(.`init`) + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 1 + } + await store.send(.incrementTapped) + await store.receive(.doIncrement) { + $0 = 2 + } + subject.input.sendCompleted() } - let store = TestStore( - initialState: 0, - reducer: Reducer { state, action, _ in - switch action { - case .incrementTapped: - subject.input.send(value: ()) - return .none + func testCoalesceSynchronousActions() { + let store = Store( + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case 0: + return .merge( + Effect(value: 1), + Effect(value: 2), + Effect(value: 3) + ) + default: + state = action + return .none + } + }, + environment: () + ) - case .`init`: - return subject.output.producer.map { .doIncrement } + var emissions: [Int] = [] + let viewStore = ViewStore(store) + viewStore.produced.producer + .startWithValues { emissions.append($0) } - case .doIncrement: - state += 1 - return .none - } - }, - environment: () - ) - - store.send(.`init`) - store.send(.incrementTapped) - store.receive(.doIncrement) { - $0 = 1 - } - store.send(.incrementTapped) - store.receive(.doIncrement) { - $0 = 2 + XCTAssertNoDifference(emissions, [0]) + + viewStore.send(0) + + XCTAssertNoDifference(emissions, [0, 3]) } - subject.input.sendCompleted() - } - func testCoalesceSynchronousActions() { - let store = Store( - initialState: 0, - reducer: Reducer { state, action, _ in - switch action { - case 0: - return .merge( - Effect(value: 1), - Effect(value: 2), - Effect(value: 3) - ) - default: - state = action - return .none - } - }, - environment: () - ) + func testBufferedActionProcessing() { + struct ChildState: Equatable { + var count: Int? + } - var emissions: [Int] = [] - let viewStore = ViewStore(store) - viewStore.produced.producer - .startWithValues { emissions.append($0) } + let childReducer = Reducer { state, action, _ in + state.count = action + return .none + } - XCTAssertNoDifference(emissions, [0]) + struct ParentState: Equatable { + var count: Int? + var child: ChildState? + } - viewStore.send(0) + enum ParentAction: Equatable { + case button + case child(Int?) + } - XCTAssertNoDifference(emissions, [0, 3]) - } + var handledActions: [ParentAction] = [] + let parentReducer = Reducer.combine([ + childReducer + .optional() + .pullback( + state: \.child, + action: /ParentAction.child, + environment: {} + ), + Reducer { state, action, _ in + handledActions.append(action) + + switch action { + case .button: + state.child = .init(count: nil) + return .none + + case .child(let childCount): + state.count = childCount + return .none + } + }, + ]) - func testBufferedActionProcessing() { - struct ChildState: Equatable { - var count: Int? - } + let parentStore = Store( + initialState: .init(), + reducer: parentReducer, + environment: () + ) - let childReducer = Reducer { state, action, _ in - state.count = action - return .none - } + parentStore + .scope( + state: \.child, + action: ParentAction.child + ) + .ifLet { childStore in + ViewStore(childStore).send(2) + } - struct ParentState: Equatable { - var count: Int? - var child: ChildState? - } + XCTAssertNoDifference(handledActions, []) - enum ParentAction: Equatable { - case button - case child(Int?) + _ = parentStore.send(.button) + XCTAssertNoDifference( + handledActions, + [ + .button, + .child(2), + ]) } - var handledActions: [ParentAction] = [] - let parentReducer = Reducer.combine([ - childReducer - .optional() - .pullback( - state: \.child, - action: /ParentAction.child, - environment: {} - ), - Reducer { state, action, _ in - handledActions.append(action) - + func testCascadingTaskCancellation() async { + enum Action { case task, response, response1, response2 } + let reducer = Reducer { state, action, _ in switch action { - case .button: - state.child = .init(count: nil) - return .none - - case .child(let childCount): - state.count = childCount - return .none + case .task: + return .task { .response } + case .response: + return .merge( + SignalProducer { _, _ in }.eraseToEffect(), + .task { .response1 } + ) + case .response1: + return .merge( + SignalProducer { _, _ in }.eraseToEffect(), + .task { .response2 } + ) + case .response2: + return SignalProducer { _, _ in }.eraseToEffect() } - }, - ]) - - let parentStore = Store( - initialState: .init(), - reducer: parentReducer, - environment: () - ) - - parentStore - .scope( - state: \.child, - action: ParentAction.child - ) - .ifLet { childStore in - ViewStore(childStore).send(2) } - XCTAssertNoDifference(handledActions, []) + let store = TestStore( + initialState: 0, + reducer: reducer, + environment: () + ) - parentStore.send(.button) - XCTAssertNoDifference( - handledActions, - [ - .button, - .child(2), - ]) - } + let task = await store.send(.task) + await store.receive(.response) + await store.receive(.response1) + await store.receive(.response2) + await task.cancel() + } - func testNonMainQueueStore() { - var expectations: [XCTestExpectation] = [] - for i in 1...100 { - let expectation = XCTestExpectation(description: "\(i)th iteration is complete") - expectations.append(expectation) - DispatchQueue.global().async { - let viewStore = ViewStore( - Store.unchecked( - initialState: 0, - reducer: Reducer { state, _, expectation in - state += 1 - if state == 2 { - return .fireAndForget { expectation.fulfill() } - } - return .none - }, - environment: expectation - ) - ) - viewStore.send(()) - DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { - viewStore.send(()) + func testTaskCancellationEmpty() async { + enum Action { case task } + let reducer = Reducer { state, action, _ in + switch action { + case .task: + return .fireAndForget { try await Task.never() } } } + + let store = TestStore( + initialState: 0, + reducer: reducer, + environment: () + ) + + await store.send(.task).cancel() } - wait(for: expectations, timeout: 1) + func testScopeCancellation() async throws { + let neverEndingTask = Task { try await Task.never() } + + let store = Store( + initialState: (), + reducer: Reducer { _, _, _ in + .fireAndForget { + try await neverEndingTask.value + } + }, + environment: () + ) + let scopedStore = store.scope(state: { $0 }) + + let sendTask = scopedStore.send(()) + await Task.yield() + neverEndingTask.cancel() + try await XCTUnwrap(sendTask).value + XCTAssertEqual(store.effectDisposables.count, 0) + XCTAssertEqual(scopedStore.effectDisposables.count, 0) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/TaskCancellationTests.swift b/Tests/ComposableArchitectureTests/TaskCancellationTests.swift new file mode 100644 index 000000000..aecc4f060 --- /dev/null +++ b/Tests/ComposableArchitectureTests/TaskCancellationTests.swift @@ -0,0 +1,26 @@ +import XCTest + +@testable import ComposableArchitecture + +final class TaskCancellationTests: XCTestCase { + func testCancellation() async throws { + cancellationCancellables.removeAll() + enum ID {} + let (stream, continuation) = AsyncStream.streamWithContinuation() + let task = Task { + try await withTaskCancellation(id: ID.self) { + continuation.yield() + continuation.finish() + try await Task.never() + } + } + await stream.first(where: { true }) + await Task.cancel(id: ID.self) + XCTAssertEqual(cancellationCancellables, [:]) + do { + try await task.cancellableValue + XCTFail() + } catch { + } + } +} diff --git a/Tests/ComposableArchitectureTests/TaskResultTests.swift b/Tests/ComposableArchitectureTests/TaskResultTests.swift new file mode 100644 index 000000000..3dda7d6d4 --- /dev/null +++ b/Tests/ComposableArchitectureTests/TaskResultTests.swift @@ -0,0 +1,123 @@ +import ComposableArchitecture +import XCTest + +class TaskResultTests: XCTestCase { + + // `XCTExpectFailure` is not supported on Linux + #if !os(Linux) + func testEqualityNonEquatableError() { + struct Failure: Error { + let message: String + } + + XCTExpectFailure { + XCTAssertNotEqual( + TaskResult.failure(Failure(message: "Something went wrong")), + TaskResult.failure(Failure(message: "Something went wrong")) + ) + } issueMatcher: { + $0.compactDescription == """ + 'Failure' is not equatable. … + + To test two values of this type, it must conform to the 'Equatable' protocol. For example: + + extension Failure: Equatable {} + + See the documentation of 'TaskResult' for more information. + """ + } + } + + func testEqualityMismatchingError() { + struct Failure1: Error { + let message: String + } + struct Failure2: Error { + let message: String + } + + XCTExpectFailure { + XCTAssertNoDifference( + TaskResult.failure(Failure1(message: "Something went wrong")), + TaskResult.failure(Failure2(message: "Something went wrong")) + ) + } issueMatcher: { + $0.compactDescription == """ + XCTAssertNoDifference failed: … + +   TaskResult.failure( + − TaskResultTests.Failure1(message: "Something went wrong") + + TaskResultTests.Failure2(message: "Something went wrong") +   ) + + (First: −, Second: +) + """ + } + } + + func testHashabilityNonHashableError() { + struct Failure: Error { + let message: String + } + + XCTExpectFailure { + _ = TaskResult.failure(Failure(message: "Something went wrong")).hashValue + } issueMatcher: { + $0.compactDescription == """ + 'Failure' is not hashable. … + + To hash a value of this type, it must conform to the 'Hashable' protocol. For example: + + extension Failure: Hashable {} + + See the documentation of 'TaskResult' for more information. + """ + } + } + #endif + + func testEquality_EquatableError() { + enum Failure: Error, Equatable { + case message(String) + case other + } + + XCTAssertEqual( + TaskResult.failure(Failure.message("Something went wrong")), + TaskResult.failure(Failure.message("Something went wrong")) + ) + XCTAssertNotEqual( + TaskResult.failure(Failure.message("Something went wrong")), + TaskResult.failure(Failure.message("Something else went wrong")) + ) + XCTAssertEqual( + TaskResult.failure(Failure.other), + TaskResult.failure(Failure.other) + ) + XCTAssertNotEqual( + TaskResult.failure(Failure.other), + TaskResult.failure(Failure.message("Uh oh")) + ) + } + + func testHashable_HashableError() { + enum Failure: Error, Hashable { + case message(String) + case other + } + + let error1 = TaskResult.failure(Failure.message("Something went wrong")) + let error2 = TaskResult.failure(Failure.message("Something else went wrong")) + let statusByError = Dictionary( + [ + (error1, 1), + (error2, 2), + (.failure(Failure.other), 3), + ], + uniquingKeysWith: { $1 } + ) + + XCTAssertEqual(Set(statusByError.values), [1, 2, 3]) + XCTAssertNotEqual(error1.hashValue, error2.hashValue) + } +} diff --git a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift index ac7fc42fe..5ec3d5265 100644 --- a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift @@ -1,7 +1,9 @@ import ComposableArchitecture import XCTest -#if !os(Linux) // XCTExpectFailure is not supported on Linux +// `XCTExpectFailure` is not supported on Linux / `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor class TestStoreFailureTests: XCTestCase { func testNoStateChangeFailure() { enum Action { case first, second } @@ -17,7 +19,7 @@ import XCTest ) XCTExpectFailure { - store.send(.first) { _ = $0 } + _ = store.send(.first) { _ = $0 } } issueMatcher: { $0.compactDescription == """ Expected state to change, but no change occurred. @@ -50,7 +52,7 @@ import XCTest ) XCTExpectFailure { - store.send(()) { $0.count = 0 } + _ = store.send(()) { $0.count = 0 } } issueMatcher: { $0.compactDescription == """ A state change does not match expectation: … @@ -73,7 +75,7 @@ import XCTest environment: () ) - XCTExpectFailure { + _ = XCTExpectFailure { store.send(()) } issueMatcher: { $0.compactDescription == """ @@ -151,7 +153,7 @@ import XCTest let store = TestStore( initialState: 0, reducer: Reducer { state, action, _ in - .task { try? await Task.sleep(nanoseconds: NSEC_PER_SEC) } + .task { try await Task.sleep(nanoseconds: NSEC_PER_SEC) } }, environment: () ) @@ -166,6 +168,9 @@ import XCTest them complete by the end of the test. There are a few reasons why an effect may not have \ completed: + • If using async/await in your effect, it may need a little bit of time to properly \ + finish. To fix you can simply perform "await store.finish()" at the end of your test. + • If an effect uses a scheduler (via "receive(on:)", "delay", "debounce", etc.), make sure \ that you wait enough time for the scheduler to perform the effect. If you are using a test \ scheduler, advance the scheduler so that the effects may complete, or consider using an \ @@ -259,7 +264,7 @@ import XCTest ) XCTExpectFailure { - store.send(()) { _ in + _ = store.send(()) { _ in struct SomeError: Error {} throw SomeError() } diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift index a2f2ab16f..67750cb6f 100644 --- a/Tests/ComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -2,98 +2,309 @@ import ComposableArchitecture import ReactiveSwift import XCTest -class TestStoreTests: XCTestCase { - func testEffectConcatenation() { - struct State: Equatable {} +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + class TestStoreTests: XCTestCase { + func testEffectConcatenation() async { + struct State: Equatable {} - enum Action: Equatable { - case a, b1, b2, b3, c1, c2, c3, d + enum Action: Equatable { + case a, b1, b2, b3, c1, c2, c3, d + } + + let reducer = Reducer { _, action, scheduler in + switch action { + case .a: + return .merge( + SignalProducer.concatenate(.init(value: .b1), .init(value: .c1)) + .delay(1, on: scheduler) + .eraseToEffect(), + Effect.none + .cancellable(id: 1) + ) + case .b1: + return + SignalProducer + .concatenate(.init(value: .b2), .init(value: .b3)) + .eraseToEffect() + case .c1: + return + SignalProducer + .concatenate(.init(value: .c2), .init(value: .c3)) + .eraseToEffect() + case .b2, .b3, .c2, .c3: + return .none + + case .d: + return .cancel(id: 1) + } + } + + let mainQueue = TestScheduler() + + let store = TestStore( + initialState: State(), + reducer: reducer, + environment: mainQueue + ) + + await store.send(.a) + + await mainQueue.advance(by: 1) + + await store.receive(.b1) + await store.receive(.b2) + await store.receive(.b3) + + await store.receive(.c1) + await store.receive(.c2) + await store.receive(.c3) + + await store.send(.d) } - let testScheduler = TestScheduler() + func testAsync() async { + enum Action: Equatable { + case tap + case response(Int) + } + let store = TestStore( + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case .tap: + return .task { .response(42) } + case let .response(number): + state = number + return .none + } + }, + environment: () + ) - let reducer = Reducer { _, action, scheduler in - switch action { - case .a: - return .merge( - Effect.concatenate(.init(value: .b1), .init(value: .c1)) - .delay(1, on: scheduler), - Effect.none - .cancellable(id: 1) - ) - case .b1: - return - Effect - .concatenate(.init(value: .b2), .init(value: .b3)) - case .c1: - return - Effect - .concatenate(.init(value: .c2), .init(value: .c3)) - case .b2, .b3, .c2, .c3: - return .none - - case .d: - return .cancel(id: 1) + await store.send(.tap) + await store.receive(.response(42)) { + $0 = 42 } } - let store = TestStore( - initialState: State(), - reducer: reducer, - environment: testScheduler - ) + // `XCTExpectFailure` is not supported on Linux + #if !os(Linux) + func testExpectedStateEquality() async { + struct State: Equatable { + var count: Int = 0 + var isChanging: Bool = false + } - store.send(.a) + enum Action: Equatable { + case increment + case changed(from: Int, to: Int) + } - testScheduler.advance(by: 1) + let reducer = Reducer { state, action, scheduler in + switch action { + case .increment: + state.isChanging = true + return Effect(value: .changed(from: state.count, to: state.count + 1)) + case .changed(let from, let to): + state.isChanging = false + if state.count == from { + state.count = to + } + return .none + } + } - store.receive(.b1) - store.receive(.b2) - store.receive(.b3) + let store = TestStore( + initialState: State(), + reducer: reducer, + environment: () + ) - store.receive(.c1) - store.receive(.c2) - store.receive(.c3) + await store.send(.increment) { + $0.isChanging = true + } + await store.receive(.changed(from: 0, to: 1)) { + $0.isChanging = false + $0.count = 1 + } - store.send(.d) - } + XCTExpectFailure { + _ = store.send(.increment) { + $0.isChanging = false + } + } + XCTExpectFailure { + store.receive(.changed(from: 1, to: 2)) { + $0.isChanging = true + $0.count = 1100 + } + } + } - func testStateAccess() { - enum Action { case a, b, c, d } - let store = TestStore( - initialState: 0, - reducer: Reducer { count, action, _ in - switch action { - case .a: - count += 1 - return .merge(Effect(value: .b), Effect(value: .c), Effect(value: .d)) - case .b, .c, .d: - count += 1 - return .none + func testExpectedStateEqualityMustModify() async { + struct State: Equatable { + var count: Int = 0 } - }, - environment: () - ) - store.send(.a) { - $0 = 1 - XCTAssertEqual(store.state, 0) - } - XCTAssertEqual(store.state, 1) - store.receive(.b) { - $0 = 2 + enum Action: Equatable { + case noop, finished + } + + let reducer = Reducer { state, action, scheduler in + switch action { + case .noop: + return Effect(value: .finished) + case .finished: + return .none + } + } + + let store = TestStore( + initialState: State(), + reducer: reducer, + environment: () + ) + + await store.send(.noop) + await store.receive(.finished) + + XCTExpectFailure { + _ = store.send(.noop) { + $0.count = 0 + } + } + XCTExpectFailure { + store.receive(.finished) { + $0.count = 0 + } + } + } + #endif + + func testStateAccess() async { + enum Action { case a, b, c, d } + let store = TestStore( + initialState: 0, + reducer: Reducer { count, action, _ in + switch action { + case .a: + count += 1 + return .merge(Effect(value: .b), Effect(value: .c), Effect(value: .d)) + case .b, .c, .d: + count += 1 + return .none + } + }, + environment: () + ) + + await store.send(.a) { + $0 = 1 + XCTAssertEqual(store.state, 0) + } XCTAssertEqual(store.state, 1) - } - XCTAssertEqual(store.state, 2) - store.receive(.c) { - $0 = 3 + await store.receive(.b) { + $0 = 2 + XCTAssertEqual(store.state, 1) + } XCTAssertEqual(store.state, 2) - } - XCTAssertEqual(store.state, 3) - store.receive(.d) { - $0 = 4 + await store.receive(.c) { + $0 = 3 + XCTAssertEqual(store.state, 2) + } XCTAssertEqual(store.state, 3) + await store.receive(.d) { + $0 = 4 + XCTAssertEqual(store.state, 3) + } + XCTAssertEqual(store.state, 4) } - XCTAssertEqual(store.state, 4) + + // @MainActor + // func testNonDeterministicActions() async { + // struct State: Equatable { + // var count1 = 0 + // var count2 = 0 + // } + // enum Action { case tap, response1, response2 } + // let store = TestStore( + // initialState: State(), + // reducer: Reducer { state, action, _ in + // switch action { + // case .tap: + // return .merge( + // .task { .response1 }, + // .task { .response2 } + // ) + // case .response1: + // state.count1 = 1 + // return .none + // case .response2: + // state.count2 = 2 + // return .none + // } + // }, + // environment: () + // ) + // + // store.send(.tap) + // await store.receive(.response1) { + // $0.count1 = 1 + // } + // await store.receive(.response2) { + // $0.count2 = 2 + // } + // } + + // @MainActor + // func testSerialExecutor() async { + // struct State: Equatable { + // var count = 0 + // } + // enum Action: Equatable { + // case tap + // case response(Int) + // } + // let store = TestStore( + // initialState: State(), + // reducer: Reducer { state, action, _ in + // switch action { + // case .tap: + // return .run { send in + // await withTaskGroup(of: Void.self) { group in + // for index in 1...5 { + // group.addTask { + // await send(.response(index)) + // } + // } + // } + // } + // case let .response(value): + // state.count += value + // return .none + // } + // }, + // environment: () + // ) + // + // store.send(.tap) + // await store.receive(.response(1)) { + // $0.count = 1 + // } + // await store.receive(.response(2)) { + // $0.count = 3 + // } + // await store.receive(.response(3)) { + // $0.count = 6 + // } + // await store.receive(.response(4)) { + // $0.count = 10 + // } + // await store.receive(.response(5)) { + // $0.count = 15 + // } + // } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/TimerTests.swift b/Tests/ComposableArchitectureTests/TimerTests.swift index e05b0e603..502560791 100644 --- a/Tests/ComposableArchitectureTests/TimerTests.swift +++ b/Tests/ComposableArchitectureTests/TimerTests.swift @@ -1,111 +1,125 @@ -import ComposableArchitecture import ReactiveSwift import XCTest -final class TimerTests: XCTestCase { - func testTimer() { - let mainQueue = TestScheduler() +@testable import ComposableArchitecture - var count = 0 +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class TimerTests: XCTestCase { + func testTimer() async { + let mainQueue = TestScheduler() - Effect.timer(id: 1, every: .seconds(1), on: mainQueue) - .startWithValues { _ in count += 1 } + var count = 0 - mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 1) + Effect.timer(id: 1, every: .seconds(1), on: mainQueue) + .producer + .startWithValues { _ in count += 1 } - mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 2) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 1) - mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 3) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 2) - mainQueue.advance(by: 3) - XCTAssertNoDifference(count, 6) - } - - func testInterleavingTimer() { - let mainQueue = TestScheduler() - - var count2 = 0 - var count3 = 0 - - Effect.merge( - Effect.timer(id: 1, every: .seconds(2), on: mainQueue) - .on(value: { _ in count2 += 1 }), - Effect.timer(id: 2, every: .seconds(3), on: mainQueue) - .on(value: { _ in count3 += 1 }) - ) - .start() - - mainQueue.advance(by: 1) - XCTAssertNoDifference(count2, 0) - XCTAssertNoDifference(count3, 0) - mainQueue.advance(by: 1) - XCTAssertNoDifference(count2, 1) - XCTAssertNoDifference(count3, 0) - mainQueue.advance(by: 1) - XCTAssertNoDifference(count2, 1) - XCTAssertNoDifference(count3, 1) - mainQueue.advance(by: 1) - XCTAssertNoDifference(count2, 2) - XCTAssertNoDifference(count3, 1) - } + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 3) - func testTimerCancellation() { - let mainQueue = TestScheduler() + await mainQueue.advance(by: 3) + XCTAssertNoDifference(count, 6) + } - var firstCount = 0 - var secondCount = 0 + func testInterleavingTimer() async { + let mainQueue = TestScheduler() - struct CancelToken: Hashable {} + var count2 = 0 + var count3 = 0 - Effect.timer(id: CancelToken(), every: .seconds(2), on: mainQueue) - .on(value: { _ in firstCount += 1 }) + Effect.merge( + Effect.timer(id: 1, every: .seconds(2), on: mainQueue) + .producer + .on(value: { _ in count2 += 1 }) + .eraseToEffect(), + Effect.timer(id: 2, every: .seconds(3), on: mainQueue) + .producer + .on(value: { _ in count3 += 1 }) + .eraseToEffect() + ) + .producer .start() - mainQueue.advance(by: 2) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count2, 0) + XCTAssertNoDifference(count3, 0) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count2, 1) + XCTAssertNoDifference(count3, 0) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count2, 1) + XCTAssertNoDifference(count3, 1) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count2, 2) + XCTAssertNoDifference(count3, 1) + } - XCTAssertNoDifference(firstCount, 1) + func testTimerCancellation() async { + let mainQueue = TestScheduler() - mainQueue.advance(by: 2) + var firstCount = 0 + var secondCount = 0 - XCTAssertNoDifference(firstCount, 2) + struct CancelToken: Hashable {} - Effect.timer(id: CancelToken(), every: .seconds(2), on: mainQueue) - .on(value: { _ in secondCount += 1 }) - .startWithValues { _ in } + Effect.timer(id: CancelToken(), every: .seconds(2), on: mainQueue) + .producer + .on(value: { _ in firstCount += 1 }) + .start() - mainQueue.advance(by: 2) + await mainQueue.advance(by: 2) - XCTAssertNoDifference(firstCount, 2) - XCTAssertNoDifference(secondCount, 1) + XCTAssertNoDifference(firstCount, 1) - mainQueue.advance(by: 2) + await mainQueue.advance(by: 2) - XCTAssertNoDifference(firstCount, 2) - XCTAssertNoDifference(secondCount, 2) - } + XCTAssertNoDifference(firstCount, 2) + + Effect.timer(id: CancelToken(), every: .seconds(2), on: mainQueue) + .producer + .on(value: { _ in secondCount += 1 }) + .startWithValues { _ in } + + await mainQueue.advance(by: 2) + + XCTAssertNoDifference(firstCount, 2) + XCTAssertNoDifference(secondCount, 1) + + await mainQueue.advance(by: 2) + + XCTAssertNoDifference(firstCount, 2) + XCTAssertNoDifference(secondCount, 2) + } - func testTimerCompletion() { - let mainQueue = TestScheduler() + func testTimerCompletion() async { + let mainQueue = TestScheduler() - var count = 0 + var count = 0 - Effect.timer(id: 1, every: .seconds(1), on: mainQueue) - .take(first: 3) - .startWithValues { _ in count += 1 } + Effect.timer(id: 1, every: .seconds(1), on: mainQueue) + .producer + .take(first: 3) + .startWithValues { _ in count += 1 } - mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 1) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 1) - mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 2) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 2) - mainQueue.advance(by: 1) - XCTAssertNoDifference(count, 3) + await mainQueue.advance(by: 1) + XCTAssertNoDifference(count, 3) - mainQueue.run() - XCTAssertNoDifference(count, 3) + await mainQueue.run() + XCTAssertNoDifference(count, 3) + } } -} +#endif diff --git a/Tests/ComposableArchitectureTests/ViewStoreTests.swift b/Tests/ComposableArchitectureTests/ViewStoreTests.swift index ee5d67799..a3e9e982f 100644 --- a/Tests/ComposableArchitectureTests/ViewStoreTests.swift +++ b/Tests/ComposableArchitectureTests/ViewStoreTests.swift @@ -6,102 +6,81 @@ import XCTest import Combine #endif -final class ViewStoreTests: XCTestCase { - override func setUp() { - super.setUp() - equalityChecks = 0 - subEqualityChecks = 0 - } - - func testPublisherFirehose() { - let store = Store( - initialState: 0, - reducer: Reducer.empty, - environment: () - ) - - let viewStore = ViewStore(store) - - var emissionCount = 0 - viewStore.produced.producer - .startWithValues { _ in emissionCount += 1 } - - XCTAssertNoDifference(emissionCount, 1) - viewStore.send(()) - XCTAssertNoDifference(emissionCount, 1) - viewStore.send(()) - XCTAssertNoDifference(emissionCount, 1) - viewStore.send(()) - XCTAssertNoDifference(emissionCount, 1) - } - - func testEqualityChecks() { - let store = Store( - initialState: State(), - reducer: Reducer.empty, - environment: () - ) - - let store1 = store.scope(state: { $0 }) - let store2 = store1.scope(state: { $0 }) - let store3 = store2.scope(state: { $0 }) - let store4 = store3.scope(state: { $0 }) - - let viewStore1 = ViewStore(store1) - let viewStore2 = ViewStore(store2) - let viewStore3 = ViewStore(store3) - let viewStore4 = ViewStore(store4) - - viewStore1.produced.producer.startWithValues { _ in } - viewStore2.produced.producer.startWithValues { _ in } - viewStore3.produced.producer.startWithValues { _ in } - viewStore4.produced.producer.startWithValues { _ in } - viewStore1.produced.substate.startWithValues { _ in } - viewStore2.produced.substate.startWithValues { _ in } - viewStore3.produced.substate.startWithValues { _ in } - viewStore4.produced.substate.startWithValues { _ in } - - XCTAssertNoDifference(0, equalityChecks) - XCTAssertNoDifference(0, subEqualityChecks) - viewStore4.send(()) - XCTAssertNoDifference(4, equalityChecks) - XCTAssertNoDifference(4, subEqualityChecks) - viewStore4.send(()) - XCTAssertNoDifference(8, equalityChecks) - XCTAssertNoDifference(8, subEqualityChecks) - viewStore4.send(()) - XCTAssertNoDifference(12, equalityChecks) - XCTAssertNoDifference(12, subEqualityChecks) - viewStore4.send(()) - XCTAssertNoDifference(16, equalityChecks) - XCTAssertNoDifference(16, subEqualityChecks) - } - - func testAccessViewStoreStateInPublisherSink() { - let reducer = Reducer { count, _, _ in - count += 1 - return .none +// `@MainActor` introduces issues gathering tests on Linux +#if !os(Linux) + @MainActor + final class ViewStoreTests: XCTestCase { + override func setUp() { + super.setUp() + equalityChecks = 0 + subEqualityChecks = 0 } - let store = Store(initialState: 0, reducer: reducer, environment: ()) - let viewStore = ViewStore(store) - - var results: [Int] = [] + func testPublisherFirehose() { + let store = Store( + initialState: 0, + reducer: Reducer.empty, + environment: () + ) - viewStore.produced.producer - .startWithValues { _ in results.append(viewStore.state) } + let viewStore = ViewStore(store) - viewStore.send(()) - viewStore.send(()) - viewStore.send(()) + var emissionCount = 0 + viewStore.produced.producer + .startWithValues { _ in emissionCount += 1 } - XCTAssertNoDifference([0, 1, 2, 3], results) - } + XCTAssertNoDifference(emissionCount, 1) + viewStore.send(()) + XCTAssertNoDifference(emissionCount, 1) + viewStore.send(()) + XCTAssertNoDifference(emissionCount, 1) + viewStore.send(()) + XCTAssertNoDifference(emissionCount, 1) + } - #if canImport(Combine) - func testWillSet() { - var cancellables: Set = [] + func testEqualityChecks() { + let store = Store( + initialState: State(), + reducer: Reducer.empty, + environment: () + ) + + let store1 = store.scope(state: { $0 }) + let store2 = store1.scope(state: { $0 }) + let store3 = store2.scope(state: { $0 }) + let store4 = store3.scope(state: { $0 }) + + let viewStore1 = ViewStore(store1) + let viewStore2 = ViewStore(store2) + let viewStore3 = ViewStore(store3) + let viewStore4 = ViewStore(store4) + + viewStore1.produced.producer.startWithValues { _ in } + viewStore2.produced.producer.startWithValues { _ in } + viewStore3.produced.producer.startWithValues { _ in } + viewStore4.produced.producer.startWithValues { _ in } + viewStore1.produced.substate.startWithValues { _ in } + viewStore2.produced.substate.startWithValues { _ in } + viewStore3.produced.substate.startWithValues { _ in } + viewStore4.produced.substate.startWithValues { _ in } + + XCTAssertNoDifference(0, equalityChecks) + XCTAssertNoDifference(0, subEqualityChecks) + viewStore4.send(()) + XCTAssertNoDifference(4, equalityChecks) + XCTAssertNoDifference(4, subEqualityChecks) + viewStore4.send(()) + XCTAssertNoDifference(8, equalityChecks) + XCTAssertNoDifference(8, subEqualityChecks) + viewStore4.send(()) + XCTAssertNoDifference(12, equalityChecks) + XCTAssertNoDifference(12, subEqualityChecks) + viewStore4.send(()) + XCTAssertNoDifference(16, equalityChecks) + XCTAssertNoDifference(16, subEqualityChecks) + } + func testAccessViewStoreStateInPublisherSink() { let reducer = Reducer { count, _, _ in count += 1 return .none @@ -112,141 +91,229 @@ final class ViewStoreTests: XCTestCase { var results: [Int] = [] - viewStore.objectWillChange - .sink { _ in results.append(viewStore.state) } - .store(in: &cancellables) + viewStore.produced.producer + .startWithValues { _ in results.append(viewStore.state) } viewStore.send(()) viewStore.send(()) viewStore.send(()) - XCTAssertNoDifference([0, 1, 2], results) - } - #endif - - // disabled as the fix for this would be onerous with - // ReactiveSwift, forcing explicit disposable of any use of - // `ViewStore.produced.producer` - func disabled_testPublisherOwnsViewStore() { - let reducer = Reducer { count, _, _ in - count += 1 - return .none + XCTAssertNoDifference([0, 1, 2, 3], results) } - let store = Store(initialState: 0, reducer: reducer, environment: ()) - var results: [Int] = [] - ViewStore(store) - .produced.producer - .startWithValues { results.append($0) } + #if canImport(Combine) + func testWillSet() { + var cancellables: Set = [] - ViewStore(store).send(()) - XCTAssertNoDifference(results, [0, 1]) - } + let reducer = Reducer { count, _, _ in + count += 1 + return .none + } - func testStorePublisherSubscriptionOrder() { - let reducer = Reducer { count, _, _ in - count += 1 - return .none + let store = Store(initialState: 0, reducer: reducer, environment: ()) + let viewStore = ViewStore(store) + + var results: [Int] = [] + + viewStore.objectWillChange + .sink { _ in results.append(viewStore.state) } + .store(in: &cancellables) + + viewStore.send(()) + viewStore.send(()) + viewStore.send(()) + + XCTAssertNoDifference([0, 1, 2], results) + } + #endif + + // disabled as the fix for this would be onerous with + // ReactiveSwift, forcing explicit disposable of any use of + // `ViewStore.produced.producer` + func disabled_testPublisherOwnsViewStore() { + let reducer = Reducer { count, _, _ in + count += 1 + return .none + } + let store = Store(initialState: 0, reducer: reducer, environment: ()) + + var results: [Int] = [] + ViewStore(store) + .produced.producer + .startWithValues { results.append($0) } + + ViewStore(store).send(()) + XCTAssertNoDifference(results, [0, 1]) } - let store = Store(initialState: 0, reducer: reducer, environment: ()) - let viewStore = ViewStore(store) - var results: [Int] = [] + func testStorePublisherSubscriptionOrder() { + let reducer = Reducer { count, _, _ in + count += 1 + return .none + } + let store = Store(initialState: 0, reducer: reducer, environment: ()) + let viewStore = ViewStore(store) + + var results: [Int] = [] - viewStore.produced.producer - .startWithValues { _ in results.append(0) } + viewStore.produced.producer + .startWithValues { _ in results.append(0) } - viewStore.produced.producer - .startWithValues { _ in results.append(1) } + viewStore.produced.producer + .startWithValues { _ in results.append(1) } - viewStore.produced.producer - .startWithValues { _ in results.append(2) } + viewStore.produced.producer + .startWithValues { _ in results.append(2) } - XCTAssertNoDifference(results, [0, 1, 2]) + XCTAssertNoDifference(results, [0, 1, 2]) - for _ in 0..<9 { - viewStore.send(()) + for _ in 0..<9 { + viewStore.send(()) + } + + XCTAssertNoDifference(results, Array(repeating: [0, 1, 2], count: 10).flatMap { $0 }) } - XCTAssertNoDifference(results, Array(repeating: [0, 1, 2], count: 10).flatMap { $0 }) - } + #if canImport(_Concurrency) && compiler(>=5.5.2) + func testSendWhile() async { + Task { + enum Action { + case response + case tapped + } + let reducer = Reducer { state, action, environment in + switch action { + case .response: + state = false + return .none + case .tapped: + state = true + return SignalProducer(value: .response) + .observe(on: QueueScheduler.main) + .eraseToEffect() + } + } - #if canImport(_Concurrency) && compiler(>=5.5.2) - func testSendWhile() async { - Task { @MainActor in - enum Action { - case response - case tapped + let store = Store(initialState: false, reducer: reducer, environment: ()) + let viewStore = ViewStore(store) + + XCTAssertNoDifference(viewStore.state, false) + await viewStore.send(.tapped, while: { $0 }) + XCTAssertNoDifference(viewStore.state, false) } - let reducer = Reducer { state, action, environment in - switch action { - case .response: - state = false - return .none - case .tapped: - state = true - return Effect(value: .response) - .observe(on: QueueScheduler.main) + } + + func testSuspend() async { + let expectation = self.expectation(description: "await") + Task { + enum Action { + case response + case tapped + } + let reducer = Reducer { state, action, environment in + switch action { + case .response: + state = false + return .none + case .tapped: + state = true + return SignalProducer(value: .response) + .observe(on: QueueScheduler.main) + .eraseToEffect() + } } + + let store = Store(initialState: false, reducer: reducer, environment: ()) + let viewStore = ViewStore(store) + + XCTAssertNoDifference(viewStore.state, false) + _ = { viewStore.send(.tapped) }() + XCTAssertNoDifference(viewStore.state, true) + await viewStore.yield(while: { $0 }) + XCTAssertNoDifference(viewStore.state, false) + } + _ = XCTWaiter.wait(for: [expectation], timeout: 1) + } + + func testAsyncSend() async throws { + enum Action { + case tap + case response(Int) } + let store = Store( + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case .tap: + return .task { + return .response(42) + } + case let .response(value): + state = value + return .none + } + }, + environment: () + ) - let store = Store(initialState: false, reducer: reducer, environment: ()) let viewStore = ViewStore(store) - XCTAssertNoDifference(viewStore.state, false) - await viewStore.send(.tapped, while: { $0 }) - XCTAssertNoDifference(viewStore.state, false) + XCTAssertEqual(viewStore.state, 0) + await viewStore.send(.tap).finish() + XCTAssertEqual(viewStore.state, 42) } - } - func testSuspend() async { - Task { @MainActor in + func testAsyncSendCancellation() async throws { enum Action { - case response - case tapped - } - let reducer = Reducer { state, action, environment in - switch action { - case .response: - state = false - return .none - case .tapped: - state = true - return Effect(value: .response) - .observe(on: QueueScheduler.main) - } + case tap + case response(Int) } + let store = Store( + initialState: 0, + reducer: Reducer { state, action, _ in + switch action { + case .tap: + return .task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return .response(42) + } + case let .response(value): + state = value + return .none + } + }, + environment: () + ) - let store = Store(initialState: false, reducer: reducer, environment: ()) let viewStore = ViewStore(store) - XCTAssertNoDifference(viewStore.state, false) - viewStore.send(.tapped) - XCTAssertNoDifference(viewStore.state, true) - await viewStore.yield(while: { $0 }) - XCTAssertNoDifference(viewStore.state, false) + XCTAssertEqual(viewStore.state, 0) + let task = viewStore.send(.tap) + await task.cancel() + try await Task.sleep(nanoseconds: NSEC_PER_MSEC) + XCTAssertEqual(viewStore.state, 0) } - } - #endif -} + #endif + } -private struct State: Equatable { - var substate = Substate() + private struct State: Equatable { + var substate = Substate() - static func == (lhs: Self, rhs: Self) -> Bool { - equalityChecks += 1 - return lhs.substate == rhs.substate + static func == (lhs: Self, rhs: Self) -> Bool { + equalityChecks += 1 + return lhs.substate == rhs.substate + } } -} -private struct Substate: Equatable { - var name = "Blob" + private struct Substate: Equatable { + var name = "Blob" - static func == (lhs: Self, rhs: Self) -> Bool { - subEqualityChecks += 1 - return lhs.name == rhs.name + static func == (lhs: Self, rhs: Self) -> Bool { + subEqualityChecks += 1 + return lhs.name == rhs.name + } } -} +#endif private var equalityChecks = 0 private var subEqualityChecks = 0